更新商家信息后,緩存與DB數(shù)據(jù)不一致導(dǎo)致用戶看到舊數(shù)據(jù),如何解決?
作為一名開發(fā)者,我們很可能都遇到過這樣的場景:電商平臺(tái)的運(yùn)營同學(xué)火急火燎地跑過來,說某個(gè)商家的logo、名稱或活動(dòng)信息明明已經(jīng)更新了,但前端APP和頁面上還是顯示著舊數(shù)據(jù),用戶投訴不斷。
你心里“咯噔”一下,立刻去數(shù)據(jù)庫查,發(fā)現(xiàn)數(shù)據(jù)確實(shí)已經(jīng)更新為正確的了。那么問題出在哪?很可能,就是緩存與數(shù)據(jù)庫之間的數(shù)據(jù)不一致了。
在當(dāng)今高并發(fā)系統(tǒng)中,緩存(如Redis, Memcached)幾乎是必不可少的組件。它通過將熱點(diǎn)數(shù)據(jù)存放在內(nèi)存中,極大地減輕了數(shù)據(jù)庫的壓力,提升了系統(tǒng)的響應(yīng)速度。然而,引入緩存的同時(shí),我們也引入了新的復(fù)雜性——如何保證緩存里的數(shù)據(jù)和數(shù)據(jù)庫里的數(shù)據(jù)是同步的?這就是經(jīng)典的緩存一致性問題。
本文將深入探討這個(gè)問題,并從簡單到復(fù)雜,介紹幾種行之有效的解決方案。
一、問題根源:我們?yōu)槭裁磿?huì)需要緩存?
在深入問題之前,我們先達(dá)成一個(gè)共識(shí):緩存是數(shù)據(jù)庫的一個(gè)副本,而不是替代品。 它的核心價(jià)值在于性能。
想象一下,一個(gè)熱門商家主頁,每秒有數(shù)萬次請求。如果每次請求都直接去查詢數(shù)據(jù)庫:
1. 數(shù)據(jù)庫的CPU和IO壓力巨大。
2. 查詢速度相對較慢(毫秒級 vs 內(nèi)存的微秒級),用戶體驗(yàn)差。
因此,我們引入緩存。當(dāng)?shù)谝粋€(gè)用戶請求商家信息時(shí),系統(tǒng)會(huì):
1. 檢查緩存中是否存在該數(shù)據(jù)(cacheKey = “shop:123”)。
2. 如果不存在(我們稱之為緩存未命中,Cache Miss),則去數(shù)據(jù)庫中查詢。
3. 將查詢到的數(shù)據(jù)寫入緩存,并設(shè)置一個(gè)過期時(shí)間(TTL)。
4. 返回?cái)?shù)據(jù)給用戶。
后續(xù)的用戶請求,都會(huì)直接在緩存中找到數(shù)據(jù)(緩存命中,Cache Hit),快速返回。這被稱為 “Cache-Aside” 或 “Lazy Loading” 模式。
那么,不一致是如何產(chǎn)生的?
問題出在“更新”操作上。當(dāng)我們更新商家信息時(shí),如果只更新了數(shù)據(jù)庫,而沒有妥善處理緩存,就會(huì)出現(xiàn)不一致。
二、初探解決方案:常見的策略與陷阱
1. 先更新數(shù)據(jù)庫,再刪除緩存(Cache-Aside)
這是最常用、也最被推薦的策略之一。流程如下:
- 寫請求:更新數(shù)據(jù)庫中的商家信息。
- 寫請求:刪除緩存中對應(yīng)的key(如
DEL shop:123)。 - 讀請求:后續(xù)的讀請求發(fā)現(xiàn)緩存中不存在(Cache Miss),于是從數(shù)據(jù)庫讀取最新數(shù)據(jù),并重新寫入緩存。
代碼示例(偽代碼):
public voidupdateShop(Shop shop) {
// 1. 更新數(shù)據(jù)庫
shopMapper.updateById(shop);
// 2. 刪除緩存
redisClient.del("shop:" + shop.getId());
}
public Shop getShopById(Long id) {
// 1. 先查緩存
StringcacheKey="shop:" + id;
Shopshop= redisClient.get(cacheKey);
if (shop != null) {
return shop; // 緩存命中,直接返回
}
// 2. 緩存未命中,查數(shù)據(jù)庫
shop = shopMapper.selectById(id);
if (shop != null) {
// 3. 將數(shù)據(jù)庫數(shù)據(jù)寫入緩存
redisClient.setex(cacheKey, 300, shop); // 設(shè)置300秒過期
}
return shop;
}這個(gè)策略的優(yōu)點(diǎn):
? 簡單有效:邏輯清晰,易于理解和實(shí)現(xiàn)。
? 容錯(cuò)性較好:即使第二步刪除緩存失敗,也只是一個(gè)“臟數(shù)據(jù)”暫時(shí)存在的風(fēng)險(xiǎn),可以通過設(shè)置緩存的過期時(shí)間(TTL)來最終兜底。
但它并非完美,存在一個(gè)經(jīng)典的不一致場景:假設(shè)在并發(fā)極高的情況下:
- 請求A(讀)查詢緩存,未命中,于是去查數(shù)據(jù)庫(此時(shí)讀到的是舊數(shù)據(jù))。
- 請求B(寫)更新了數(shù)據(jù)庫。
- 請求B(寫)刪除了緩存。
- 請求A(讀)將步驟1中讀到的舊數(shù)據(jù)寫入了緩存。
這樣一來,緩存里就是舊數(shù)據(jù),數(shù)據(jù)庫里是新數(shù)據(jù),不一致發(fā)生了。雖然這個(gè)條件比較苛刻(讀請求必須在寫請求更新數(shù)據(jù)庫之后、刪除緩存之前完成數(shù)據(jù)庫查詢,并且其寫緩存操作還要在最晚發(fā)生),但在理論上是存在的。
2. 先刪除緩存,再更新數(shù)據(jù)庫
這個(gè)策略的目的是解決上述的并發(fā)問題,但同樣會(huì)引入新問題。
- 寫請求:刪除緩存。
- 寫請求:更新數(shù)據(jù)庫。
在并發(fā)情況下:
- 請求A(寫)刪除了緩存。
- 請求B(讀)發(fā)現(xiàn)緩存不存在,去數(shù)據(jù)庫查詢此時(shí)還是舊數(shù)據(jù),并將舊數(shù)據(jù)寫入緩存。
- 請求A(寫)才更新數(shù)據(jù)庫。
結(jié)果:緩存是舊數(shù)據(jù),數(shù)據(jù)庫是新數(shù)據(jù),不一致再次發(fā)生。這個(gè)概率比第一種策略的場景要高。
三、進(jìn)階方案:如何應(yīng)對高并發(fā)苛刻場景
對于大多數(shù)業(yè)務(wù),第一種“先更新數(shù)據(jù)庫,再刪除緩存”的策略,配合合理的重試機(jī)制和TTL,已經(jīng)足夠。但如果你的業(yè)務(wù)對一致性要求極高,無法忍受哪怕一瞬間的舊數(shù)據(jù),可以考慮以下方案。
方案一:延遲雙刪
這是在“先更新數(shù)據(jù)庫,再刪除緩存”基礎(chǔ)上做的增強(qiáng)。既然在并發(fā)下有可能在刪除緩存后,又被一個(gè)舊的讀請求塞入臟數(shù)據(jù),那我們再刪一次不就行了?
流程:
1. 寫請求:刪除緩存。
2. 寫請求:更新數(shù)據(jù)庫。
3. 寫請求:休眠一個(gè)短暫的時(shí)間(如500毫秒到1秒),再次刪除緩存。
這第二次刪除,目的就是清除掉在“更新數(shù)據(jù)庫”這個(gè)時(shí)間窗口內(nèi),可能被其他讀請求寫入的臟數(shù)據(jù)。
代碼示例:
public void updateShopWithDoubleDelete(Shop shop) {
String cacheKey = "shop:" + shop.getId();
// 1. 先刪除緩存
redisClient.del(cacheKey);
// 2. 更新數(shù)據(jù)庫
shopMapper.updateById(shop);
// 3. 休眠一段時(shí)間,確保讀請求已經(jīng)完成了“讀數(shù)據(jù)庫 -> 寫緩存”的操作
Thread.sleep(500);
// 4. 再次刪除緩存
redisClient.del(cacheKey);
}如何確定休眠時(shí)間?這個(gè)時(shí)間需要根據(jù)你項(xiàng)目的讀請求平均耗時(shí)來估算。目的是確保所有在第一步刪除緩存后、第二步更新數(shù)據(jù)庫前發(fā)起的讀請求,都已經(jīng)完成了它們的“寫緩存”操作。
缺點(diǎn):
? 降低了寫操作的吞吐量,因?yàn)閺?qiáng)行休眠了。
? 時(shí)間難以精確設(shè)定,設(shè)短了可能刪不干凈,設(shè)長了影響性能。
方案二:異步串行化與緩存隊(duì)列
這是更復(fù)雜但也更嚴(yán)謹(jǐn)?shù)囊环N方案,核心思想是讓對同一個(gè)數(shù)據(jù)的讀寫請求串行化。
我們可以使用一個(gè)內(nèi)存隊(duì)列(或分布式消息隊(duì)列)來實(shí)現(xiàn)。
1. 系統(tǒng)為每一個(gè)商家ID(例如 shop:123)維護(hù)一個(gè)隊(duì)列。
2. 所有對這個(gè)商家的寫請求(更新、刪除)和讀請求(在緩存未命中時(shí)),都封裝成任務(wù),按順序放入對應(yīng)的隊(duì)列。
3. 一個(gè)后臺(tái)的工作線程,從隊(duì)列中順序取出任務(wù)并執(zhí)行。
? 如果是寫任務(wù):執(zhí)行 更新DB -> 刪除緩存。
? 如果是讀任務(wù):執(zhí)行 查DB -> 寫緩存。
這樣做,就保證了對于同一個(gè)商家的操作是嚴(yán)格有序的,不可能出現(xiàn)一個(gè)寫操作還在更新數(shù)據(jù)庫,一個(gè)讀操作就去讀了舊數(shù)據(jù)并寫入緩存的情況。
缺點(diǎn):
? 系統(tǒng)復(fù)雜度急劇上升,需要維護(hù)隊(duì)列和 worker。
? 因?yàn)榇谢?,性能?huì)受一定影響。如果某個(gè)商家是超級熱點(diǎn),其隊(duì)列可能會(huì)積壓。
這個(gè)方案通常只在極端場景下使用,比如“秒殺商品”的庫存更新。
四、終極武器:監(jiān)聽數(shù)據(jù)庫Binlog,異步淘汰緩存
上面所有的方案,都要求應(yīng)用層在代碼里顯式地處理緩存刪除邏輯。如果項(xiàng)目很龐大,團(tuán)隊(duì)很多,很難保證每一個(gè)寫數(shù)據(jù)庫的地方都正確地配上了刪緩存的操作。有沒有一種更解耦、更通用的方式?
有!我們可以把自己偽裝成一個(gè)數(shù)據(jù)庫的“從庫”,去監(jiān)聽數(shù)據(jù)庫的二進(jìn)制日志(Binlog,如MySQL)或變更流(Change Stream,如MongoDB)。當(dāng)數(shù)據(jù)庫有任何數(shù)據(jù)變更時(shí),我們都能近乎實(shí)時(shí)地接收到這個(gè)事件,然后根據(jù)變更的內(nèi)容去刪除緩存。
技術(shù)選型:
? Canal:阿里巴巴開源的MySQL Binlog增量訂閱&消費(fèi)組件。
? Debezium:一個(gè)開源項(xiàng)目,為CDC(Change Data Capture)而生,支持多種數(shù)據(jù)庫。
? MaxWell:另一個(gè)輕量級的MySQL Binlog解析工具。
架構(gòu)流程:
1. 你的業(yè)務(wù)應(yīng)用正常更新數(shù)據(jù)庫,完全不用關(guān)心緩存。
2. Canal等服務(wù)連接到MySQL,模擬從庫,接收Binlog。
3. Canal解析Binlog,獲取到哪個(gè)表、哪行數(shù)據(jù)、發(fā)生了何種變更(增/刪/改)。
4. Canal的客戶端(你寫的程序)接收到這些變更事件。
5. 客戶端根據(jù)變更的行數(shù)據(jù),生成對應(yīng)的緩存Key,然后調(diào)用Redis進(jìn)行刪除。
// 這是一個(gè)Canal客戶端的示例邏輯
@EventListener
publicvoidonDataChange(DataChangeEvent event) {
if (event.getTableName().equals("t_shop")) {
LongshopId= event.getData().getLong("id");
StringcacheKey="shop:" + shopId;
redisClient.del(cacheKey);
log.info("通過Binlog清除緩存: {}", cacheKey);
}
}優(yōu)點(diǎn):
? 徹底解耦:應(yīng)用層代碼變得非常干凈,只需關(guān)注業(yè)務(wù)和DB。
? 通用性強(qiáng):無論通過什么途徑(后臺(tái)管理、API、數(shù)據(jù)庫直接操作)更新的數(shù)據(jù),都能觸發(fā)緩存刪除。
? 性能優(yōu)秀:異步處理,對主業(yè)務(wù)鏈路幾乎沒有性能影響。
缺點(diǎn):
? 架構(gòu)復(fù)雜:引入了新的中間件,增加了運(yùn)維成本。
? 時(shí)效性:雖然近乎實(shí)時(shí),但依然有極短的延遲。
五、總結(jié)與選型建議
沒有放之四海而皆準(zhǔn)的銀彈,選擇哪種方案取決于你的業(yè)務(wù)場景和技術(shù)要求。
方案 | 優(yōu)點(diǎn) | 缺點(diǎn) | 適用場景 |
先更新DB,再刪除緩存 | 簡單、可靠、容錯(cuò)性好 | 存在極低概率的不一致 | 絕大多數(shù)業(yè)務(wù)場景的首選 ,配合TTL和重試機(jī)制 |
延遲雙刪 | 能解決經(jīng)典策略的并發(fā)問題 | 休眠時(shí)間難定,犧牲寫性能 | 對一致性要求較高,且能接受一定延遲的寫操作 |
異步串行化 | 強(qiáng)一致性,理論上最嚴(yán)謹(jǐn) | 系統(tǒng)復(fù)雜,性能有瓶頸 | 極端場景,如金融賬戶余額、秒殺庫存 |
監(jiān)聽Binlog | 徹底解耦,通用性強(qiáng) | 架構(gòu)復(fù)雜,運(yùn)維成本高 | 大型項(xiàng)目,多團(tuán)隊(duì)協(xié)作,有專門的基礎(chǔ)架構(gòu)團(tuán)隊(duì) |
給你的實(shí)踐建議:
1. 從簡單的開始:首先嘗試 “先更新數(shù)據(jù)庫,再刪除緩存” 。在99%的場景下,它已經(jīng)足夠好。
2. 務(wù)必設(shè)置緩存過期時(shí)間(TTL):這是最后的兜底策略。即使所有刪除方案都失敗了,數(shù)據(jù)最終也會(huì)因過期而消失,然后被正確的數(shù)據(jù)填充。這被稱為 “最終一致性”。
3. 增加刪除失敗的重試機(jī)制:如果刪除緩存這一步失敗了,可以將刪除操作放入一個(gè)重試隊(duì)列(或用消息隊(duì)列),不斷重試直到成功。這能極大提高方案的健壯性。
4. 評估成本:不要為了0.01%的不一致概率,去投入100%的復(fù)雜架構(gòu)。技術(shù)決策永遠(yuǎn)是權(quán)衡的藝術(shù)。
希望這篇文章能幫助你徹底理解并解決緩存一致性問題,讓你的用戶永遠(yuǎn)看到最新的商家信息。


























