動態

詳情 返回 返回

詳解分佈式緩存不一致性的所有情況!除了分佈式緩存外,其它場景又是如何解決緩存一致性的呢? - 動態 詳情

[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 中,而不是立即從磁盤讀取索引頁進行更新:

  1. 執行 DML 操作需要更新非唯一二級索引
  2. 檢查索引頁是否在 Buffer Pool 中
  3. 如果不在,將變更信息記錄到 Change Buffer
  4. 後續當該索引頁被讀取到 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

user avatar mannayang 頭像 king_wenzhinan 頭像 journey_64224c9377fd5 頭像 sunplay 頭像 u_16502039 頭像 AmbitionGarden 頭像 lenglingx 頭像 buildyuan 頭像 java_study 頭像 gvison 頭像 yunpan-plus 頭像 fanjiapeng 頭像
點贊 43 用戶, 點贊了這篇動態!
點贊

Add a new 評論

Some HTML is okay.