美團(tuán)2面:如何保障 MySQL 和 Redis 數(shù)據(jù)一致性?這樣答,虐爆面試官
最近,有個(gè)小伙伴美團(tuán),2面又遇到了這個(gè)問題。
這里,給大家梳理一個(gè)科書式的答案。
首先,什么是Cache-Aside Pattern(旁路緩存模式)?
Cache-Aside Pattern(旁路緩存)模式,又叫旁路路由策略,在這種模式中,讀取緩存、讀取數(shù)據(jù)庫和更新緩存的操作都是在應(yīng)用程序中完成。此模式是業(yè)務(wù)系統(tǒng)最常用的緩存策略。
旁路緩存又模式分為讀緩存和寫緩存。
旁路緩存模式在讀的時(shí)候,先讀緩存,緩存命中的話,直接返回?cái)?shù)據(jù);如果緩存沒有命中的話,就去讀數(shù)據(jù)庫,從數(shù)據(jù)庫取出數(shù)據(jù),放入緩存后,同時(shí)返回響應(yīng)。
Cache-Aside Pattern(旁路緩存)模式讀操作流程,具體如下:
step 1:應(yīng)用程序接收用戶的數(shù)據(jù)查詢的請(qǐng)求;
step 2:應(yīng)用程序優(yōu)先從緩存查找數(shù)據(jù);
step 3:如果存在(cache hit),從緩存上查詢出來,返回查詢到數(shù)據(jù);
Step 4:如果不存在(cache miss),從數(shù)據(jù)庫中查詢數(shù)據(jù)并存入緩存中,返回查詢到數(shù)據(jù)。
Cache-Aside Pattern(旁路緩存)模式讀操作流程,具體如下圖所示:

圖:Cache-Aside Pattern(旁路緩存)模式讀操作流程
Cache-Aside Pattern(旁路緩存)模式寫操作流程,具體如下:
step 1:接收用戶的數(shù)據(jù)寫入的請(qǐng)求;
step 2:先寫入數(shù)據(jù)庫;
step 3:再寫入緩存。
Cache-Aside Pattern(旁路緩存)模式寫操作流程,具體如下圖所示:

圖:Cache-Aside Pattern(旁路緩存)模式寫操作流程
數(shù)據(jù)什么時(shí)候從數(shù)據(jù)庫(如Mysql集群)加載到緩存(如Redis集群)呢?有以下兩種加載模式可被選擇:懶漢模式、餓漢模式。懶漢模式、餓漢模式可以理解為及時(shí)加載模式、延遲加載模式。
所謂懶漢模式,就會(huì)在使用時(shí)臨時(shí)加載緩存。具體來說,就是當(dāng)需要使用數(shù)據(jù)時(shí),就從數(shù)據(jù)庫中把它查詢出來,然后寫入緩存。第一次查詢之后,后續(xù)的請(qǐng)求都能從緩存中查詢到數(shù)據(jù)。
所謂餓漢模式,就是提前預(yù)加載緩存。具體來說,在項(xiàng)目啟動(dòng)的時(shí)候,預(yù)加載數(shù)據(jù)到緩存。當(dāng)需要使用數(shù)據(jù)時(shí),能直接從緩存獲取數(shù)據(jù),而不需要從數(shù)據(jù)獲取。
餓漢模式,提前預(yù)加載數(shù)據(jù)到緩存的時(shí)機(jī),能極大地提升請(qǐng)求處理的性能力,極大地提升系統(tǒng)的吞吐量。此模式,適合于緩存那些不是經(jīng)常變更的數(shù)據(jù)(例如商品類目數(shù)據(jù)),或者那些訪問非常頻繁的極熱數(shù)據(jù)(例如秒殺商品數(shù)據(jù))。
Cache-Aside如何保證雙寫的數(shù)據(jù)一致性?
Cache-Aside是日常開發(fā)中使用最多的緩存層高并發(fā)訪問模式。所以,面試官也喜歡圍繞這種模式進(jìn)行發(fā)問。一個(gè)非常高頻的問題是:Cache-Aside在寫入的時(shí)候,為什么是刪除緩存而不是更新緩存呢。而且,很多大廠也喜歡問這個(gè)領(lǐng)域的問題,下面就是一道來自于社群的美團(tuán)真題。
美團(tuán)面試題
Cache-Aside如何保證DB和Cache雙寫的數(shù)據(jù)一致性?
要完美的回答這個(gè)問題,咱們把Cache-Aside模式(旁路緩存模式)下的DB和Cache雙寫的策略,做一個(gè)系統(tǒng)化的梳理,大概分為如下五大策略。
策略一:先更數(shù)據(jù)庫,再更緩存
策略二:先刪緩存,再更新數(shù)據(jù)庫
策略三:先更數(shù)據(jù)庫,再刪緩存
策略四:延遲雙刪策略
策略五:邏輯刪除策略
策略六:先更數(shù)據(jù)庫,再基于隊(duì)列刪緩存
如果能在面試的時(shí)候,把其中每一種策略的角色功能、適用場(chǎng)景、執(zhí)行流程、優(yōu)勢(shì)弱點(diǎn)、改進(jìn)策略進(jìn)行系統(tǒng)化、體系化的陳述,無論是那個(gè)廠,無論是什么頂級(jí)的大廠,一定會(huì)對(duì)候選人的能力有十分的認(rèn)可。
這里的內(nèi)容,來自于《Java高并發(fā)核心編程 卷3加強(qiáng)版》
有關(guān)6中策略的代碼實(shí)操介紹,請(qǐng)參見 100Wqps三級(jí)緩存組件實(shí)操
策略一:先更數(shù)據(jù)庫,再更緩存
在實(shí)際的業(yè)務(wù)場(chǎng)景中,一種常見的并發(fā)場(chǎng)景是:微服務(wù)Provider實(shí)例A、B同時(shí)進(jìn)行同一個(gè)數(shù)據(jù)的更新操作。按照先更數(shù)據(jù)庫,再更緩存的策略,則微服務(wù)Provider實(shí)例A、B可能會(huì)出現(xiàn)下面的執(zhí)行次序:
step 1:微服務(wù)A去執(zhí)行update DB
step 2:微服務(wù)B去執(zhí)行update DB
step 3:微服務(wù)B去執(zhí)行update Cache
step 4:微服務(wù)A去執(zhí)行update Cache
上面的執(zhí)行流程,具體如下圖所示:

圖:先更數(shù)據(jù)庫,再更緩存的并發(fā)執(zhí)行案例
上面的執(zhí)行流程,是典型的并發(fā)寫入場(chǎng)景。
在圖中的并發(fā)寫入的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B也進(jìn)行數(shù)據(jù)的寫入。
最終的結(jié)果是:DB中的數(shù)據(jù)是Provider B的數(shù)據(jù),Cache中的數(shù)據(jù)是Provider A的數(shù)據(jù),出現(xiàn)DB和Cache數(shù)據(jù)不一致問題。
具體的原因是:Provider B的更新在Cache中的數(shù)據(jù),被Provider A的更新在Cache中的數(shù)據(jù)覆蓋了。DB的更新次序先A后B,理論上Cache中的數(shù)據(jù)更新也應(yīng)該是先A后B。理論上,最終Cache中的數(shù)據(jù)應(yīng)該是Provider B的數(shù)據(jù),而不是Provider A的數(shù)據(jù)。所以,在流程執(zhí)行完畢后,緩存中的Provider A的數(shù)據(jù)為臟數(shù)據(jù)。
而之出現(xiàn)這個(gè)問題,是因?yàn)橐陨狭鞒讨衧tep 3與step 4的執(zhí)行均為操作緩存,都是高并發(fā)的操作,很難保證先后次序,所以緩存出現(xiàn)臟數(shù)據(jù)的概率很大。
為何不更新緩存而是刪除緩存?
核心面試題
一個(gè)非常高頻的問題是:Cache-Aside在寫入的時(shí)候,為什么是刪除緩存而不是更新緩存呢?
回到上一節(jié)的例子,在圖中的并發(fā)寫入的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B也進(jìn)行數(shù)據(jù)的寫入。
在這個(gè)例子中,寫入DB的次序如下:
Provider A先發(fā)起一個(gè)寫操作,第一步先更新數(shù)據(jù)庫
Provider B再發(fā)起一個(gè)寫操作,第二步更新了數(shù)據(jù)庫
現(xiàn)在,由于分布式系統(tǒng),無法保證并發(fā)操作的有序性,寫入Cache的次序可能如下:
Provider B先發(fā)起一個(gè)Cache寫操作,第一步先更新Cache
Provider A再發(fā)起一個(gè)Cache寫操作,第二步更新了Cache
這時(shí)候,Cache保存的是Provider A的數(shù)據(jù)(老數(shù)據(jù)),DB保存的是B的數(shù)據(jù)(新數(shù)據(jù)),于是發(fā)生了DB和Cache數(shù)據(jù)不一致,Cache中出現(xiàn)臟數(shù)據(jù)。
如果使用刪除操作取代更新操作,則Cache不會(huì)出現(xiàn)上面的臟數(shù)據(jù)問題。具體如下圖所示:

圖:為何不更新緩存而是刪除緩存
除了能夠減少臟數(shù)據(jù)之外,更新緩存相對(duì)于刪除緩存,還有兩點(diǎn)劣勢(shì):
(1)如果寫入Cache的值,是經(jīng)過復(fù)雜計(jì)算才得到的話。更新緩存頻率高的話,就會(huì)大大降低性能。
(2)及時(shí)更新緩存屬于餓漢模式,適用于數(shù)據(jù)讀取高頻的場(chǎng)景。在寫多讀少的情況下,數(shù)據(jù)很多時(shí)候還沒被讀取到,又被更新了,這也浪費(fèi)了Cache的空間,也降低了性能。
策略二:先刪緩存,再更新數(shù)據(jù)庫
在實(shí)際的業(yè)務(wù)場(chǎng)景中,一種常見的并發(fā)場(chǎng)景是:微服務(wù)Provider實(shí)例A進(jìn)行數(shù)據(jù)的寫入,而服務(wù)Provider實(shí)例 B同時(shí)進(jìn)行同一個(gè)數(shù)據(jù)的讀取操作。按照先刪緩存,再更新數(shù)據(jù)庫的策略,則微服務(wù)Provider實(shí)例A、B可能會(huì)出現(xiàn)下面的執(zhí)行次序:
step 1:微服務(wù)A去執(zhí)行delete Cache
step 2:微服務(wù)B去執(zhí)行l(wèi)oad from DB
step 3:微服務(wù)B去執(zhí)行update Cache
step 4:微服務(wù)A去執(zhí)行update DB
上面的執(zhí)行流程,具體如下圖所示:

圖:先刪緩存,再更新數(shù)據(jù)庫的并發(fā)執(zhí)行案例
上面的執(zhí)行流程,是典型的并發(fā)讀寫場(chǎng)景。
在圖中的并發(fā)讀寫的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B進(jìn)行數(shù)據(jù)的查詢。
最終,DB中的數(shù)據(jù)是Provider A的更新數(shù)據(jù),Cache中的數(shù)據(jù)是Provider B從DB加載的數(shù)據(jù),而這個(gè)數(shù)據(jù)已經(jīng)過時(shí),出現(xiàn)DB和Cache數(shù)據(jù)不一致問題。
具體的原因是:Provider B查詢Cache的時(shí)候,Cache中的數(shù)據(jù)被刪除,Provider B只能去DB查找,然后將數(shù)據(jù)更新在Cache。而Provider A在Provider B查完之后,竟然更新了DB,導(dǎo)致了DB和Cache的不一致。
出現(xiàn)這個(gè)DB和Cache的不一致問題的根本原因,大致如下:
寫操作是先刪Cache(操作1)再寫DB(操作2),如果在此期間發(fā)生并發(fā)讀,讀取的動(dòng)作很容易發(fā)生操作1、操作2的中間,從而讀取到過時(shí)的數(shù)據(jù),最終導(dǎo)致Cache和DB不一致。更為嚴(yán)重的時(shí)候,讀操作把過期數(shù)據(jù)刷入Cache后,會(huì)導(dǎo)致后面比較長時(shí)間的不一致。這個(gè)時(shí)間,一直持續(xù)到緩存過期,如說4個(gè)小時(shí)(以項(xiàng)目中的配置時(shí)間為準(zhǔn))。
上面的Cache和DB不一致,將導(dǎo)致一個(gè)嚴(yán)重的后面:后續(xù)的讀取操作,都會(huì)使用Cache中的數(shù)據(jù),所以,后面的讀取操作都會(huì)使用過時(shí)數(shù)據(jù)。
這里的內(nèi)容,來自于《Java高并發(fā)核心編程 卷3加強(qiáng)版》 (注意,是加強(qiáng)版)的 策略2
策略2的代碼實(shí)操介紹,請(qǐng)參見 尼恩的 100Wqps三級(jí)緩存組件實(shí)操
策略三:先更數(shù)據(jù)庫,再刪緩存
先更數(shù)據(jù)庫,再刪緩存,基本上可以解決并發(fā)讀寫場(chǎng)景中,Cache和DB數(shù)據(jù)不一致的問題。
但是,在一些特殊的場(chǎng)景中,還是會(huì)存在數(shù)據(jù)不一致的問題。
一種非常特殊的并發(fā)場(chǎng)景是:
微服務(wù)Provider實(shí)例A進(jìn)行數(shù)據(jù)的寫入操作,先寫DB(操作1),再刪Cache(操作2),如果由于某種原因出現(xiàn)了卡頓,沒有及時(shí)把數(shù)據(jù)放入Cache,或者說放入Cache的操作,簡單的說,操作2發(fā)生了滯后。
此時(shí),服務(wù)Provider實(shí)例 B進(jìn)行一個(gè)數(shù)據(jù)的讀取操作,讀取的次序仍然是先讀Cache,再讀DB,很容易發(fā)生DB和Cache的不一致性。
按照先更數(shù)據(jù)庫,再刪緩存的策略,則微服務(wù)Provider實(shí)例A、B可能會(huì)出現(xiàn)下面的執(zhí)行次序:
step 1:微服務(wù)A去執(zhí)行update DB
step 2:微服務(wù)B去執(zhí)行l(wèi)oad from Cache
step 3:微服務(wù)A去執(zhí)行delete Cache,但是發(fā)生了延遲
上面的執(zhí)行流程,具體如下圖所示:

圖:先更數(shù)據(jù)庫,再刪緩存的并發(fā)執(zhí)行案例
在圖中的并發(fā)讀寫的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B進(jìn)行數(shù)據(jù)的查詢。
微服務(wù)Provider實(shí)例A先寫DB(操作1),再刪Cache(操作2),如果Provider實(shí)例A發(fā)生卡頓、或者網(wǎng)絡(luò)延遲等異常的問題,導(dǎo)致操作2嚴(yán)重滯后。在操作2執(zhí)行完成之前,DB和Cache的數(shù)據(jù)是不一致的。
在此期間,其他的數(shù)據(jù)讀取操作,都會(huì)讀取Cache中的過期數(shù)據(jù),出現(xiàn)DB和Cache數(shù)據(jù)不一致問題。
出現(xiàn)這個(gè)DB和Cache的不一致問題的根本原因,大致如下:
寫操作是先寫DB(操作1)再刪Cache(操作2),如果在此期間發(fā)生并發(fā)讀,讀操作很容易發(fā)生操作1、操作2的中間,從而,并發(fā)讀操作從Cache讀取到過時(shí)的數(shù)據(jù),最終導(dǎo)致Cache和DB不一致。
但是等到寫操作刪除Cache(操作2)的動(dòng)作執(zhí)行完成之后,Cache和DB的數(shù)據(jù),會(huì)恢復(fù)一致性。
無論如何,策略三(先寫DB再刪Cache),比策略二(先刪Cache再寫DB)發(fā)生數(shù)據(jù)不一致的時(shí)間短。相比較而言,推薦大家使用策略三,而不是策略二。
那么,策略三的問題是啥呢?
(1)寫DB(操作1)和刪Cache(操作2)之間,存在短時(shí)間的數(shù)據(jù)不一致;
(2)如果刪Cache失敗,存在較長時(shí)間的數(shù)據(jù)不一致,這個(gè)時(shí)間會(huì)一直持續(xù)到Cache過期;
如何解決策略三中Cache刪除失敗所導(dǎo)致的DB和Cache較長時(shí)間的數(shù)據(jù)不一致呢?可以使用策略四:延遲雙刪。
策略四:延遲雙刪策略
什么是延遲雙刪呢?延遲雙刪是基于策略二進(jìn)行改進(jìn),就是先刪Cache,后寫DB,最后延遲一定時(shí)間,再次刪Cache。
在實(shí)際的業(yè)務(wù)場(chǎng)景中,一種常見的并發(fā)場(chǎng)景是:微服務(wù)Provider實(shí)例A進(jìn)行數(shù)據(jù)的寫入,而服務(wù)Provider實(shí)例 B同時(shí)進(jìn)行同一個(gè)數(shù)據(jù)的讀取操作。按照先刪Cache,后寫DB,最后延遲一定時(shí)間,再次刪Cache策略,則微服務(wù)Provider實(shí)例A、B可能會(huì)出現(xiàn)下面的執(zhí)行次序:
step 1:微服務(wù)A去執(zhí)行delete Cache
step 2:微服務(wù)B去執(zhí)行l(wèi)oad from DB
step 3:微服務(wù)B去執(zhí)行update Cache
step 4:微服務(wù)A去執(zhí)行update DB
step 5:微服務(wù)A去執(zhí)行 delay delete Cache
上面的執(zhí)行流程,具體如下圖所示:

圖:先刪Cache,后寫DB,再次延遲刪Cache的并發(fā)執(zhí)行案例
在圖中的并發(fā)讀寫的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B進(jìn)行數(shù)據(jù)的查詢。
微服務(wù)Provider實(shí)例A先刪Cache(操作1),再寫DB(操作2),最后再二次延遲刪除Cache(操作3)。在操作2之前,如果發(fā)生并發(fā)讀,從DB讀取到過時(shí)數(shù)據(jù),可能出現(xiàn)DB和Cache數(shù)據(jù)不一致問題。
出現(xiàn)這個(gè)DB和Cache的不一致問題的根本原因,大致如下:
寫操作是先刪Cache(操作1)再寫DB(操作2),如果在此期間發(fā)生并發(fā)讀,讀操作容易發(fā)生操作1、操作2的中間,從DB讀到過時(shí)數(shù)據(jù),最終導(dǎo)致Cache和DB不一致。但是,這一輪的數(shù)據(jù)不一致,持續(xù)時(shí)間不會(huì)太長。為啥呢?寫操作還有一個(gè)兜底的動(dòng)作:二次延遲刪除Cache(操作3),從而保證數(shù)據(jù)一致。
所以,延遲雙刪也會(huì)存在數(shù)據(jù)不一致,不過是持續(xù)時(shí)間比較短而已。
那么,策略四的問題是啥呢?
(1)如果寫操作比較頻繁,可能會(huì)對(duì)Redis造成一定的壓力;
(2)極端情況下,第二次延遲刪Cache失敗,操作的效果退化到策略二。DB和Cache存在較長時(shí)間的數(shù)據(jù)不一致,這個(gè)時(shí)間會(huì)一直持續(xù)到Cache過期,比如說4個(gè)小時(shí)(以項(xiàng)目中的配置時(shí)間為準(zhǔn))。
如何解決策略四的以上兩個(gè)問題呢?可以使用策略五:先更數(shù)據(jù)庫,再基于隊(duì)列刪緩存。
策略五:邏輯刪除/邏輯過期的問題
首先什么是邏輯過期時(shí)間呢。
邏輯代表什么,假的刪除,不是真正的刪除。而是空間換時(shí)間,設(shè)置一些額外的標(biāo)志
比如:
在存儲(chǔ)數(shù)據(jù)的時(shí)候加個(gè)字段,比如 logicExpireTime 給它設(shè)置值。
這個(gè)值,跟我們緩存key的有效時(shí)間肯定不一樣。
比如,永不過期
比如,當(dāng)前時(shí)間再加上 幾個(gè)小時(shí) 轉(zhuǎn)為時(shí)間戳的方式跟數(shù)據(jù)一起存入redis。
邏輯過期時(shí)間= 業(yè)務(wù)過期時(shí)間
物理過期時(shí)間= 邏輯過期時(shí)間 + 高并發(fā)冗余時(shí)間
查詢的時(shí)候,檢查 logicExpireTime ,如果發(fā)現(xiàn)到時(shí)間了,
另外有一個(gè)緩存的重建線程,進(jìn)行異步重建
更新的時(shí)候, 更改 邏輯過期時(shí)間 = 當(dāng)前時(shí)間
策略六:先更數(shù)據(jù)庫,再基于隊(duì)列刪緩存
來到策略六:先更數(shù)據(jù)庫,再基于隊(duì)列刪緩存。那么,如何基于任務(wù)隊(duì)列刪緩存呢?實(shí)質(zhì)上,策略六是基于策略三進(jìn)行改進(jìn)。首先回顧一下策略三的問題?
(1)寫DB(操作1)和刪Cache(操作2)之間,存在短時(shí)間的數(shù)據(jù)不一致;
(2)如果刪Cache失敗,存在較長時(shí)間的數(shù)據(jù)不一致,這個(gè)時(shí)間會(huì)一直持續(xù)到Cache過期;
策略六主要的操作次序,和策略三保持一致,依然是先寫DB后刪除Cache。不同的是,策略六引入隊(duì)列,把刪Cache的操作加入隊(duì)列,后臺(tái)會(huì)有一個(gè)異步線程、或者進(jìn)程去異步消費(fèi)隊(duì)列中的刪除任務(wù),去執(zhí)行刪Cache的操作。
基于隊(duì)列刪緩存,可以細(xì)分為:
第1種細(xì)分的方案:基于內(nèi)存隊(duì)列刪除緩存
第2種細(xì)分的方案:基于消息隊(duì)列刪除緩存
第3種細(xì)分的方案:基于binlog+消息隊(duì)列刪除緩存
首先來看第一種細(xì)分的方案:基于內(nèi)存隊(duì)列刪除緩存。
此策略把刪Cache的操作加入任務(wù)隊(duì)列,后臺(tái)會(huì)有一個(gè)異步線程去異步消費(fèi)任務(wù)隊(duì)列里面的刪除任務(wù),去執(zhí)行刪Cache的操作,如果緩存刪除失敗,可以重試多次,確保刪除成功。
在實(shí)際的業(yè)務(wù)場(chǎng)景中,一種常見的并發(fā)場(chǎng)景是:微服務(wù)Provider實(shí)例A進(jìn)行數(shù)據(jù)的寫入,而服務(wù)Provider實(shí)例 B同時(shí)進(jìn)行同一個(gè)數(shù)據(jù)的讀取操作。Provider實(shí)例A先寫DB,然后將刪Cache加入任務(wù)隊(duì)列;Provider實(shí)例 B則是先讀緩存,沒有數(shù)據(jù)再讀DB。微服務(wù)Provider實(shí)例A、B可能會(huì)出現(xiàn)下面的執(zhí)行次序:
step 1:微服務(wù)A去執(zhí)行update DB
step 2:微服務(wù)A將delete Cache操作進(jìn)入任務(wù)隊(duì)列
step 3:微服務(wù)B去執(zhí)行l(wèi)oad from Cache
step 4:消費(fèi)線程從任務(wù)隊(duì)列提取delete Cache操作,執(zhí)行刪除Cache的操作,直到刪除成功。
上面的執(zhí)行流程,具體如下圖所示:

圖:先更數(shù)據(jù)庫,后基于內(nèi)存隊(duì)列刪緩存的并發(fā)執(zhí)行案例
在圖中的并發(fā)讀寫的場(chǎng)景中,Provider A進(jìn)行數(shù)據(jù)的寫入,Provider B進(jìn)行數(shù)據(jù)的查詢。
微服務(wù)Provider實(shí)例A先寫DB(操作1),再將刪Cache操作加入任務(wù)隊(duì)列(操作2)。在刪除Cache操作真正執(zhí)行完成之前,其他的數(shù)據(jù)讀取操作,都會(huì)讀取Cache中的過期數(shù)據(jù),出現(xiàn)DB和Cache數(shù)據(jù)不一致問題。但是這種不一致,是短暫的。任務(wù)隊(duì)列的消費(fèi)線程,會(huì)異步執(zhí)行刪除Cache的任務(wù),并且會(huì)不斷重試確保成功,刪除Cache之后,DB和Cache數(shù)據(jù)不一致問題就會(huì)得到解決。
說 明
保存刪除Cache任務(wù)的隊(duì)列,建議使用阻塞隊(duì)列。任務(wù)隊(duì)列的消費(fèi)線程,可參考Rocketmq源碼中的ServiceThread異步服務(wù)線程,其設(shè)計(jì)思想和執(zhí)行性能都非常優(yōu)越。后面尼恩會(huì)通過視頻,介紹一下基于隊(duì)列刪除緩存的實(shí)操。
策略六也會(huì)出現(xiàn)這個(gè)DB和Cache的不一致問題,尤其是如果寫操作非常頻繁,隊(duì)列的任務(wù)比較多,可能消費(fèi)會(huì)比較慢,導(dǎo)致DB和Cache的不一致的時(shí)間會(huì)延長。在這種情況下,可以根據(jù)任務(wù)隊(duì)列的擁塞程度,開啟多個(gè)線程,提升并發(fā)執(zhí)行的效率。
與策略四相比,策略六的優(yōu)勢(shì)是:
(1)在寫操作比較頻繁的場(chǎng)景,策略四有兩次刪Cache操作,可能會(huì)對(duì)Redis造成一定的壓力;策略六只有一次刪Cache操作,Redis壓力小一半。
(2)策略四如果刪Cache失敗,沒有引入重試策略;策略六會(huì)多次重試,確保刪Cache成功,如果重試多次仍然不成功,可以執(zhí)行運(yùn)維預(yù)警。
(3)策略四將寫DB、刪Cache這兩個(gè)操作耦合在了一起,沒有很好的做到單一職責(zé);策略六將寫DB、刪Cache兩個(gè)操作解耦,模塊職責(zé)更加單一。
那么,策略六的問題是啥呢?
(1)如果寫操作非常頻繁,隊(duì)列的任務(wù)比較多,可能消費(fèi)會(huì)比較慢;需要引入多線程機(jī)制,加快消費(fèi)速度。
(2)程序復(fù)雜度成倍上升,引入消費(fèi)線程、任務(wù)隊(duì)列,并且還需要不斷進(jìn)行性能優(yōu)化。
(3)內(nèi)存隊(duì)列是JVM進(jìn)程的內(nèi)部隊(duì)列,如果JVM崩潰,內(nèi)存隊(duì)列沒有來得及處理的Cache記錄刪除任務(wù)會(huì)丟失,這些數(shù)據(jù)的Cache記錄和DB記錄會(huì)長時(shí)間不一致。
其次,來看第二種細(xì)分的方案:基于消息隊(duì)列刪除緩存。
在前面的第一種細(xì)分方案中,將刪除Cache的任務(wù)保存在內(nèi)存隊(duì)列,并不是高可靠的。
為了保證高可靠的刪除Cache記錄,這里引入高可用的獨(dú)立組件——Rocketmq消息隊(duì)列。需要注意的是,這里引入的RocketMq消息隊(duì)列是高可用的類型消息隊(duì)列,不是單節(jié)點(diǎn)的類型消息隊(duì)列,從而保障消息記錄的高可用,保障Cache的刪除操作只要沒有被執(zhí)行成功,就不會(huì)丟失。
引入高可用RocketMq消息隊(duì)列之后,執(zhí)行雙寫操作的Provider A的操作流程,有小幅度的調(diào)整。Provider A需要將刪除Cache的操作,序列化成Rocketmq消息,然后寫入高可用Rocketmq消息隊(duì)列中間件即可。然后,由專門的消費(fèi)者(Cache Delete Consumer)進(jìn)行消息的消費(fèi),根據(jù)消息內(nèi)容執(zhí)行Cache記錄刪除工作。
DB和Redis雙寫的場(chǎng)景下,Provider A先更數(shù)據(jù)庫,后基于消息隊(duì)列刪緩存的并發(fā)執(zhí)行案例的執(zhí)行流程,具體如下圖所示:

圖:先更數(shù)據(jù)庫,后基于消息隊(duì)列刪緩存的并發(fā)執(zhí)行案例
引入高可用的獨(dú)立組件RocketMq消息隊(duì)列之后,Provider A的寫入邏輯變得很簡單,刪Cache的時(shí)候,只需要發(fā)送消息到RocketMq即可,大大簡化了Provider A程序的寫入邏輯。只是為了保證消息的高可靠傳遞,這里Provider A在發(fā)送消息的時(shí)候,需要使用同步發(fā)送模式,而不能使用異步發(fā)送的模式。
在消息投遞的環(huán)節(jié),由RocketMq高可用組件的ACK機(jī)制保證消息的高可靠投遞。如果消息第一次消費(fèi)失敗,RocketMq會(huì)重復(fù)多次進(jìn)行投遞,確保消息被正常消費(fèi),如果一直不能被成功消費(fèi),在重復(fù)投遞一定的次數(shù)之后(默認(rèn)16次),消息會(huì)進(jìn)入死信隊(duì)列。系統(tǒng)的監(jiān)控程序會(huì)對(duì)死信隊(duì)列進(jìn)行監(jiān)控,一旦發(fā)現(xiàn)死信消息,監(jiān)控程序會(huì)進(jìn)行運(yùn)維告警,由運(yùn)維人員解決最終的緩存刪除問題。除非Redis集群崩潰,一般都不會(huì)出現(xiàn)這樣的極端情況。
和基于內(nèi)存隊(duì)列刪除緩存,基于消息隊(duì)列刪除緩存的方案的優(yōu)勢(shì)是:
增加了Cache刪除的可靠性,避免了因JVM崩潰所導(dǎo)致的內(nèi)存隊(duì)列中的記錄丟失的問題。
那么,Provider在執(zhí)行DB和Cache雙寫時(shí),能不能進(jìn)一步減少雙寫的負(fù)擔(dān),將發(fā)送刪除Cache消息的操作,從雙寫邏輯中剝離,交給其他的組件去完成呢?答案是可以的。具體來說,就是使用基于基于binlog+消息隊(duì)列去刪除Cache的方案。
最后,來看第三種細(xì)分的方案:基于binlog+消息隊(duì)列刪除緩存。
以Mysql為例,可以使用阿里的Canal中間件,采集在數(shù)據(jù)寫入Mysql時(shí)生成的binlog日志,然后將日志發(fā)送到RocketMq隊(duì)列。在消費(fèi)端,可以編寫一個(gè)專門的消費(fèi)者(Cache Delete Consumer)完成緩存binlog日志訂閱,篩選出其中的更新類型log,解析之后進(jìn)行對(duì)應(yīng)Cache的刪除操作,并且通過RocketMq隊(duì)列ACK機(jī)制確認(rèn)處理這條更新log,保證Cache刪除能夠得到最終的刪除。
DB和Redis雙寫的場(chǎng)景下,Provider A先更數(shù)據(jù)庫,后基于基于binlog+消息隊(duì)列刪除緩存的并發(fā)執(zhí)行案例的執(zhí)行流程,具體如下圖所示:

圖:先更數(shù)據(jù)庫,后基于消息隊(duì)列刪緩存的并發(fā)執(zhí)行案例
基于binlog+消息隊(duì)列去刪除Cache的方案的優(yōu)勢(shì)是:
微服務(wù)Provider在執(zhí)行DB和Cache雙寫時(shí),只需要執(zhí)行寫入DB的操作就可以了,大大簡化了微服務(wù)Provider的業(yè)務(wù)邏輯。Cache的刪除工作已經(jīng)完全被Canal、RocketMq、專門的消費(fèi)者(Cache Delete Consumer)三者相互結(jié)合去接管了。
如何選型呢
這么多的Cache-Aside如何保證雙寫的數(shù)據(jù)一致性方案,改如何選型呢?只有更合適、沒有最合適。大家可以根據(jù)項(xiàng)目和團(tuán)隊(duì)的情況選擇最合適的。具體的方案選型,大家可以在高并發(fā)社群——瘋狂創(chuàng)客圈的微信群里邊交流。
這里對(duì)應(yīng)到 《Java高并發(fā)核心編程 卷3 加強(qiáng)版》的 策略5,寫書的時(shí)候, 沒有寫邏輯刪除策略
策略6的代碼實(shí)操介紹,請(qǐng)參見 尼恩的 100Wqps三級(jí)緩存組件實(shí)操
基于隊(duì)列的方案,主要有兩種:
第2種細(xì)分的方案:基于消息隊(duì)列刪除緩存
第3種細(xì)分的方案:基于binlog+消息隊(duì)列刪除緩存
如何選型呢?
很多小伙伴選擇基于binlog+消息隊(duì)列刪除緩存, 但是這種方案, 在很多場(chǎng)景下是不可以使用的。具體原因比較復(fù)雜,請(qǐng)參考尼恩的 100Wqps三級(jí)緩存組件實(shí)操,里邊有詳細(xì)的介紹。
從CAP視角分析DB與Cache的數(shù)據(jù)一致性
CAP理論作為分布式系統(tǒng)的基礎(chǔ)理論,它描述的是一個(gè)分布式系統(tǒng)在以下三個(gè)特性中:
一致性(Consistency)
可用性(Availability)
分區(qū)容錯(cuò)性(Partition tolerance)
分布式系統(tǒng)最多滿足其中的兩個(gè)特性:要么滿足CA,要么CP,要么AP,無法同時(shí)滿足CAP。也就是說AP和CP是一組天敵,要滿足AP高性能,只能舍棄CP。
在DB和Cache的分布式架構(gòu)中,加入分布式Cache的目的是為了獲得高性能、高吞吐,就是為了獲得分布式系統(tǒng)的AP特性。所以,如果需要數(shù)據(jù)庫和緩存數(shù)據(jù)保持強(qiáng)一致(強(qiáng)CP特性),就不適合使用緩存。
所以,從CAP的理論出發(fā),使用緩存提升性能,就是會(huì)有數(shù)據(jù)更新的延遲,就會(huì)產(chǎn)生數(shù)據(jù)的不一致。
使用分布式Cache,可以通過一些方案優(yōu)化,保證弱一致性,最終一致性的。我們只能通過不斷的方案迭代,減少不一致性的時(shí)間長度。這需要Cache設(shè)計(jì)時(shí):結(jié)合業(yè)務(wù)仔細(xì)思考是否適合用緩存;結(jié)合業(yè)務(wù)仔細(xì)思考緩存過期時(shí)間。
緩存一定要設(shè)置過期時(shí)間,這個(gè)時(shí)間太短、或者太長都不好。
如果過期時(shí)間太短,請(qǐng)求可能會(huì)比較多的落到數(shù)據(jù)庫上,這也意味著失去了緩存的優(yōu)勢(shì)。如果過期時(shí)間太長,緩存中的臟數(shù)據(jù)會(huì)使系統(tǒng)長時(shí)間處于一個(gè)延遲的狀態(tài),而且,系統(tǒng)中長時(shí)間沒有人訪問的數(shù)據(jù)一直存在內(nèi)存中不過期,浪費(fèi)內(nèi)存。
為啥DB和Cache沒有辦法強(qiáng)一致呢?
主要是寫DB和刪Cache是兩個(gè)獨(dú)立的操作,兩個(gè)操作并沒有保證原子性。如果一定要強(qiáng)CP,就需要非常復(fù)雜的低性能方案保證寫DB和刪Cache兩個(gè)操作的原子性,比如引入分布式鎖,并且需要引入CP類型的Zookeeper分布式鎖,或者引入CP類型的Redis RedLock,而不是引入AP類型的普通Redis分布式鎖。
所以,如果一定要強(qiáng)CP,就需要非常復(fù)雜的低性能方案,有點(diǎn)得不償失。
說在后面
如果遇到難題,可以來找尼恩,給大家做起底式、絞殺式、系統(tǒng)化梳理, 幫大家真正讓面試官愛到死去活來。