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