當執行寫操作后,需要保證從緩存讀取到的數據與數據庫中持久化的數據是一致的,因此需要對緩存進行更新。
因為涉及到數據庫和緩存兩步操作,難以保證更新的原子性。所以在設計更新策略時,我們需要考慮多個方面的問題:
- 對系統吞吐量的影響:比如更新緩存策略產生的數據庫負載小於刪除緩存策略的負載
- 並發安全性:並發讀寫時某些異常操作順序可能造成數據不一致,如緩存中長期保存過時數據
- 更新失敗的影響:若某個操作失敗,如何對業務影響降到最小
- 檢測和修復故障的難度: 操作失敗導致的錯誤會在日志留下詳細的記錄容易檢測和修復。並發問題導致的數據錯誤沒有明顯的痕跡難以發現,且在流量高峰期更容易產生並發錯誤導致業務風險較大。
更新緩存有兩種方式:
- 刪除失效緩存: 刪除舊緩存后,讀取時會因為未命中緩存而從數據庫中讀取新的數據並更新到緩存中
- 更新緩存: 直接將新的數據寫入緩存覆蓋過期數據
更新緩存和更新數據庫有兩種順序:
- 先數據庫后緩存
- 先緩存后數據庫
兩兩組合共有四種更新策略,現在我們逐一進行分析。
並發問題通常由於后開始的線程卻先完成操作導致,我們把這種現象稱為“搶跑”。 下面我們逐一分析四種策略中“搶跑”帶來的錯誤。
先更新數據庫,再刪除緩存
若數據庫更新成功,刪除緩存操作失敗,則此后讀到的都是緩存中過期的數據,造成不一致問題。
可能存在讀寫線程競爭導致的並發錯誤:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
1 | 緩存失效 | v1 | null | |
2 | 從數據庫讀取v1 | v1 | null | |
3 | 更新數據庫 | v2 | null | |
4 | 刪除緩存 | v2 | null | |
5 | 寫入緩存 | v2 | v1 |
先更新數據庫,再更新緩存
同刪除緩存策略一樣,若數據庫更新成功緩存更新失敗則會造成數據不一致問題。
該策略同樣存在讀寫線程競爭導致數據不一致的問題:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
1 | 緩存失效 | v1 | null | |
2 | 從數據庫讀取v1 | v1 | null | |
3 | 更新數據庫 | v2 | null | |
4 | 寫入緩存 | v2 | v2 | |
5 | 寫入緩存 | v2 | v1 |
也可能因為兩個寫線程競爭導致並發錯誤:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新數據庫為 v1 | v1 | v0 | |
2 | 更新數據庫為 v2 | v2 | v0 | |
3 | 更新緩存為 v2 | v2 | v2 | |
4 | 更新緩存為 v1 | v2 | v1 |
我們可以在寫入緩存前先比較數據的版本號或者修改時間,禁止向緩存中寫入更舊的版本。
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新數據庫為 v1 | v1 | v0 | |
2 | 更新數據庫為 v2 | v2 | v0 | |
3 | 更新緩存為 v2 | v2 | v2 | |
4 | 嘗試向緩存中寫入 v1,發現版本號低於緩存中的版本(v2),放棄寫入 | v2 | v2 |
由上圖可見,更新緩存前比較版本號可以有效的避免並發錯誤的發生。
先刪除緩存,再更新數據庫
可能發生的並發錯誤:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
1 | 刪除緩存 | v1 | null | |
2 | 緩存失效 | v1 | null | |
3 | 從數據庫讀取v1 | v1 | null | |
4 | 更新數據庫為v2 | v2 | null | |
5 | 將v1寫入緩存 | v2 | v1 |
先更新緩存,再更新數據庫
若緩存更新成功數據庫更新失敗, 則此后讀到的都是未持久化的數據。因為緩存中的數據是易失的,這種狀態非常危險。
因為數據庫中存在的鍵約束導致數據庫寫入失敗的可能性較高,所以發生上述錯誤的概率會進一步升高。
該策略同樣存在讀寫線程競爭導致的錯誤:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
1 | 緩存失效 | v1 | null | |
2 | 從數據庫讀取v1 | v1 | null | |
3 | 更新緩存 | v1 | v2 | |
4 | 寫入數據庫 | v2 | v2 | |
5 | 寫入緩存 | v2 | v1 |
兩個寫線程競爭也會導致數據不一致:
時間 | 線程A | 線程B | 數據庫 | 緩存 |
---|---|---|---|---|
0 | v0 | v0 | ||
1 | 更新緩存為 v1 | v0 | v1 | |
2 | 更新緩存為 v2 | v0 | v2 | |
3 | 更新數據庫為 v2 | v2 | v2 | |
4 | 更新數據庫為 v1 | v1 | v2 |
異步更新
雙寫更新的邏輯復雜,一致性問題較多。我們可以采用訂閱數據庫更新的方式來更新緩存。
阿里開源了 MySQL 數據庫binlog的增量訂閱和消費組件 - canal。 canal 模擬從庫獲得主庫的 binlog 更新,然后將更新數據寫入 MQ 或直接進行消費。
我們可以讓API服務器只負責寫入數據庫,另一個線程訂閱數據庫 binlog 增量進行緩存更新。
因為 binlog 是有序的,因此可以避免兩個寫線程競爭。但我們仍然需要解決讀寫線程競爭的問題:
時間 | 讀線程 | 寫線程 | 異步線程 | 數據庫 | 緩存 |
---|---|---|---|---|---|
1 | 緩存失效 | v1 | null | ||
2 | 從數據庫讀取v1 | v1 | null | ||
3 | 更新數據庫為v2 | v2 | null | ||
4 | 刪除緩存/更新緩存 | v2 | null | ||
5 | 寫入緩存 | v2 | v1 |
與雙寫策略類似,只需要在寫入緩存前比較一下版本號即可:
時間 | 讀線程 | 寫線程 | 異步線程 | 數據庫 | 緩存 |
---|---|---|---|---|---|
1 | 緩存失效 | v1 | null | ||
2 | 從數據庫讀取v1 | v1 | null | ||
3 | 更新數據庫為v2 | v2 | null | ||
4 | 更新緩存 | v2 | v2 | ||
5 | 嘗試更新緩存為v1,因版本號過低放棄更新 | v2 | v2 |