再亂用緩存,CTO可就發(fā)飆了!
本文轉(zhuǎn)載自微信公眾號(hào)「小姐姐味道」,作者小姐姐養(yǎng)的狗02號(hào) 。轉(zhuǎn)載本文請(qǐng)聯(lián)系小姐姐味道公眾號(hào)。
今天總監(jiān)很生氣,原因是強(qiáng)調(diào)了很多年的緩存同步方案,硬是有人不按常理出牌,挑戰(zhàn)權(quán)威。最終出了問題,這讓總監(jiān)很沒面子。
"一群人拿著大老板的賬號(hào)在哪里瞎測(cè),結(jié)果把數(shù)據(jù)搞出問題來了吧",總監(jiān)牛鼻子里噴著氣,叉著腰說。自從老板的賬號(hào)有一次參與測(cè)試之后,就成了大家心照不宣的終極測(cè)試賬號(hào)。很多人用,硬生生把一個(gè)賬號(hào)的余額操作,給搞成了高并發(fā)的。
"老板的賬號(hào)不就是個(gè)測(cè)試賬號(hào)么...",下面有人小聲的嘀咕。
"現(xiàn)實(shí)中哪會(huì)有賬號(hào)有這樣的密集型操作的...",又有人小聲嘀咕,讓總監(jiān)的臉色越來越沉。
"你們覺得我在開玩笑么?",總監(jiān)紅著眼說,“我就曾經(jīng)因?yàn)檫@樣的數(shù)據(jù)不一致問題,吃過一個(gè)一級(jí)故障。正好,今天就帶你們了解一下,為什么會(huì)有數(shù)據(jù)不一致的情況吧”。
我扶著眼鏡搖搖晃晃的做到臺(tái)下,心中暗笑,總監(jiān)又要把Cache Aside Pattern給科普一遍了。
1. 為什么數(shù)據(jù)會(huì)不一致?
數(shù)據(jù)庫(kù)的瓶頸是大家有目共睹的,高并發(fā)的環(huán)境下,很容易I/O鎖死。當(dāng)務(wù)之急,就是把常用的數(shù)據(jù),給撈到速度更快的存儲(chǔ)里去。這個(gè)更快的存儲(chǔ),就有可能是分布式的,比如Redis,也有可能是單機(jī)的,比如Caffeine。
但一旦加入緩存,就不得不面對(duì)一個(gè)蛋疼的問題:數(shù)據(jù)的一致性。
數(shù)據(jù)不一致的問題,人世間多了去了。進(jìn)修過Java多線程的同學(xué),肯定會(huì)對(duì)JMM的模型記憶猶新。一個(gè)數(shù)值,只要同時(shí)在兩個(gè)地方存儲(chǔ),那就會(huì)產(chǎn)生問題。
但緩存系統(tǒng)和數(shù)據(jù)庫(kù),比JMM更加的不可靠。因?yàn)榉植际浇M件更加的脆弱,它隨時(shí)都可能發(fā)生問題。
2. Cache Aside Pattern
怎樣保證數(shù)據(jù)在DB和緩存中的一致性呢?現(xiàn)在一個(gè)比較好的最佳實(shí)踐方案,就是Cache Aside Pattern。
先來看一下數(shù)據(jù)的讀取過程,規(guī)則是:先讀cache,再讀db 。詳細(xì)步驟如下:
- 每次讀取數(shù)據(jù),都從cache里讀
- 如果讀到了,則直接返回,稱作 cache hit
- 如果讀不到cache的數(shù)據(jù),則從db里面撈一份,稱作cache miss
- 將讀取到的數(shù)據(jù),塞入到緩存中,下次讀取的時(shí)候,就可以直接命中
再來看一下寫請(qǐng)求。規(guī)則是:先更新db,再刪除緩存 。詳細(xì)步驟如下:
- 將變更寫入到數(shù)據(jù)庫(kù)中
- 刪除緩存里對(duì)應(yīng)的數(shù)據(jù)
說到這里,我看著有幾個(gè)人皺起了眉頭。我知道,肯定會(huì)有人不服氣,認(rèn)為自己那一套是對(duì)的。比如,為什么是刪除緩存,不是更新緩存呢?效率會(huì)不會(huì)更低?為什么不先刪除緩存再更新數(shù)據(jù)庫(kù)?
好家伙,他們要向總監(jiān)發(fā)問了。
3. 為什么是刪除緩存,而不是更新緩存?
這個(gè)比較好理解。當(dāng)多個(gè)更新操作同時(shí)到來的時(shí)候,刪除動(dòng)作,產(chǎn)生的結(jié)果是確定的;而更新操作,則可能會(huì)產(chǎn)生不同的結(jié)果。
如圖。兩個(gè)請(qǐng)求A和B,請(qǐng)求B在請(qǐng)求A之后,數(shù)據(jù)是最新的。由于緩存的存在,如果在保存的時(shí)許發(fā)生稍許的偏差,就會(huì)造成A的緩存值覆蓋了B的值,那么數(shù)據(jù)庫(kù)中的記錄值,和緩存中的就產(chǎn)生了不一致,直到下一次數(shù)據(jù)變更。
而使用刪除的方式,由于緩存會(huì)miss,所以會(huì)每次都會(huì)從db中獲取最新的數(shù)據(jù)進(jìn)行填充,與緩存操作的時(shí)機(jī)關(guān)系不大。
4. 為什么不先刪緩存,再更新數(shù)據(jù)庫(kù)?
這個(gè)問題是類似的。我們甚至都不需要并發(fā)寫的場(chǎng)景就能發(fā)現(xiàn)問題。
我們上面提到的緩存刪除動(dòng)作,和數(shù)據(jù)庫(kù)的更新動(dòng)作,明顯是不在一個(gè)事務(wù)里的。如果一個(gè)請(qǐng)求刪除了緩存,同時(shí)有另外一個(gè)請(qǐng)求到來,此時(shí)發(fā)現(xiàn)沒有相關(guān)的緩存項(xiàng),就從數(shù)據(jù)庫(kù)里加載了一份到緩存系統(tǒng)。接下來,數(shù)據(jù)庫(kù)的更新操作也完成了,此時(shí)數(shù)據(jù)庫(kù)的內(nèi)容和緩存里的內(nèi)容,就產(chǎn)生了不一致。
如上圖,寫請(qǐng)求首先刪除了緩存。結(jié)果在這個(gè)時(shí)候,有其他的讀請(qǐng)求,將數(shù)據(jù)庫(kù)的舊值,讀取到數(shù)據(jù)庫(kù)中,此時(shí)緩存中的數(shù)據(jù)是0。接下來更新了DB,將數(shù)據(jù)庫(kù)記錄改為了100。經(jīng)過這么一哆嗦,數(shù)據(jù)庫(kù)和緩存中的數(shù)據(jù),就產(chǎn)生了不一致。
大家都恍然大悟的點(diǎn)點(diǎn)頭,不少人露出了迷之微笑。
5. Spring中的緩存注解
使用 SpringBoot 可以很容易地對(duì) Redis 進(jìn)行操作,Java 的 Redis的客戶端,常用的有三個(gè):jedis、redisson 和 lettuce,Spring 默認(rèn)使用的是 lettuce。
很多人,喜歡使用Spring 抽象的緩存包 spring-cache。
它使用注解,采用 AOP的方式,對(duì) 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 有三個(gè)步驟:
- 在啟動(dòng)類上加入 @EnableCaching 注解;
- 使用 CacheManager 初始化要使用的緩存框架,使用 @CacheConfig 注解注入要使用的資源;
- 使用 @Cacheable 等注解對(duì)資源進(jìn)行緩存。
而針對(duì)緩存操作的注解,有三個(gè):
- @Cacheable 表示如果緩存系統(tǒng)里沒有這個(gè)數(shù)值,就將方法的返回值緩存起來;
- @CachePut 表示每次執(zhí)行該方法,都把返回值緩存起來;
- @CacheEvict 表示執(zhí)行方法的時(shí)候,清除某些緩存值。
那么問題來了,spring-cache中的@CacheEvict注解,到底是先刪緩存,還是后刪緩存呢?不弄明白這一點(diǎn),真的是讓人夜不能寐。關(guān)鍵技術(shù)嘛,不僅要用的開心,也要用的放心。
緩存的移除,是在CacheAspectSupport中實(shí)現(xiàn)的,我們注意到下面的代碼。
- // Process any early evictions
- processCacheEvicts(contexts.get(CacheEvictOperation.class), true,
- CacheOperationExpressionEvaluator.NO_RESULT);
- ...
- // Process any late evictions
- processCacheEvicts(contexts.get(CacheEvictOperation.class), false, cacheValue);
它有一個(gè)前置的清除動(dòng)作,還有后置的清除動(dòng)作,是通過一個(gè)bool變量boolean beforeInvocation進(jìn)行設(shè)置的。這個(gè)值從哪里來的呢?還是得看@CacheEvict注解。
- /**
- * Whether the eviction should occur before the method is invoked.
- * <p>Setting this attribute to {@code true}, causes the eviction to
- * occur irrespective of the method outcome (i.e., whether it threw an
- * exception or not).
- * <p>Defaults to {@code false}, meaning that the cache eviction operation
- * will occur <em>after</em> the advised method is invoked successfully (i.e.,
- * only if the invocation did not throw an exception).
- */
- boolean beforeInvocation() default false;
很好很好,它的默認(rèn)值是false,證明刪除動(dòng)作是滯后的,踐行的也是Cache Aside Pattern。
6. 還有其他模式?
我聽說,還有Read Through Pattern,Write Through Pattern,Write Behind Caching Pattern等其他常見的緩存同步模式,為什么不用這些呢? 有位同學(xué)的屁股一直在椅子上來回挪動(dòng),躍躍欲試,逮住機(jī)會(huì),他終于發(fā)言了。
其實(shí),這些方式使用的也非常廣泛,但由于對(duì)業(yè)務(wù)大多數(shù)是無感知的,所以很多人都忽略了。換句話說,這幾個(gè)模式,大多數(shù)是在一些中間件,或者比較底層的數(shù)據(jù)庫(kù)中實(shí)現(xiàn)的,寫業(yè)務(wù)代碼可能接觸不到這些東西。
比如,Read Through,其實(shí)就是讓你對(duì)讀操作感知不到緩存層的存在。通常情況下,你會(huì)手動(dòng)實(shí)現(xiàn)緩存的載入,但Read Through可能就有代理層給你捎帶著做了。
再比如,Write Through,你不用再考慮數(shù)據(jù)庫(kù)和緩存是不是同步了,代理層都給你做了,你只管往里塞數(shù)據(jù)就行。
Read Through和Write Through是不沖突的,它們可以同時(shí)存在,這樣業(yè)務(wù)層的代碼里就沒有同步這個(gè)概念了。爽歪歪。
至于Write Behind Caching,意思就是先落地到緩存,然后有異步線程緩慢的將緩存中的數(shù)據(jù)落地到DB中。要用這個(gè)東西,你得評(píng)估一下你的數(shù)據(jù)是否可以丟失,以及你的緩存容量是否能夠經(jīng)得起業(yè)務(wù)高峰的考驗(yàn)?,F(xiàn)在的操作系統(tǒng)、DB、甚至消息隊(duì)列如Kafaka等,都會(huì)在一定程度上踐行這個(gè)模式。
但它現(xiàn)在和我們的業(yè)務(wù)需求沒半點(diǎn)關(guān)系。
7. Cache Aside Pattern也有問題
總監(jiān)上馬,一個(gè)頂倆,科普了這半天,所有的同學(xué)都心服口服。正在大家想要把掌聲送給總監(jiān)的時(shí)候,一個(gè)不和諧的聲音傳來了。
我發(fā)現(xiàn)了一個(gè)天大的問題。 有同學(xué)說, 如果數(shù)據(jù)庫(kù)更新成功了,但緩存刪除失敗了,也會(huì)造成緩存不一致。
這個(gè)問題問的好啊,故障大多數(shù)就是由于這些極端情況造成的。這個(gè)時(shí)候就有意思了,我們要拼概率,畢竟沒有100%的安全套。 總監(jiān)笑了。
方法一:將數(shù)據(jù)更新和緩存刪除動(dòng)作,放在一個(gè)事務(wù)里,同進(jìn)退。
方法二:緩存刪除動(dòng)作失敗后,重試一定的次數(shù)。如果還是不行,大概率是緩存服務(wù)的故障,這時(shí)候要記錄日志,在緩存服務(wù)恢復(fù)正常的時(shí)候?qū)⑦@些key刪除掉
方法三:再多一步操作,先刪緩存,再更新數(shù)據(jù),再刪緩存。這樣雖然操作多一些,但也更保險(xiǎn)一些。
是不是沒有問題了? 總監(jiān)環(huán)顧四周,看到大家都在點(diǎn)頭。No no no,依然還有數(shù)據(jù)不一致的情況。
所有人都一頭霧水。
上面那張看起來正確的圖,其實(shí)是錯(cuò)誤的。為什么呢?因?yàn)閿?shù)據(jù)在從數(shù)據(jù)庫(kù)讀到緩存中的操作,并不是原子性的。
比如上圖,當(dāng)緩存失效(或者被刪除)的時(shí)候,有一個(gè)讀請(qǐng)求正好到來。這個(gè)讀請(qǐng)求,拿到了舊的數(shù)據(jù)庫(kù)值,但它由于多方面的原因(比如網(wǎng)絡(luò)抽風(fēng)),沒有立馬寫入到緩存中,而是發(fā)生了延遲。在它打算寫入到緩存的這段時(shí)間,發(fā)生了很多事情,有另外一個(gè)請(qǐng)求,將數(shù)據(jù)庫(kù)的值更新為200,并刪除了緩存。
直到第二個(gè)請(qǐng)求全部完成,第一個(gè)請(qǐng)求寫入緩存的操作,才真正落地。但其實(shí),這時(shí)候數(shù)據(jù)庫(kù)和緩存的值,已經(jīng)不是同步的了。
那么為什么大家在平常的設(shè)計(jì)中,幾乎把這個(gè)場(chǎng)景給忽略掉了呢?因?yàn)樗l(fā)生的概率實(shí)在太低了。它要求在讀取數(shù)據(jù)的時(shí)候,有兩個(gè)或者多個(gè)并發(fā)寫操作(或者發(fā)生了數(shù)據(jù)失效),這在實(shí)際的應(yīng)用場(chǎng)景中實(shí)在是太少了。而且,我們要注意虛線所持續(xù)的周期,是一個(gè)數(shù)據(jù)庫(kù)的更新操作,加上一個(gè)cache的刪除操作,這個(gè)操作一般情況下,也會(huì)比緩存的設(shè)置持續(xù)的時(shí)間長(zhǎng),所以進(jìn)一步降低了概率。
所以,你們知道正確的操作方式了么? 總監(jiān)問。
知道了!以后我們就用spring-cache的注解去完成工作,再也不在代碼中手寫一致性邏輯了。
很好很好,如果這么做的話,再發(fā)生問題,好像可以把鍋甩給spring團(tuán)隊(duì)了呢。
作者簡(jiǎn)介:小姐姐味道 (xjjdog),一個(gè)不允許程序員走彎路的公眾號(hào)。聚焦基礎(chǔ)架構(gòu)和Linux。十年架構(gòu),日百億流量,與你探討高并發(fā)世界,給你不一樣的味道。我的個(gè)人微信xjjdog0,歡迎添加好友,進(jìn)一步交流。