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


























