浅谈缓存最终一致性的解决方案
作者:clareguo,騰訊 CSIG 后臺開發工程師
到底是更新緩存還是刪除緩存??到底是先更新數據庫,再刪除緩存,還是先刪除緩存,再更新數據庫?
1 引言
對于互聯網業務來說,傳統的直接訪問數據庫方式,主要通過數據分片、一主多從等方式來扛住讀寫流量,但隨著數據量的積累和流量的激增,僅依賴數據庫來承接所有流量,不僅成本高、效率低、而且還伴隨著穩定性降低的風險。鑒于大部分業務通常是讀多寫少(讀取頻率遠遠高于更新頻率),甚至存在讀操作數量高出寫操作多個數量級的情況。因此,在架構設計中,常采用增加緩存層來提高系統的響應能力,提升數據讀寫性能、減少數據庫訪問壓力,從而提升業務的穩定性和訪問體驗。
根據 CAP 原理,分布式系統在可用性、一致性和分區容錯性上無法兼得,通常由于分區容錯無法避免,所以一致性和可用性難以同時成立。對于緩存系統來說,如何保證其數據一致性是一個在應用緩存的同時不得不解決的問題。
需要明確的是,緩存系統的數據一致性通常包括持久化層和緩存層的一致性、以及多級緩存之間的一致性,這里我們僅討論前者。持久化層和緩存層的一致性問題也通常被稱為雙寫一致性問題,“雙寫”意為數據既在數據庫中保存一份,也在緩存中保存一份。對于一致性來說,包含強一致性和弱一致性,強一致性保證寫入后立即可以讀取,弱一致性則不保證立即可以讀取寫入后的值,而是盡可能的保證在經過一定時間后可以讀取到,在弱一致性中應用最為廣泛的模型則是最終一致性模型,即保證在一定時間之后寫入和讀取達到一致的狀態。對于應用緩存的大部分場景來說,追求的則是最終一致性,少部分對數據一致性要求極高的場景則會追求強一致性。
2 保證最終一致性的策略( Cache Policy )
為了達到最終一致性,針對不同的場景,業界逐步形成了下面這幾種應用緩存的策略。
2.1 Cache-Aside
Cache-Aside 意為旁路緩存模式,是應用最為廣泛的一種緩存策略。下面的圖示展示了它的讀寫流程,來看看它是如何保證最終一致性的。在讀請求中,首先請求緩存,若緩存命中( cache hit ),則直接返回緩存中的數據;若緩存未命中( cache miss ),則查詢數據庫并將查詢結果更新至緩存,然后返回查詢出的數據( demand-filled look-aside )。在寫請求中,先更新數據庫,再刪除緩存(write-invalidate)。
2.1.1 為什么刪除緩存,而不是更新緩存?
在 Cache-Aside 中,對于讀請求的處理比較容易理解,但在寫請求中,可能會有讀者提出疑問,為什么要刪除緩存,而不是更新緩存?站在符合直覺的角度來看,更新緩存是一個容易被理解的方案,但站在性能和安全的角度,更新緩存則可能會導致一些不好的后果。
首先是性能,當該緩存對應的結果需要消耗大量的計算過程才能得到時,比如需要訪問多張數據庫表并聯合計算,那么在寫操作中更新緩存的動作將會是一筆不小的開銷。同時,當寫操作較多時,可能也會存在剛更新的緩存還沒有被讀取到,又再次被更新的情況(這常被稱為緩存擾動),顯然,這樣的更新是白白消耗機器性能的,會導致緩存利用率不高。而等到讀請求未命中緩存時再去更新,也符合懶加載的思路,需要時再進行計算。刪除緩存的操作不僅是冪等的,可以在發生異常時重試,而且寫-刪除和讀-更新在語義上更加對稱。
其次是安全,在并發場景下,在寫請求中更新緩存可能會引發數據的不一致問題。參考下面的圖示,若存在兩個來自不同線程的寫請求,首先來自線程 1 的寫請求更新了數據庫( step 1 ),接著來自線程 2 的寫請求再次更新了數據庫( step 3 ),但由于網絡延遲等原因,線程 1 可能會晚于線程 2 更新緩存( step 4 晚于 step 3 ),那么這樣便會導致最終寫入數據庫的結果是來自線程 2 的新值,寫入緩存的結果是來自線程 1 的舊值,即緩存落后于數據庫,此時再有讀請求命中緩存( step 5 ),讀取到的便是舊值。
2.1.2 為什么先更新數據庫,而不是先刪除緩存?
另外,有讀者也會對更新數據庫和刪除緩存的時序產生疑問,那么為什么不先刪除緩存,再更新數據庫呢?在單線程下,這種方案看似具有一定合理性,這種合理性體現在刪除緩存成功,但更新數據庫失敗的場景下,盡管緩存被刪除了,下次讀操作時,仍能將正確的數據寫回緩存,相對于 Cache-Aside 中更新數據庫成功,刪除緩存失敗的場景來說,先刪除緩存的方案似乎更合理一些。那么,先刪除緩存有什么問題呢?
問題仍然出現在并發場景下,首先來自線程 1 的寫請求刪除了緩存( step 1 ),接著來自線程 2 的讀請求由于緩存的刪除導致緩存未命中,根據 Cache-Aside 模式,線程 2 繼而查詢數據庫( step 2 ),但由于寫請求通常慢于讀請求,線程 1 更新數據庫的操作可能會晚于線程 2 查詢數據庫后更新緩存的操作( step 4 晚于 step 3 ),那么這樣便會導致最終寫入緩存的結果是來自線程 2 中查詢到的舊值,而寫入數據庫的結果是來自線程 1 的新值,即緩存落后于數據庫,此時再有讀請求命中緩存( step 5 ),讀取到的便是舊值。
另外,先刪除緩存,由于緩存中數據缺失,加劇數據庫的請求壓力,可能會增大緩存穿透出現的概率。
2.1.3 如果選擇先刪除緩存,再更新數據庫,那如何解決一致性問題呢?
為了避免“先刪除緩存,再更新數據庫”這一方案在讀寫并發時可能帶來的緩存臟數據,業界又提出了延時雙刪的策略,即在更新數據庫之后,延遲一段時間再次刪除緩存,為了保證第二次刪除緩存的時間點在讀請求更新緩存之后,這個延遲時間的經驗值通常應稍大于業務中讀請求的耗時。延遲的實現可以在代碼中 sleep 或采用延遲隊列。顯而易見的是,無論這個值如何預估,都很難和讀請求的完成時間點準確銜接,這也是延時雙刪被詬病的主要原因。
2.1.4 那么 Cache-Aside 存在數據不一致的可能嗎?
在 Cache-Aside 中,也存在數據不一致的可能性。在下面的讀寫并發場景下,首先來自線程 1 的讀請求在未命中緩存的情況下查詢數據庫( step 1 ),接著來自線程 2 的寫請求更新數據庫( step 2 ),但由于一些極端原因,線程 1 中讀請求的更新緩存操作晚于線程 2 中寫請求的刪除緩存的操作( step 4 晚于 step 3 ),那么這樣便會導致最終寫入緩存中的是來自線程 1 的舊值,而寫入數據庫中的是來自線程 2 的新值,即緩存落后于數據庫,此時再有讀請求命中緩存( step 5 ),讀取到的便是舊值。
這種場景的出現,不僅需要緩存失效且讀寫并發執行,而且還需要讀請求查詢數據庫的執行早于寫請求更新數據庫,同時讀請求的執行完成晚于寫請求。足以見得,這種不一致場景產生的條件非常嚴格,在實際的生產中出現的可能性較小。
除此之外,在并發環境下,Cache-Aside 中也存在讀請求命中緩存的時間點在寫請求更新數據庫之后,刪除緩存之前,這樣也會導致讀請求查詢到的緩存落后于數據庫的情況。
雖然在下一次讀請求中,緩存會被更新,但如果業務層面對這種情況的容忍度較低,那么可以采用加鎖在寫請求中保證“更新數據庫&刪除緩存”的串行執行為原子性操作(同理也可對讀請求中緩存的更新加鎖)。加鎖勢必會導致吞吐量的下降,故采取加鎖的方案應該對性能的損耗有所預期。
2.2 補償機制
我們在上面提到了,在 Cache-Aside 中可能存在更新數據庫成功,但刪除緩存失敗的場景,如果發生這種情況,那么便會導致緩存中的數據落后于數據庫,產生數據的不一致的問題。其實,不僅 Cache-Aside 存在這樣的問題,在延時雙刪等策略中也存在這樣的問題。針對可能出現的刪除失敗問題,目前業界主要有以下幾種補償機制。
2.2.1 刪除重試機制
由于同步重試刪除在性能上會影響吞吐量,所以常通過引入消息隊列,將刪除失敗的緩存對應的 key 放入消息隊列中,在對應的消費者中獲取刪除失敗的 key ,異步重試刪除。這種方法在實現上相對簡單,但由于刪除失敗后的邏輯需要基于業務代碼的 trigger 來觸發 ,對業務代碼具有一定入侵性。
2.2.2 基于數據庫日志( MySQL binlog )增量解析、訂閱和消費
鑒于上述方案對業務代碼具有一定入侵性,所以需要一種更加優雅的解決方案,讓緩存刪除失敗的補償機制運行在背后,盡量少的耦合于業務代碼。一個簡單的思路是通過后臺任務使用更新時間戳或者版本作為對比獲取數據庫的增量數據更新至緩存中,這種方式在小規模數據的場景可以起到一定作用,但其擴展性、穩定性都有所欠缺。
一個相對成熟的方案是基于 MySQL 數據庫增量日志進行解析和消費,這里較為流行的是阿里巴巴開源的作為 MySQL binlog 增量獲取和解析的組件 canal (類似的開源組件還有 Maxwell、Databus 等)。canal sever 模擬 MySQL slave 的交互協議,偽裝為 MySQL slave ,向 MySQL master 發送 dump 協議,MySQL master 收到 dump 請求,開始推送 binary log 給 slave (即 canal sever ),canal sever 解析 binary log 對象(原始為 byte 流),可由 canal client 拉取進行消費,同時 canal server 也默認支持將變更記錄投遞到 MQ 系統中,主動推送給其他系統進行消費。在 ack 機制的加持下,不管是推送還是拉取,都可以有效的保證數據按照預期被消費。當前版本的 canal 支持的 MQ 有 kafka 或者 RocketMQ 。另外, canal 依賴 zookeeper 作為分布式協調組件來實現 HA ,canal 的 HA 分為兩個部分:
為了減少對 MySQL dump 的請求壓力,不同 canal server 上的 instance 要求同一時間只能有一個處于運行狀態,其他的 instance 處于 standby 狀態;
為了保證有序性,對于一個 instance 在同一時間只能由一個 canal client 進行 get/ack 等動作;
那么,針對緩存的刪除操作便可以在 canal client 或 consumer 中編寫相關業務代碼來完成。這樣,結合數據庫日志增量解析消費的方案以及 Cache-Aside 模型,在讀請求中未命中緩存時更新緩存(通常這里會涉及到復雜的業務邏輯),在寫請求更新數據庫后刪除緩存,并基于日志增量解析來補償數據庫更新時可能的緩存刪除失敗問題,在絕大多數場景下,可以有效的保證緩存的最終一致性。
另外需要注意的是,還應該隔離事務與緩存,確保數據庫入庫后再進行緩存的刪除操作。比如考慮到數據庫的主從架構,主從同步及讀從寫主的場景下,可能會造成讀取到從庫的舊數據后便更新了緩存,導致緩存落后于數據庫的問題,這就要求對緩存的刪除應該確保在數據庫操作完成之后。所以,基于 binlog 增量日志進行數據同步的方案,可以通過選擇解析從節點的 binlog,來避免主從同步下刪除緩存過早的問題。
2.2.3 數據傳輸服務 DTS
數據傳輸服務( Data Transmission Service,簡稱 DTS)是云服務商提供的一種支持 RDBMS(關系型數據庫)、NoSQL、OLAP 等多種數據源之間進行數據交互的數據流服務。DTS 提供了包括數據遷移、數據訂閱、數據同步等在內的多種數據傳輸能力,常用于不停服數據遷移、數據異地災備、異地多活(單元化)、跨境數據同步、實時數據倉庫、查詢報表分流、緩存更新、異步消息通知等多種業務應用場景。
相對于上述基于 canal 等開源組件自建系統,DTS 的優勢體現在對多種數據源的支持、對多種數據傳輸方式的支持,避免了部署維護的人力成本。目前,各家云服務商的 DTS 服務已 針對云數據庫,云緩存等產品進行了適配,解決了 Binlog 日志回收,主備切換等場景下的訂閱高可用問題。在大規模的緩存數據一致性場景下,優先推薦使用 DTS 服務。
2.3 Read-Through
Read-Through 意為讀穿透模式,它的流程和 Cache-Aside 類似,不同點在于 Read-Through 中多了一個訪問控制層,讀請求只和該訪問控制層進行交互,而背后緩存命中與否的邏輯則由訪問控制層與數據源進行交互,業務層的實現會更加簡潔,并且對于緩存層及持久化層交互的封裝程度更高,更易于移植。
2.4 Write-Through
Write-Through 意為直寫模式,對于 Write-Through 直寫模式來說,它也增加了訪問控制層來提供更高程度的封裝。不同于 Cache-Aside 的是,Write-Through 直寫模式在寫請求更新數據庫之后,并不會刪除緩存,而是更新緩存。
這種方式的優勢在于讀請求過程簡單,不需要查詢數據庫更新緩存等操作。但其劣勢也非常明顯,除了上面我們提到的更新數據庫再更新緩存的弊端之外,這種方案還會造成更新效率低,并且兩個寫操作任何一次寫失敗都會造成數據不一致。
如果要使用這種方案,最好可以將這兩個操作作為事務處理,可以同時失敗或者同時成功,支持回滾,并且防止并發環境下的不一致。另外,為了防止緩存擾動的頻發,也可以給緩存增加 TTL 來緩解。站在可行性的角度,不管是 Write-Through 模式還是 Cache-Aside 模式,理想狀況下都可以通過分布式事務保證緩存層數據與持久化層數據的一致性,但在實際項目中,大多都對一致性的要求存在一些寬容度,所以在方案上往往有所折衷。
Write-Through 直寫模式適合寫操作較多,并且對一致性要求較高的場景,在應用 Write-Through 模式時,也需要通過一定的補償機制來解決它的問題。首先,在并發環境下,我們前面提到了先更新數據庫,再更新緩存會導致緩存和數據庫的不一致,那么先更新緩存,再更新數據庫呢?這樣的操作時序仍然會導致下面這樣線程 1 先更新緩存,最后更新數據庫的情況,即由于線程 1 和 線程 2 的執行不確定性導致數據庫和緩存的不一致。這種由于線程競爭導致的緩存不一致,可以通過分布式鎖解決,保證對緩存和數據庫的操作僅能由同一個線程完成。對于沒有拿到鎖的線程,一是通過鎖的 timeout 時間進行控制,二是將請求暫存在消息隊列中順序消費。
在下面這種并發執行場景下,來自線程 1 的寫請求更新了數據庫,接著來自線程 2 的讀請求命中緩存,接著線程 1 才更新緩存,這樣便會導致線程 2 讀取到的緩存落后于數據庫。同理,先更新緩存后更新數據庫在寫請求和讀請求并發時,也會出現類似的問題。面對這種場景,我們也可以加鎖解決。
另在,在 Write-Through 模式下,不管是先更新緩存還是先更新數據庫,都存在更新緩存或者更新數據庫失敗的情況,上面提到的重試機制和補償機制在這里也是奏效的。
2.5 Write-Behind
Write behind 意為異步回寫模式,它也具有類似 Read-Through/Write-Through 的訪問控制層,不同的是,Write behind 在處理寫請求時,只更新緩存而不更新數據庫,對于數據庫的更新,則是通過批量異步更新的方式進行的,批量寫入的時間點可以選在數據庫負載較低的時間進行。
在 Write-Behind 模式下,寫請求延遲較低,減輕了數據庫的壓力,具有較好的吞吐性。但數據庫和緩存的一致性較弱,比如當更新的數據還未被寫入數據庫時,直接從數據庫中查詢數據是落后于緩存的。同時,緩存的負載較大,如果緩存宕機會導致數據丟失,所以需要做好緩存的高可用。顯然,Write behind 模式下適合大量寫操作的場景,常用于電商秒殺場景中庫存的扣減。
2.6 Write-Around
如果一些非核心業務,對一致性的要求較弱,可以選擇在 cache aside 讀模式下增加一個緩存過期時間,在寫請求中僅僅更新數據庫,不做任何刪除或更新緩存的操作,這樣,緩存僅能通過過期時間失效。這種方案實現簡單,但緩存中的數據和數據庫數據一致性較差,往往會造成用戶的體驗較差,應慎重選擇。
總結
在解決緩存一致性的過程中,有多種途徑可以保證緩存的最終一致性,應該根據場景來設計合適的方案,讀多寫少的場景下,可以選擇采用“ Cache-Aside 結合消費數據庫日志做補償”的方案,寫多的場景下,可以選擇采用“ Write-Through 結合分布式鎖”的方案 ,寫多的極端場景下,可以選擇采用“ Write-Behind ” 的方案。
最近其他好文:
最近大火的 NFT 數字藏品是什么?
2021 騰訊技術十大熱門文章
服務器開發設計之算法寶典
總結
以上是生活随笔為你收集整理的浅谈缓存最终一致性的解决方案的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: 2021 腾讯技术十大热门文章
- 下一篇: 2022年十大科技应用趋势 | 万字报告