一、為什么會出現數據不一致?
根本原因在于:這是一個涉及兩個獨立存儲系統的數據更新操作,它無法被包裝成一個原子操作(分布式事務)。更新數據庫和更新緩存是兩個獨立的步驟,無論在代碼中如何排列這兩個步驟,都可能因為并發、失敗重試等原因導致不一致。
主要的不一致場景可以歸結為以下兩類順序問題:
-
先更新數據庫,再刪除緩存
- 成功:
更新 DB -> 刪除 Cache
-> 數據一致(下次讀取會回種緩存)。 - 失敗:
- 更新 DB 成功,刪除 Cache 失敗:數據庫是新數據,緩存是舊數據,發生不一致。
- 并發讀寫:在更新 DB 后、刪除 Cache 前,另一個線程來讀取,發現緩存是舊的并加載,隨后 Cache 被刪除,但里面已經是臟數據了。
- 成功:
-
先刪除緩存,再更新數據庫
- 成功:
刪除 Cache -> 更新 DB
-> 數據一致。 - 失敗:
- 刪除 Cache 成功,更新 DB 失敗:緩存是空的,數據庫是舊數據,數據一致(但緩存miss,會從DB讀舊數據回種,仍是舊數據)。
- 并發讀寫(更嚴重):
- 線程A
刪除緩存
。 - 線程B 發現緩存不存在,
從數據庫讀取舊數據
。 - 線程B
將舊數據回種到緩存
。 - 線程A
更新數據庫為新數據
。
- 結果:數據庫是新數據,緩存是舊數據,發生不一致。
- 線程A
- 成功:
二、常見的解決方案與策略
沒有一種完美的銀彈方案,需要根據業務場景(對一致性的要求、讀寫比例、性能要求等)進行權衡和選擇。
1. Cache-Aside (旁路緩存) + 延遲雙刪
這是最常用的模式,其讀/寫邏輯如下:
-
讀流程:
- 從 Redis 讀取數據。
- 如果命中,直接返回。
- 如果未命中,從 MySQL 讀取數據。
- 將 MySQL 的數據寫入 Redis(回種緩存),然后返回。
-
寫流程(關鍵):
- 更新 MySQL 中的數據。
- 刪除 Redis 中的對應緩存。
- (延遲雙刪的關鍵步驟) 等待一小段時間(如幾百毫秒),再次刪除 Redis 緩存。
為什么需要“延遲雙刪”?
它旨在解決上述“先更新數據庫,再刪除緩存”模式下的并發問題。第二次刪除是為了清除在“更新DB”和“第一次刪除Cache”這個時間間隙內,可能被其他讀請求回種的舊數據。
優點:
- 實現相對簡單,適用性廣。
- 延遲雙刪能解決大部分并發導致的不一致。
缺點:
- 等待時間(延遲)需要估算,不好設置。
- 第二次刪除可能仍會失敗(需要重試機制)。
- 在延遲期間,可能仍有短暫的不一致。
改進:為第二次刪除增加重試機制。可以將失敗的刪除操作寫入一個消息隊列,由專門的服務消費重試,確保最終一定刪除成功。
2. Write-Through (穿透寫) / Write-Behind
這類方案通常需要依賴一個獨立的服務或中間件來統一管理緩存和數據庫的寫入。
-
Write-Through:
- 應用層只寫入緩存(由一個中間件來管理)。
- 中間件同步地寫入緩存和數據庫。
- 保證了強一致性,但性能很差,因為每次寫操作都要等待兩個存儲系統都完成。
-
Write-Behind (也叫Write-Back):
- 應用層只寫入緩存(由一個中間件來管理)。
- 中間件先寫緩存,然后異步地批量更新到數據庫。
- 性能極高,但存在數據丟失風險(如果緩存宕機,未持久化的數據會丟失)。一致性最弱。
優點:
- Write-Through 強一致。
- Write-Behind 性能極高。
缺點:
- 架構復雜,需要引入和維護額外的中間件。
- Write-Through 性能低。
- Write-Behind 有丟失數據風險。
3. 基于 MySQL Binlog 的最終一致性方案(推薦)
這是目前最流行、最可靠的大型項目方案。其核心是利用 MySQL 的二進制日志(Binlog)進行增量數據同步。
工作原理:
- 業務代碼正常更新 MySQL。
- 一個數據同步服務(如 Canal, Maxwell, Debezium)偽裝成 MySQL 的從庫,訂閱并解析 Binlog。
- 同步服務獲取到數據的變更事件(增、刪、改)后,發送到一個消息隊列(如 Kafka/RocketMQ)。
- 一個緩存更新服務消費 MQ 中的消息,然后刪除 Redis 中對應的緩存。
優點:
- 徹底解耦:業務代碼只關心數據庫,完全不知道緩存的存在。代碼簡潔。
- 高可靠性:Binlog 是 MySQL 自帶的高可靠機制,保證了數據變更不會丟失。
- 最終一致性:通過 MQ 的異步消費,保證了數據最終會一致,延遲低。
- 通用性:一套系統可以為多種業務服務。
缺點:
- 架構最復雜,技術門檻高,需要維護多個組件(同步服務、MQ、消費服務)。
三、方案選擇與最佳實踐總結
策略 | 一致性強度 | 復雜度 | 性能 | 適用場景 |
---|---|---|---|---|
Cache-Aside + 延遲雙刪 | 最終一致(可能有短暫不一致) | 中等 | 高 | 通用方案,適合大多數中小型項目 |
Write-Through | 強一致 | 高 | 低 | 對一致性要求極高,可接受寫性能差的場景 |
Write-Behind | 弱一致(可能丟失數據) | 高 | 極高 | 寫入巨大,對性能要求極高,能容忍數據丟失的場景(如計數、日志) |
Binlog 同步 | 最終一致(可靠性高) | 非常高 | 高 | 大型互聯網項目,架構完善,需要高可靠性和解耦 |
通用最佳實踐建議:
-
優先選擇刪除緩存,而不是更新緩存。
- 更新緩存可能帶來并發問題、浪費資源(多次更新可能只有最后一次被讀到)。直接刪除讓下一次讀請求來回種緩存,是更 lazy 和高效的做法。
-
Key 的過期時間:
- 即使一切正常,也一定要給 Redis 的 Key 設置一個過期時間。這是最后一道防線,即使同步失敗,舊緩存也會自動失效,最終達到一致。
-
保證刪除操作的重試:
- 無論是哪種方案,刪除緩存都可能失敗。必須要有重試機制(如通過消息隊列),確保刪除最終成功。
-
讀操作是否回種緩存?
- 在高并發場景下,如果緩存缺失(Cache Miss),可能會導致大量請求穿透到數據庫(緩存擊穿)。可以考慮使用互斥鎖(Mutex Lock),只讓一個請求去數據庫回種緩存,其他請求等待。
-
根據業務容忍度選擇策略:
- 對于用戶信息、商品價格等對一致性要求較高的數據,推薦使用 Binlog 同步方案 或 延遲雙刪。
- 對于點贊數、瀏覽量等對一致性要求不高的數據,甚至可以設置短一點的過期時間,容忍短暫不一致。
結論:
對于追求穩定和可靠性的大型項目,基于 Binlog 的異步同步方案是最佳選擇。對于中小型項目,從簡單有效出發,Cache-Aside + 延遲雙刪 + 失敗重試機制 是一個不錯的起點,同時務必為緩存設置過期時間。