前言
先說個小事情,今天試了下做動圖,就一張動圖都花了我 1 個小時,還做得很難看。。
本文主要內容如下:
上一篇講到如何做性能調優的方法,比如給表加索引、動靜分離、減少不必要的日志打印。但有一個很強大的優化方式沒有提到,那就是加緩存,比如查詢小程序的廣告位配置,因為沒什么人會去頻繁的改,將廣告位配置丟到緩存里面再適合不過了。那我們就給開源 Spring Cloud 實戰項目 PassJava 加下緩存來提升下性能。
我把后端、前端、小程序都上傳到同一個倉庫里面了,大家可以通過 github 或 碼雲訪問。地址如下:
Github: https://github.com/Jackson0714/PassJava-Platform
碼雲:https://gitee.com/jayh2018/PassJava-Platform
配套教程:www.passjava.cn
在實戰之前,我們先來看下使用緩存的原理和問題。
一、緩存
1.1 為什么要用緩存
20 年前常見的系統就是單機的,比如 ERP 系統,對性能要求不高,使用緩存的並不常見,但現如今,已經步入到互聯網時代,高並發、高可用、高性能總是被提起,而緩存在這“三高”中立下汗馬功勞。
我們通過會將部分數據放入緩存中,來提高訪問速度,然后數據庫承擔存儲的工作。
那么哪些數據適合放入緩存中呢?
-
即時性。例如查詢最新的物流狀態信息。
-
數據一致性要求不高。例如門店信息,修改后,數據庫中已經改了,5 分鍾后緩存中才是最新的,但不影響功能使用。
-
訪問量大且更新頻率不高。比如首頁的廣告信息,訪問量,但是不會經常變化。
當我們想要查詢數據時,使用緩存的流程如下:
1.2 本地緩存
最簡單的使用緩存的方式就是用本地緩存。
比如現在有一個需求,前端小程序需要查詢題目的類型,而題目類型放在小程序的首頁在,訪問量是非常高的,但是又不是經常變化的數據,所以可以將題目類型數據放到緩存中。
最簡單的使用緩存的方式是使用本地緩存,也就是在內存中緩存數據,可以用 HashMap、數組等數據結構來緩存數據。
1.2.1 不使用緩存
我們先來看下不使用緩存的情況:前端的請求先經過網關,然后請求到題目微服務,然后查詢數據庫,返回查詢結果。
再來看下核心代碼是怎么樣的。
先自定義一個 Rest API 用來查詢題目類型列表,數據是從數據庫查詢出來后直接返回給前端。
@RequestMapping("/list")
public R list(){
// 從數據庫中查詢數據
typeEntityList = ITypeService.list();
return R.ok().put("typeEntityList", typeEntityList);
}
1.2.2 使用緩存
來看下使用緩存的情況:前端先經過網關,然后到題目微服務,先判斷緩存中有沒有數據,如果沒有,則查詢數據庫再更新緩存,最后返回查詢到的結果。
那我們現在創建一個 HashMap 來緩存題目的類型列表:
private Map<String, Object> cache = new HashMap<>();
先獲取緩存的類型列表
List<TypeEntity> typeEntityListCache = (List<TypeEntity>) cache.get("typeEntityList");
如果緩存中沒有,則先從數據庫中獲取。當然,第一次查詢緩存時,肯定是沒有這個數據的。
// 如果緩存中沒有數據
if (typeEntityListCache == null) {
System.out.println("The cache is empty");
// 從數據庫中查詢數據
List<TypeEntity> typeEntityList = ITypeService.list();
// 將數據放入緩存中
typeEntityListCache = typeEntityList;
cache.put("typeEntityList", typeEntityList);
}
return R.ok().put("typeEntityList", typeEntityListCache);
我們用 Postman 工具來看下查詢結果:
請求URL:https://github.com/Jackson0714/PassJava-Platform
返回了題目類型列表,共 14 條數據。
以后再次查詢時,因為緩存中已經有該數據了,所以直接走緩存,不會再從數據庫中查詢數據了。
從上面的例子中我們可以知道本地緩存有哪些優點呢?
- 減少和數據庫的交互,降低因磁盤 I/O 引起的性能問題。
- 避免數據庫的死鎖問題。
- 加速相應速度。
當然,本地緩存也存在一些問題:
- 占用本地內存資源。
- 機器宕機重啟后,緩存丟失。
- 可能會存在數據庫數據和緩存數據不一致的問題。
- 同一台機器中的多個微服務緩存的數據不一致。
- 集群環境下存在緩存的數據不一致的問題。
基於本地緩存的問題,我們引入了分布式緩存 Redis
來解決。
二、緩存 Redis
2.1 Docker 安裝 Redis
首先需要安裝 Redis,我是通過 Docker 來安裝 Redis。另外我在 ubuntu 和 Mac M1 上都裝過 docker 版的 Redis,大家可以參照這兩篇來安裝。
《Ubuntu 上到 Docker 安裝redis》
《M1 運行 Docker》
2.2 引入 Redis 組件
我用的是 passjava-question 微服務,所以是在 passjava-question 模塊下的配置文件 pom.xml 中引入 redis 組件。
文件路徑:/passjava-question/pom.xml
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
</dependency>
2.3 測試 Redis
我們可以寫一個測試方法來測試引入的 redis 是否能存數據,以及能否查出存的數據。
我們都是使用 StringRedisTemplate
庫來操作 Redis,所以可以自動裝載下 StringRedisTemplate
。
@Autowired
StringRedisTemplate stringRedisTemplate;
然后在測試方法中,測試存儲方法:ops.set(),以及 查詢方法:ops.get()
@Test
public void TestStringRedisTemplate() {
// 初始化 redis 組件
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
// 存儲數據
ops.set("悟空", "悟空聊架構_" + UUID.randomUUID().toString());
// 查詢數據
String wukong = ops.get("悟空");
System.out.println(wukong);
}
set 方法的第一個參數是 key,比如示例中的 “悟空”。
get 方法的參數也是 key。
最后打印出了 redis 中 key = “悟空” 的緩存的值:
另外也可以通過客戶端工具來查看,如下圖所示:
我下載的是這個軟件:Redis Desktop Manager windows下載地址:
http://www.pc6.com/softview/SoftView_450180.html
2.4 用 Redis 改造業務邏輯
用 redis 替換 hashmap 也不難,把用到hashmap 到都用 redis 改下。另外需要注意的是:
從數據庫中查詢到的數據先要序列化
成 JSON 字符串后再存入到 Redis 中,從 Redis 中查詢數據時,也需要將 JSON 字符串反序列化
為對象實例。
public List<TypeEntity> getTypeEntityList() {
// 1.初始化 redis 組件
ValueOperations<String, String> ops = stringRedisTemplate.opsForValue();
// 2.從緩存中查詢數據
String typeEntityListCache = ops.get("typeEntityList");
// 3.如果緩存中沒有數據
if (StringUtils.isEmpty(typeEntityListCache)) {
System.out.println("The cache is empty");
// 4.從數據庫中查詢數據
List<TypeEntity> typeEntityListFromDb = this.list();
// 5.將從數據庫中查詢出的數據序列化 JSON 字符串
typeEntityListCache = JSON.toJSONString(typeEntityListFromDb);
// 6.將序列化后的數據存入緩存中
ops.set("typeEntityList", typeEntityListCache);
return typeEntityListFromDb;
}
// 7.如果緩存中有數據,則從緩存中拿出來,並反序列化為實例對象
List<TypeEntity> typeEntityList = JSON.parseObject(typeEntityListCache, new TypeReference<List<TypeEntity>>(){});
return typeEntityList;
}
整個流程如下:
-
1.初始化 redis 組件。
-
2.從緩存中查詢數據。
-
3.如果緩存中沒有數據,執行步驟 4、5、6。
-
4.從數據庫中查詢數據
-
5.將從數據庫中查詢出的數據轉化為 JSON 字符串
-
6.將序列化后的數據存入緩存中,並返回數據庫中查詢到的數據。
-
7.如果緩存中有數據,則從緩存中拿出來,並反序列化為實例對象
2.5 測試業務邏輯
我們還是用 postman 工具進行測試:
通過多次測試,第一次請求會稍微慢點,后面幾次速度非常快。說明使用緩存后性能有提升。
另外我們用 Redis 客戶端看下結果:
Redis key = typeEntityList,Redis value 是一個 JSON 字符串,里面的內容是題目分類列表。
三、緩存穿透、雪崩、擊穿
高並發下使用緩存會帶來的幾個問題:緩存穿透、雪崩、擊穿。
3.1 緩存穿透
3.1.1 緩存穿透的概念
緩存穿透指一個一定不存在的數據,由於緩存未命中這條數據,就會去查詢數據庫,數據庫也沒有這條數據,所以返回結果是 null
。如果每次查詢都走數據庫,則緩存就失去了意義,就像穿透了緩存一樣。
3.1.2 帶來的風險
利用不存在的數據進行攻擊,數據庫壓力增大,最終導致系統崩潰。
3.1.3 解決方案
對結果 null
進行緩存,並加入短暫的過期時間。
3.2 緩存雪崩
3.2.1 緩存雪崩的概念
緩存雪崩是指我們緩存多條數據時,采用了相同的過期時間,比如 00:00:00 過期,如果這個時刻緩存同時失效,而有大量請求進來了,因未緩存數據,所以都去查詢數據庫了,數據庫壓力增大,最終就會導致雪崩。
3.2.2 帶來的風險
嘗試找到大量 key 同時過期的時間,在某時刻進行大量攻擊,數據庫壓力增大,最終導致系統崩潰。
3.2.3 解決方案
在原有的實效時間基礎上增加一個碎擠汁,比如 1-5 分鍾隨機,降低緩存的過期時間的重復率,避免發生緩存集體實效。
3.3 緩存擊穿
3.3.1 緩存擊穿的概念
某個 key 設置了過期時間,但在正好失效的時候,有大量請求進來了,導致請求都到數據庫查詢了。
3.3.2 解決方案
大量並發時,只讓一個請求可以獲取到查詢數據庫的鎖,其他請求需要等待,查到以后釋放鎖,其他請求獲取到鎖后,先查緩存,緩存中有數據,就不用查數據庫。
四、加鎖解決緩存擊穿
怎么處理緩存穿透、雪崩、擊穿的問題呢?
- 對空結果進行緩存,用來解決緩存穿透問題。
- 設置過期時間,且加上隨機值進行過期偏移,用來解決緩存雪崩問題。
- 加鎖,解決緩存擊穿問題。另外需要注意,加鎖對性能會帶來影響。
這里我們來看下用代碼演示如何解決緩存擊穿問題。
我們需要用 synchronized 來進行加鎖。當然這是本地鎖的方式,分布式鎖我們會在下篇講到。
public List<TypeEntity> getTypeEntityListByLock() {
synchronized (this) {
// 1.從緩存中查詢數據
String typeEntityListCache = stringRedisTemplate.opsForValue().get("typeEntityList");
if (!StringUtils.isEmpty(typeEntityListCache)) {
// 2.如果緩存中有數據,則從緩存中拿出來,並反序列化為實例對象,並返回結果
List<TypeEntity> typeEntityList = JSON.parseObject(typeEntityListCache, new TypeReference<List<TypeEntity>>(){});
return typeEntityList;
}
// 3.如果緩存中沒有數據,從數據庫中查詢數據
System.out.println("The cache is empty");
List<TypeEntity> typeEntityListFromDb = this.list();
// 4.將從數據庫中查詢出的數據序列化 JSON 字符串
typeEntityListCache = JSON.toJSONString(typeEntityListFromDb);
// 5.將序列化后的數據存入緩存中,並返回數據庫查詢結果
stringRedisTemplate.opsForValue().set("typeEntityList", typeEntityListCache, 1, TimeUnit.DAYS);
return typeEntityListFromDb;
}
}
-
1.從緩存中查詢數據。
-
2.如果緩存中有數據,則從緩存中拿出來,並反序列化為實例對象,並返回結果。
-
3.如果緩存中沒有數據,從數據庫中查詢數據。
-
4.將從數據庫中查詢出的數據序列化 JSON 字符串。
-
5.將序列化后的數據存入緩存中,並返回數據庫查詢結果。
五、本地鎖的問題
本地鎖只能鎖定當前服務的線程,如下圖所示,部署了多個題目微服務,每個微服務用本地鎖進行加鎖。
本地鎖在一般情況下沒什么問題,但是當用來鎖庫存就有問題了:
-
1.當前總庫存為 100,被緩存在 Redis 中。
-
2.庫存微服務 A 用本地鎖扣減庫存 1 之后,總庫存為 99。
-
3.庫存微服務 B 用本地鎖扣減庫存 1 之后,總庫存為 99。
-
4.那庫存扣減了 2 次后,還是 99,就超賣了 1 個。
那如何解決本地加鎖的問題呢?
緩存實戰(中篇):實戰分布式鎖。