美團面試題:緩存一致性,我是這么回答的!
人生本就是苦還是只有童年苦?生命就是如此!
-----這個殺手不太冷
前言
一道之前的面試題:
如何保證緩存和數(shù)據(jù)庫的一致性?
來自:社招一年半面經(jīng)分享(含阿里美團頭條京東滴滴)
文章首發(fā)在公眾號,之后同步掘金和個人網(wǎng)站:https://upheart.cn/?。?/p>
下面介紹幾種方案(大家回答的時候最好根據(jù)自己的業(yè)務,結(jié)合下面的方案)
文章較長,可以點贊在看
方案分析
更新緩存策略方式常見的有下面幾種:
先更新緩存,再更新數(shù)據(jù)庫 先更新數(shù)據(jù)庫,再更新緩存 先刪除緩存,再更新數(shù)據(jù)庫 先更新數(shù)據(jù)庫,再刪除緩存
下面一一介紹!
方案一:更新緩存,更新數(shù)據(jù)庫
這種方式可輕易排除,因為如果先更新緩存成功,但是數(shù)據(jù)庫更新失敗,則肯定會造成數(shù)據(jù)不一致。
方案二:更新數(shù)據(jù)庫,更新緩存
這種緩存更新策略俗稱雙寫,存在問題是:并發(fā)更新數(shù)據(jù)庫場景下,會將臟數(shù)據(jù)刷到緩存
updateDB();
updateRedis();
舉例:如果在兩個操作之間數(shù)據(jù)庫和緩存又被后面請求修改,此時再去更新緩存已經(jīng)是過期數(shù)據(jù)了。

方案三:刪除緩存,更新數(shù)據(jù)庫
存在問題:更新數(shù)據(jù)庫之前,若有查詢請求,會將臟數(shù)據(jù)刷到緩存
deleteRedis();
updateDB();
舉例:如果在兩個操作之間發(fā)生了數(shù)據(jù)查詢,那么會有舊數(shù)據(jù)放入緩存。

該方案會導致請求數(shù)據(jù)不一致
如果同時有一個請求A進行更新操作,另一個請求B進行查詢操作。那么會出現(xiàn)如下情形:
請求A進行寫操作,刪除緩存
請求B查詢發(fā)現(xiàn)緩存不存在
請求B去數(shù)據(jù)庫查詢得到舊值
請求B將舊值寫入緩存
請求A將新值寫入數(shù)據(jù)庫
上述情況就會導致不一致的情形出現(xiàn)。而且,如果不采用給緩存設置過期時間策略,該數(shù)據(jù)永遠都是臟數(shù)據(jù)。
方案四:更新數(shù)據(jù)庫,刪除緩存
存在問題:在更新數(shù)據(jù)庫之前有查詢請求,并且緩存失效了,會查詢數(shù)據(jù)庫,然后更新緩存。如果在查詢數(shù)據(jù)庫和更新緩存之間進行了數(shù)據(jù)庫更新的操作,那么就會把臟數(shù)據(jù)刷到緩存
updateDB();
deleteRedis();
舉例:如果在查詢數(shù)據(jù)庫和放入緩存這兩個操作中間發(fā)生了數(shù)據(jù)更新并且刪除緩存,那么會有舊數(shù)據(jù)放入緩存。

假設有兩個請求,一個請求A做查詢操作,一個請求B做更新操作,那么會有如下情形產(chǎn)生
緩存剛好失效
請求A查詢數(shù)據(jù)庫,得一個舊值
請求B將新值寫入數(shù)據(jù)庫
請求B刪除緩存
請求A將查到的舊值寫入緩存
如果發(fā)生上述情況,確實是會發(fā)生臟數(shù)據(jù)。但是發(fā)生上述情況有一個先天性條件,就是寫數(shù)據(jù)庫操作比讀數(shù)據(jù)庫操作耗時更短
不過數(shù)據(jù)庫的讀操作的速度遠快于寫操作的
因此這一情形很難出現(xiàn)。
方案對比
方案1和方案2的共同缺點:
并發(fā)更新數(shù)據(jù)庫場景下,會將臟數(shù)據(jù)刷到緩存,但一般并發(fā)寫的場景概率都相對小一些;
線程安全角度,會產(chǎn)生臟數(shù)據(jù),比如:
線程A更新了數(shù)據(jù)庫 線程B更新了數(shù)據(jù)庫 線程B更新了緩存 線程A更新了緩存
方案3和方案4的共同缺點:
不管采用哪種順序,2種方式都是存在一些問題的:
主從延時問題:不管是先刪除還是后刪除,數(shù)據(jù)庫主從延時可能導致臟數(shù)據(jù)的產(chǎn)生。 緩存刪除失?。喝绻彺鎰h除失敗,則都會產(chǎn)生臟數(shù)據(jù)。
問題解決思路:延遲雙刪,添加重試機制,下面介紹!
更新緩存還是刪除緩存?
1.更新緩存緩存需要有一定的維護成本,而且會存在并發(fā)更新的問題
2.寫多讀少的情況下,讀請求還沒有來,緩存以及被更新很多次,沒有起到緩存的作用
3.放入緩存的值可能是經(jīng)過復雜計算的,如果每次更新,都計算寫入緩存的值,浪費性能的
刪除緩存優(yōu)點:簡單、成本低,容易開發(fā);缺點:會造成一次cache miss
如果更新緩存開銷較小并且讀多寫少,基本不會有寫并發(fā)的時候可以才用更新緩存,否則通用做法還是刪除緩存。
總結(jié)
| 方案 | 問題 | 問題出現(xiàn)概率 | 推薦程度 |
|---|---|---|---|
| 更新緩存 -> 更新數(shù)據(jù)庫 | 為了保證數(shù)據(jù)準確性,數(shù)據(jù)必須以數(shù)據(jù)庫更新結(jié)果為準,所以該方案絕不可行 | 大 | 不推薦 |
| 更新數(shù)據(jù)庫 -> 更新緩存 | 并發(fā)更新數(shù)據(jù)庫場景下,會將臟數(shù)據(jù)刷到緩存 | 并發(fā)寫場景,概率一般 | 寫請求較多時會出現(xiàn)不一致問題,不推薦使用。 |
| 刪除緩存 -> 更新數(shù)據(jù)庫 | 更新數(shù)據(jù)庫之前,若有查詢請求,會將臟數(shù)據(jù)刷到緩存 | 并發(fā)讀場景,概率較大 | 讀請求較多時會出現(xiàn)不一致問題,不推薦使用 |
| 更新數(shù)據(jù)庫 -> 刪除緩存 | 在更新數(shù)據(jù)庫之前有查詢請求,并且緩存失效了,會查詢數(shù)據(jù)庫,然后更新緩存。如果在查詢數(shù)據(jù)庫和更新緩存之間進行了數(shù)據(jù)庫更新的操作,那么就會把臟數(shù)據(jù)刷到緩存 | 并發(fā)讀場景&讀操作慢于寫操作,概率最小 | 讀操作比寫操作更慢的情況較少,相比于其他方式出錯的概率小一些。勉強推薦。 |
推薦方案
延遲雙刪
采用更新前后雙刪除緩存策略
public void write(String key,Object data){
redis.del(key);
db.update(data);
Thread.sleep(1000);
redis.del(key);
}
先淘汰緩存
再寫數(shù)據(jù)庫
休眠1秒,再次淘汰緩存
大家應該評估自己的項目的讀數(shù)據(jù)業(yè)務邏輯的耗時。然后寫數(shù)據(jù)的休眠時間則在讀數(shù)據(jù)業(yè)務邏輯的耗時基礎上即可。
這么做的目的,就是確保讀請求結(jié)束,寫請求可以刪除讀請求造成的緩存臟數(shù)據(jù)。
問題及解法:
1、同步刪除,吞吐量降低如何處理
將第二次刪除作為異步的,提交一個延遲的執(zhí)行任務
2、解決刪除失敗的方式:
添加重試機制,例如:將刪除失敗的key,寫入消息隊列;但對業(yè)務耦合有些嚴重;

延時工具可以選擇:
最普通的阻塞Thread.currentThread().sleep(1000);
Jdk調(diào)度線程池,quartz定時任務,利用jdk自帶的delayQueue,netty的HashWheelTimer,Rabbitmq的延時隊列,等等
實際場景
我們有個商品中心的場景,是讀多寫少的服務,并且寫數(shù)據(jù)會發(fā)送MQ通知下游拿數(shù)據(jù),這樣就需要嚴格保證緩存和數(shù)據(jù)庫的一致性,需要提供高可靠的系統(tǒng)服務能力。
寫緩存策略
緩存key設置失效時間 先DB操作,再緩存失效 寫操作都標記key(美團中間件)強制走主庫 接入美團中間件監(jiān)聽binlog(美團中間件)變化的數(shù)據(jù)在進行兜底,再刪除緩存

讀緩存策略
先判斷是否走主庫 如果走主庫,則使用標記(美團中間件)查主庫 如果不是,則查看緩存中是否有數(shù)據(jù) 緩存中有數(shù)據(jù),則使用緩存數(shù)據(jù)作為結(jié)果 如果沒有,則查DB數(shù)據(jù),再寫數(shù)據(jù)到緩存

注意
關于緩存過期時間的問題
如果緩存設置了過期時間,那么上述的所有不一致情況都只是暫時的。
但是如果沒有設置過期時間,那么不一致問題就只能等到下次更新數(shù)據(jù)時解決。
所以一定要設置緩存過期時間。
最后
覺得有收獲,希望幫忙點贊,轉(zhuǎn)發(fā)下哈,謝謝,謝謝
微信搜索:月伴飛魚,交個朋友
公眾號后臺回復666,可以獲得免費電子書籍

動態(tài)代理總結(jié),面試你要知道的都在這里,無廢話!

這些線程安全的坑,你在工作中踩了么?

