[toc]
引言
持久化層和緩存層的一致性問題也通常被稱為「雙寫一致性問題」,“雙寫”意為數據既在數據庫中保存一份,也在緩存中保存一份。對於一致性來説,包含強一致性和弱一致性,強一致性保證寫入後立即可以讀取,弱一致性則不保證立即可以讀取寫入後的值,而是儘可能的保證在經過一定時間後可以讀取到,在弱一致性中應用最為廣泛的模型則是最終一致性模型,即保證在一定時間之後寫入和讀取達到一致的狀態。
我們一般會拿換 Redis 做什麼呢?
- 緩存。提高性能,降低 RT,提高吞吐量
- 分佈式鎖
為什麼 Redis 和 MySQL 數據可能不一致
- Redis 和 MySQL 是兩個獨立的組件,無法保證它們之間的原子性
-
性能和一致性做了不同的選擇:
- Redis 為了追求性能,不保證 ACID
- MySQL 為了追求一致性,保證 ACID,但犧牲了性能
- 網絡的不確定性,無法保證客户端請求的先後順序
緩存不一致場景分析
基於極端場景分析
先寫 數據庫 再刪 緩存(Cache Aside)
- 讀:讀緩存。不存在,讀數據庫寫緩存
- 寫:寫數據庫後,再刪緩存
寫失效,讀更新
緩存不一致場景
- 讀時更新緩存,遇到寫數據庫操作,讀請求卻晚於寫請求執行
- 主從延遲
- 緩存刪除失敗
先寫 數據庫 再寫 緩存
- 讀:讀緩存。不存在,讀數據庫寫緩存
- 寫:寫數據庫,再寫緩存(雙寫)
在讀和寫都可能會觸發寫緩存的操作
緩存不一致場景
- 情況一:會遇到與 Cache Aside 類似的情況。讀時更新緩存,遇到寫數據庫操作,讀請求卻晚於寫請求執行
- 情況二:寫入數據庫之後,寫緩存之前,存在另一個線程寫數據庫和緩存。會用舊的緩存將新的緩存覆蓋
解決方案
- 分佈式鎖。保證寫數據庫和寫緩存的原子性
- 樂觀鎖。寫緩存時比較版本號,確保新值不被覆蓋
先寫 緩存 再寫 數據庫
- 讀:讀緩存。不存在,讀數據庫寫緩存
- 寫:寫緩存,再寫 DB(雙寫)
緩存不一致的場景
寫緩存成功,寫數據庫失敗,無法保證原子性,無法回滾
如果此時緩存被其它請求讀取,就會導致數據不一致。因為我們只能認為寫入數據庫的數據才是“真正存在”的正確數據
解決方案
分佈式事務。寫數據庫失敗後,將原本的緩存刪除
先刪 緩存 再寫 數據庫
- 讀:讀緩存。不存在,讀數據庫寫緩存
- 寫:先刪 緩存 再寫 數據庫
緩存不一致的場景
- 過早地刪除緩存,會導致「緩存擊穿」問題
- 在寫入數據庫之前,另一個線程發現緩存不存在,讀取了舊的值更新緩存(在流量比較大的場景,會更頻繁地出現)
解決方案
- 每次讀寫都加鎖
- 延遲雙刪
延遲雙刪
解決上面方案中,刪緩存後,又被緩存舊數據的情況
- 讀:讀緩存。不存在,讀數據庫寫緩存
-
寫:先刪 緩存 再寫 數據庫。等待一定時間後,再刪除一次緩存
- 第一次刪除:避免其它線程讀取到舊緩存
- 第二次刪除:確保將當前線程寫入期間,把其它線程更新的舊緩存刪除
存在問題
-
如何把控等待的時間
- 太短,沒有用
- 太長,增加響應時間。雖然可以異步去做,但如果期間真的存在舊的緩存,過長的等待時間會導致數據不一致的窗口變長
- 兩次刪除操作,會增加代碼複雜性
- 過早刪除緩存,存在「緩存擊穿」的風險
並不能有效減少緩存不一致的問題出現的可能性,成本大於收益
異步寫入
寫回(Write Back)
數據先寫入緩存,異步批量寫入數據庫,充分利用內存寫性能強的特點
- 讀:讀緩存。不存在,讀數據庫寫緩存
- 寫:寫緩存後立即返回。異步寫數據庫
批量 UPDATE
// 給視頻點贊,每次點贊就是 1 條 SQL,需要執行 3 次
UPDATE t_video SET like = like + 1 WHERE id = '1';
UPDATE t_video SET like = like + 1 WHERE id = '1';
UPDATE t_video SET like = like + 1 WHERE id = '1';
// 聚合 3 條 SQL,執行 1 次
UPDATE t_video SET like = like + 3 WHERE id = '1';
緩存不一致的場景
- 內存的數據有丟失的風險
- 寫數據庫不一定能成功,此時需要通過分佈式事務回滾
- 緩存不一致窗口時間 = 等待聚合時間 + 執行寫入 SQL 時間
應用場景
對寫性能要求高,可以容忍一部分數據的不一致
手動/定時更新緩存
手動/定時更新緩存,比如可以對即將上線的活動預熱
問題在於,很難確定哪些緩存是熱點
監聽 binlog 異步更新緩存
- 寫:直接寫數據庫,通過 Canal 監聽 binlog,異步更新緩存
- 讀:直接讀取緩存
業務代碼侵入性低,只需要考慮數據庫更新,不需要考慮緩存的寫入
優缺點
- 在數據庫更新後,MQ 消息之前,緩存是不一致的,鏈路長,有一定的延遲
- 實現較為複雜,需要額外維護 Canal 和 MQ
再進一步:本地緩存+Redis+MySQL
使用本地緩存,相比直接使用 Redis 的好處:
- 提高讀取性能。避免了網絡延遲、序列化/反序列化的開銷
- 緩解 Redis 熱 key 問題。只需要在本地讀取緩存,不需要訪問 Redis
本地緩存是去中心化的思路,將計算和存儲放在靠近請求產生的地方。使用本地緩存的問題:
- 如果將全量數據存到 JVM 中,內存都拿來緩存數據而不是處理業務。通過限制本地緩存的大小+LRU,避免內存無限制的增長
- 額外的編碼複雜性
- 緩存一致性問題。本地緩存與 Redis,Redis 與 MySQL 的一致性
足夠短的過期時間
使用 Cache Aside,並設置足夠短的過期時間
比如 1 秒鐘。那麼至少本地緩存和 Redis 的不一致時間能控制在 1 秒鐘
- 優點:簡單,不需要太複雜的邏輯解決緩存一致性
- 缺點:不一致的窗口取決於過期時間,過期時間越長,數據不一致窗口就越舊,同時佔用內存的時間就越長;設置時間太短,緩存命中率低,沒什麼收益
MQ 異步讓緩存失效
監聽 MySQL binlog,監聽到數據更新,同時給每個 JVM 進程,將對應的本地緩存失效
- 優點:可以相對及時地讓本地緩存過期
- 缺點:成本高
擴展:其它場景的緩存一致性
HTTP 強制緩存 & 協商緩存
- 第一次訪問資源,正常請求資源,服務端返回 Cache-Control
- 第二次訪問資源,資源沒有過期,不需要請求服務器,直接使用本地緩存(強制緩存),狀態碼為 200(from disk cache)
-
第三次訪問資源,資源已經過期,向服務器發送 If-None-Match 和 If-Modified-Since 請求頭,協商緩存。服務器通過校驗 ETag 或 Last-Modified,判斷是否資源是否被修改過:
- 沒有被修改,狀態碼 304(Not Modified),響應體不需要帶任何值
- 被修改,正常請求資源,狀態碼 200
解決緩存一致性的方案
過期時間、ETag、Last-Modified
CPU 緩存一致性
CPU L1 核心是獨立的,需要保證核心與核心之間的一致性。主要依賴 MESI 協議:
- Modified(M):數據被當前核心修改,與內存不一致,其他核心無副本
- Exclusive(E):數據僅當前核心有緩存,與內存一致
- Shared(S):多個核心共享該數據,所有緩存與內存一致
- Invalid(I):緩存行無效(其他核心修改了數據)
任意一對快取,對應快取行的相容關係:
工作流程
- 核心 A 修改數據時,會先將其他核心的對應緩存行標記為 Invalid,然後更新自己的緩存為 Modified
- 核心 B 讀取該數據時,會觸發總線嗅探(Bus Snooping),發現數據已失效,從核心 A 或內存重新加載
解決緩存一致性的方案
硬件和 MESI 協議的支持
CPU 與內存的一致性
CPU 緩存的性能遠比內存更好。所以實際讀寫數據的時候,都是基於 CPU 緩存來操作,減少對內存的頻繁寫入(尤其是多次修改同一緩存行時)
工作原理
- 數據修改僅寫入緩存,不立即更新內存
- 當緩存行被替換(如 LRU 淘汰)時,才將髒數據寫回內存
- 通過髒位(Dirty Bit)標記緩存行是否需要回寫
解決緩存一致性的方案
髒頁寫回
(圖源見圖片右下角)
Linux 的 Page Cache
Page Cache 是磁盤的緩存。用於加快磁盤數據的讀寫:
-
延遲寫入:當進程寫入文件時,數據首先被寫入 Page Cache 中的內存頁(標記為“Dirty”),而非立即同步到磁盤。應用程序無需等待磁盤 I/O 完成。內核通過以下方式異步刷盤:
- 由內核線程定期將髒頁寫回磁盤
- 觸發條件包括髒頁佔比超閾值、時間間隔到期或系統內存不足
- 合併連續 I/O:會將短時間內對同一磁盤區域的多次寫入合併為一次 I/O 操作(尤其是順序寫入)。減少磁盤尋址次數
- 預讀:當檢測到順序寫入模式時,內核預讀後續數據到 Page Cache,避免寫入時因磁盤尋址阻塞(時間局部性和空間局部性)
int fd = open("data.txt", O_WRONLY);
write(fd, "hello", 5); // 只寫 page cache
fsync(fd); // 真正 flush 到磁盤
為什麼電腦非正常關機(比如斷電),會丟失數據?
Page Cache 還沒來得及刷盤,內存數據會丟失
如果是正常關機,操作系統能保證內存數據,一定都能寫入到磁盤
解決緩存一致性的方案
髒頁寫回
MySQL 磁盤數據與內存的 Buffer Pool
Buffer Pool 是 InnoDB 引擎的核心內存區域,用於緩存表數據和索引數據。設計思想和 Page Cache 非常像
當查詢需要訪問數據時:
- 首先檢查 Buffer Pool 中是否存在所需數據頁
- 如果存在(命中),直接從內存讀取
- 如果不存在(未命中),從磁盤讀取數據頁到 Buffer Pool
同時由於 redo log 已經落盤,「髒頁」哪怕丟了,也能通過 redo log 恢復數據
擴展:為什麼 MySQL 的「唯一索引」寫入性能為什麼比「普通索引」更慢?
唯一索引與普通索引讀取性能完全一致,但寫入性能卻不如普通索引
先介紹下 Change Buffer。Change Buffer 是 Buffer Pool 的一部分,用於緩存「非唯一二級索引頁」的修改操作。當這些索引頁不在 Buffer Pool 中時,相關的變更會先記錄在 Change Buffer 中,而不是立即從磁盤讀取索引頁進行更新:
- 執行 DML 操作需要更新非唯一二級索引
- 檢查索引頁是否在 Buffer Pool 中
- 如果不在,將變更信息記錄到 Change Buffer
- 後續當該索引頁被讀取到 Buffer Pool 時,合併變更(稱為 merge 操作)
普通索引和唯一索引 Change Buffer 上的區別:
- 普通索引:非唯一變更可以先記錄在 Change Buffer,延遲合併到索引頁
- 唯一索引:必須立即檢查唯一性,無法使用 Change Buffer 優化,必須直接寫入到磁盤
Buffer Pool 與 Page Cache
MySQL 默認配置下,存在“雙緩衝”
- 正常讀寫文件:Page Cache
- MySQL 讀寫數據:Buffer Pool -> Page Cache
Buffer Pool 功能和 Page Cache 很多相同的地方。所以可以考慮繞過 page cache,提高讀寫性能
解決緩存一致性的方案
髒頁寫回
使用建議
- 必須設置「過期時間」,保證最終一致性。緩存過期後,下次更新緩存,大概率就能一致了
- 緩存數據時,避免使用「分佈式事務」和「鎖」。因為使用緩存的意義,就是為了提高讀性能,而使用「分佈式事務」和「鎖」會大幅降低讀和寫的性能。同時,既然使用緩存了,説明此時並不是「強一致」的常見
-
根據業務決定合適的方案
-
讀多寫少:
- Cache Aside。簡單高效,緩存會出現不一致,但概率極低
- 本地緩存。性能好,但在非熱點場景,命中率低
- 監聽 binlog 異步更新緩存。能很好地保證緩存的最終一致性,但是實現複雜
-
讀多寫多:
- 不使用緩存。因為剛寫入的緩存立即被刪除了,緩存命中率低,不如不用緩存
- Write Back. 寫入內存的數據可能會丟失
-
補充
- 最終一致性指的是,一定時間後,數據一定能達到一致。這裏的“一定時間”,在平常是很模糊。但是在緩存一致性的場景中,“一定時間”就是緩存的過期時間
- 單節點「分佈式鎖」不存在一致性問題,因為分佈式鎖只存儲在一個 Redis 中,也不會存儲到 MySQL。在主從場景中,需要考慮主從節點的一致性
- 緩存不一致問題是併發問題,是概率問題。如果沒什麼流量,選擇哪一種方案都區別不大,因為出現緩存不一致的場景往往都非常極端
結語
在軟件設計上,我們基本上不可能做出一個沒有缺陷的設計,就像算法設計中的時間換空間,空間換時間一個道理,有時候,強一致性和高性能,高可用和高性能是有衝突的。軟件設計從來都是取捨 Trade-Off
既要性能、又要保證強一致性,在緩存一致性場景中就是偽命題。關鍵是看自己能為了得到什麼,願意犧牲什麼。所以工程上,我們往往選擇 Cache Aside,通過概率的方式賭它不會出現緩存不一致,從而避免為了維護緩存一致性導致的性能問題
參考
https://juejin.cn/post/7373136303179792395
https://cloud.tencent.com/developer/article/2197853
封面:Kapxapius