目錄
- Spring注解使用,控制Redis緩存更新
- 緩存一致性問題是如何產(chǎn)生的?
- 雙更新模式:操作不合理,導(dǎo)致數(shù)據(jù)一致性問題
- “后刪緩存”,能解決多數(shù)不一致
- 大廠高并發(fā),“后刪緩存”依舊不一致
- 如何解決高并發(fā)的不一致問題?延遲雙刪與閃電緩存
- 如何解決緩存擊穿?讀操作互斥與集中更新
Redis 是現(xiàn)在互聯(lián)網(wǎng)中使用最廣泛的分布式緩存系統(tǒng),幾乎每家公司都在用。它的 qps 可以達(dá)到10萬每秒,吞吐量還是非常可觀的,對于一般體量的互聯(lián)網(wǎng)公司,一臺機(jī)器就夠了。但不論是什么業(yè)務(wù),都不得不面對一個棘手的問題:那就是Redis和源數(shù)據(jù)的一致性問題。
一、Spring注解使用,控制Redis緩存更新
使用 SpringBoot 可以很容易地對 Redis 進(jìn)行操作。Java 的 Redis 的客戶端常用的有三個:jedis、redisson、lettuce。其中,Spring 默認(rèn)使用的是 lettuce。
很多人喜歡使用 Spring 抽象的緩存包 spring-cache,它可以使用注解,非常方便。它的注解采用 AOP 的方式,對 Cache 層進(jìn)行了抽象,可以在各種堆內(nèi)緩存框架和分布式框架之間進(jìn)行切換。
我們來看一下它的 maven 坐標(biāo):
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-cache</artifactId>
</dependency>
使用spring-cache有三個步驟:
- 在啟動類上加上@EnableCaching 注解;
- 使用 CacheManager 初始化要使用的緩存框架,使用 @CacheConfig 注解注入要使用的資源;
- 使用@Cacheable等注解對資源進(jìn)行緩存。而針對緩存操作的注解有三個:
@Cacheable 表示如果緩存系統(tǒng)里沒有這個數(shù)值,就將方法的返回值緩存起來;
@CachePut 表示每次執(zhí)行該方法,都把返回值緩存起來;
@CacheEvict 表示執(zhí)行方法的時候,清除某些緩存值。
二、緩存一致性問題是如何產(chǎn)生的?
在說緩存一致性問題如何產(chǎn)生的前,我們先看下緩存的API操作,緩存操作和數(shù)據(jù)庫的CRUD結(jié)合起來,大致可以抽象成以下幾個方法:
- getFromDB(key)
- getFromRedis(key)
- putToDB(key,value)
- putToRedis(key,value)
- deleteFromDB(key)
- deleteFromRedis(key)
把Redis當(dāng)緩存使用,就說明Redis是不合適作為落地存儲的。
一般我們是把最終的數(shù)據(jù)存放在數(shù)據(jù)庫中的,,一般情況下,Redis 的操作速度比數(shù)據(jù)庫的操作速度快得多。畢竟是 10wQPS 和上千 QPS 的對比。
上面這些 API 很簡單,但把它們的順序調(diào)整一下,一致性就會出現(xiàn)問題。一致性,簡單說就是“數(shù)據(jù)庫里的數(shù)據(jù)”與“Redis 中的數(shù)據(jù)”不一樣了。
對于讀的過程,一般是沒什么異議的。
- 首先,讀緩存;
- 如果緩存沒有值,那就讀取數(shù)據(jù)庫的值;
- 同時把這個值寫進(jìn)緩存中;
我們下面主要看一下寫模式。
三、雙更新模式:操作不合理,導(dǎo)致數(shù)據(jù)一致性問題
我們來看下常見的一個錯誤編碼方式,這些是代碼 review 時要著重看的點(diǎn),也是常出問題的地方。
public void putValue(key,value){
putToRedis(key,value);
putToDB(key,value);//操作失敗了
}
比如我們需要更新一個值,首先刷了緩存,然后把數(shù)據(jù)庫也更新了。但更新數(shù)據(jù)庫過程中出現(xiàn)了異常,發(fā)生了回滾。所以,最后“緩存里的數(shù)據(jù)”和“數(shù)據(jù)庫的數(shù)據(jù)”就不一樣了,也就是出現(xiàn)了數(shù)據(jù)不一致的問題。
那如果先更新數(shù)據(jù)庫,再更新緩存呢?如代碼:
public void putValue(key,value){
putToDB(key,value);
putToRedis(key,value);
}
這依然會有問題。
考慮到下面的場景:操作 A 更新 a 的值為 1,操作 B 更新 a 的值為 2。由于數(shù)據(jù)庫和 Redis 的操作,并不是原子的,它們的執(zhí)行時長也不是可控制的。當(dāng)兩個請求的時序發(fā)生了錯亂,就會發(fā)生緩存不一致的情況。
放到實(shí)操中來說:A 操作在更新數(shù)據(jù)庫成功后,再更新 Redis;但在更新 Redis 之前,另外一個更新操作 B 執(zhí)行完畢。那么操作 A 的這個 Redis 更新動作,就和數(shù)據(jù)庫里面的值不一樣了。
其實(shí)雙更新模式的問題,主要不是體現(xiàn)在并發(fā)的一致性上,而是業(yè)務(wù)操作的合理性上。
我們大多數(shù)業(yè)務(wù)代碼并沒有經(jīng)過良好的設(shè)計(jì)。一個緩存的值,可能是多條數(shù)據(jù)庫記錄拼湊或計(jì)算得出來的。比如一個余額操作,可能是“錢包里的值”加上“基金里的值”計(jì)算得出來的。
要是采用“更新”的方式,那這個計(jì)算代碼就分散在項(xiàng)目的多個地方,這就不合理了。
那么怎么辦呢?其實(shí),我們把“緩存更新”改成“刪除”就好了。
四、“后刪緩存”,能解決多數(shù)不一致
因?yàn)槊看巫x取時,如果判斷 Redis 里沒有值,就會重新讀取數(shù)據(jù)庫,這個邏輯是沒問題的。唯一的問題是:我們是先刪除緩存?還是后刪除緩存?
答案是后者!
1、如果先刪緩存
我們來看一下先刪除緩存會有什么問題:
public void putValue(key,value){
deleteFromRedis(key);
putToDB(key,value);
}
操作 B 刪除了某個 key 的值,這時候有另外一個請求 A 到來,那么它就會擊穿到數(shù)據(jù)庫,讀取到舊的值。無論操作 B 更新數(shù)據(jù)庫的操作持續(xù)多長時間,都會產(chǎn)生不一致的情況。
2、如果后刪緩存
而把刪除的動作放在后面,就能夠保證每次讀到的值都是新鮮的,從數(shù)據(jù)庫里面拿到最新的。
public void putValue(key,value){
putToDB(key,value);
deleteFromRedis(key);
}
這就是我們通常說的Cache-Aside Pattern,也是我們平常使用最多的模式。我們看一下它的具體方式。
先看一下數(shù)據(jù)的讀取過程,規(guī)則是“先讀 cache,再讀 db”,詳細(xì)步驟如下:
- 每次讀取數(shù)據(jù),都從 cache 里讀;
- 如果讀到了,則直接返回,稱作 cache hit;
- 如果讀不到 cache 的數(shù)據(jù),則從 db 里面撈一份,稱作 cache miss;
- 將讀取到的數(shù)據(jù)塞入到緩存中,下次讀取時,就可以直接命中。
再來看一下寫請求,規(guī)則是“先更新 db,再刪除緩存”,詳細(xì)步驟如下:
- 將變更寫入到數(shù)據(jù)庫中;
- 刪除緩存里對應(yīng)的數(shù)據(jù)。
為什么說最常用呢?因?yàn)?Spring cache 就是默認(rèn)實(shí)現(xiàn)了這個模式。
五、大廠高并發(fā),“后刪緩存”依舊不一致
所以在高并發(fā)情況下,Cache Aside Pattern會不夠用。下面就描述一個“先更新再刪除”這種場景下,依然會產(chǎn)生不一致的情況。場景很好理解、很極端,但在高并發(fā)多實(shí)例的情況下很常見。
有一系列的高并發(fā)操作,一直執(zhí)行著更新、刪除的動作。某個時刻,它更新數(shù)據(jù)庫的值為 1,然后刪除了緩存。
public void proccess(key,value){
N:putToDB(key,1);
N:deleteFromRedis(key);
A:getFromRedis(key);
A:getFromDB(key)=1;
B:putToDB(key,2);
B:deleteFromRedis(key);
A:putToRedis(key,1);
//DB=2,Redis=1
}
正在這時,有兩個請求發(fā)生了:
-
一個是讀操作,讀到的當(dāng)然是數(shù)據(jù)庫的舊值 1,我們記作操作 A;
-
同時,另外一個請求發(fā)起了更新操作,把數(shù)據(jù)庫記錄更新為 2,我們記作操作 B。
一般情況下,讀取操作都是比寫入操作快的,但我們要考慮兩種極端情況:
-
一種是這個讀取操作 A,發(fā)生在更新操作 B 的尾部;
-
一種是操作 A 的這個 Redis 的操作時長,耗費(fèi)了非常多的時間。比如,這個節(jié)點(diǎn)正好發(fā)生了 STW。
那么很容易地,讀操作 A 的結(jié)束時間就超過了操作 B 刪除的動作。就像上圖虛線部分畫的一樣,這個時候,數(shù)據(jù)也是不一致的。
實(shí)際上,你也無法控制它們的執(zhí)行順序。只要發(fā)生這種情況,大概率數(shù)據(jù)庫和 Redis 的值會不一致。
但為什么一般公司不去處理這種情況呢?你仔細(xì)看這張圖,它發(fā)生的條件是非??量痰摹K笤谝幌盗小安l(fā)寫”的同時,還有“并發(fā)讀”的參與。而一般業(yè)務(wù)是達(dá)不到這個量級的,所以一般公司不去處理這種情況,但高并發(fā)業(yè)務(wù)就非常常見了。
六、如何解決高并發(fā)的不一致問題?
大家看上面這種不一致情況發(fā)生的場景,歸根結(jié)底還是“刪除操作”發(fā)生在“更新操作”之前了。
1、延時雙刪
而假如有一種機(jī)制,能夠確保刪除動作一定被執(zhí)行,那就可以解決問題,起碼能縮小數(shù)據(jù)不一致的時間窗口。常用的方法就是延時雙刪,依然是先更新再刪除,唯一不同的是:我們把這個刪除動作,在不久之后再執(zhí)行一次,比如 5 秒之后。
public void putValue(key,value){
putToDB(key,value);
deleteFromRedis(key);
...deleteFromRedis(key,after5sec);
}
而刪除動作也有多種選擇:
-
如果放在 DelayQueue 中,會有隨著 JVM 進(jìn)程的死亡,丟失更新的風(fēng)險;
-
如果放在 MQ 中,會增加編碼的復(fù)雜性。
所以到了這個時候,并沒有一個能夠行走天下的解決方案。我們得綜合評價很多因素去做設(shè)計(jì),比如團(tuán)隊(duì)的水平、工期、不一致的忍受程度等。
2、閃電緩存
還有一種不太常用的,那就是采用閃電緩存。就是把緩存的失效時間設(shè)置非常短,比如 3~4 秒。一旦失效,就會再次去數(shù)據(jù)庫讀取最新數(shù)據(jù)到緩存。但這種方式,在非常高的并發(fā)下,同一時間對某個 key 的請求擊穿到 DB,會鎖死數(shù)據(jù)庫,所以很少用。
對于一般并發(fā)場景,上面的各種修修補(bǔ)補(bǔ),已經(jīng)把不一致問題降低到很小的概率了。但是它仍然是有問題的,因?yàn)樗肓艘粋€高可用問題:緩存擊穿。
七、如何解決緩存擊穿?
緩存擊穿,指的是緩存中沒有數(shù)據(jù)但數(shù)據(jù)庫中有,由于同一時刻請求量特別大,但是沒有讀到緩存數(shù)據(jù),就會一股腦涌入到數(shù)據(jù)庫中讀取,造成數(shù)據(jù)庫假死。
任何刪除緩存的動作都會造成緩存擊穿。
所以我們上面一直說的是要刪除緩存,但在極高并發(fā)下,你還不能亂刪。
你反過頭去看一下,好像我們一開始雙更的方案比 Cache-Aside Pattern 還要靠譜一些,起碼能用。怎么回事?代碼還能不能寫了?這就是業(yè)務(wù)開發(fā)中的特事特辦,要專門針對這種功能進(jìn)行編碼。場景特殊時,代碼也就不要追求極端優(yōu)雅性了,畢竟也沒有萬能的解決方案。
這時,盤點(diǎn)一下我們手頭上的工具,可以看到有兩種不同的解決方式:
-
讀操作互斥,使用鎖或者分布式鎖來控制;
-
更新集中,采用定時或者 binlog 的方式同步更新。
1、讀操作互斥
先來看一下鎖操作。我們依然采用 Cache-Aside Pattern,只不過在讀的時候進(jìn)行一下處理。來看一下偽代碼,從 Redis 讀取不到值的時候,我們要上鎖去從數(shù)據(jù)庫中讀這個值。我們這里默認(rèn)這個值是有的,否則就得處理緩存穿透的問題。
get(key){
res = getFromRedis(key);
//讀取緩存為null
if(null == res){
lock.lock(...);
//再次讀取緩存為null
res = getFromRedis(key);
if(res == null){
res = getFromDB(key);
if(null != res){
//讀取設(shè)值
putToRedis(key,res);
}
}
lock.unlock();
}
return res;
}
getFromDB(key){
...
}
使用分布式鎖和非分布式鎖的主要區(qū)別,還是在于數(shù)據(jù)一致性窗口上:
-
對于多線程鎖來說,可能某些節(jié)點(diǎn)執(zhí)行得非常慢,更新了舊的值到 Redis;
-
對于分布式鎖來說,肯定又是一個效率上的話題。
2、集中更新
我們再來看一下集中更新。這個很美好,但大多數(shù)業(yè)務(wù)很復(fù)雜,這對業(yè)務(wù)架構(gòu)的前期設(shè)計(jì)要求非常高。比如通過 Binlog 方式,典型的如 Canal。我們不會在代碼里做任何 Redis 更新的操作,而是會設(shè)計(jì)一個服務(wù),訂閱最新的 binlog 更新信息,然后解析它們,主動去更新緩存。這個一般在大并發(fā)大廠才會采用。
還有一種就是弱化數(shù)據(jù)庫。所有的數(shù)據(jù)首先在 Redis 落地,也就是把 Redis 作為數(shù)據(jù)庫使用,把數(shù)據(jù)庫作為備份庫使用。有定時任務(wù),定期把 Redis 中的數(shù)據(jù),保存到數(shù)據(jù)庫或其他地方。文章來源:http://www.zghlxwxcb.cn/news/detail-416682.html
一般,重要業(yè)務(wù)還要配備一個對賬系統(tǒng),定時去掃描,以便快速發(fā)現(xiàn)不一致的情況。文章來源地址http://www.zghlxwxcb.cn/news/detail-416682.html
到了這里,關(guān)于緩存一致性設(shè)計(jì)思路的文章就介紹完了。如果您還想了解更多內(nèi)容,請?jiān)谟疑辖撬阉鱐OY模板網(wǎng)以前的文章或繼續(xù)瀏覽下面的相關(guān)文章,希望大家以后多多支持TOY模板網(wǎng)!