认识 MySQL 和 Redis 的数据一致性问题
作者:sinxu,騰訊 CSIG 后臺開發工程師
1. 什么是數據的一致性
“數據一致”一般指的是:緩存中有數據,緩存的數據值 = 數據庫中的值。
但根據緩存中是有數據為依據,則”一致“可以包含兩種情況:
緩存中有數據,緩存的數據值 = 數據庫中的值(需均為最新值,本文將“舊值的一致”歸類為“不一致狀態”)
緩存中本沒有數據,數據庫中的值 = 最新值(有請求查詢數據庫時,會將數據寫入緩存,則變為上面的“一致”狀態)
”數據不一致“:緩存的數據值 ≠ 數據庫中的值;緩存或者數據庫中存在舊值,導致其他線程讀到舊數據
2. 數據不一致情況及應對策略
根據是否接收寫請求,可以把緩存分成讀寫緩存和只讀緩存。
只讀緩存:只在緩存進行數據查找,即使用 “更新數據庫+刪除緩存” 策略;
讀寫緩存:需要在緩存中對數據進行增刪改查,即使用 “更新數據庫+更新緩存”策略。
2.1 針對只讀緩存(更新數據庫+刪除緩存)
只讀緩存:新增數據時,直接寫入數據庫;更新(修改/刪除)數據時,先刪除緩存。后續,訪問這些增刪改的數據時,會發生緩存缺失,進而查詢數據庫,更新緩存。
新增數據時 ,寫入數據庫;訪問數據時,緩存缺失,查數據庫,更新緩存(始終是處于”數據一致“的狀態,不會發生數據不一致性問題)
更新(修改/刪除)數據時 ,會有個時序問題:更新數據庫與刪除緩存的順序(這個過程會發生數據不一致性問題)
在更新數據的過程中,可能會有如下問題:
無并發請求下,其中一個操作失敗的情況
并發請求下,其他線程可能會讀到舊值
因此,要想達到數據一致性,需要保證兩點:
無并發請求下,保證 A 和 B 步驟都能成功執行
并發請求下,在 A 和 B 步驟的間隔中,避免或消除其他線程的影響
接下來,我們針對有/無并發場景,進行分析并使用不同的策略。
A. 無并發情況
無并發請求下,在更新數據庫和刪除緩存值的過程中,因為操作被拆分成兩步,那么就很有可能存在“步驟 1 成功,步驟 2 失敗” 的情況發生(由于單線程中步驟 1 和步驟 2 是串行執行的,不太可能會發生 “步驟 2 成功,步驟 1 失敗” 的情況)。
(1) 先刪除緩存,再更新數據庫
(2) 先更新數據庫,再刪除緩存
解決策略:
a.消息隊列+異步重試
無論使用哪一種執行時序,可以在執行步驟 1 時,將步驟 2 的請求寫入消息隊列,當步驟 2 失敗時,就可以使用重試策略,對失敗操作進行 “補償”。
具體步驟如下:
把要刪除緩存值或者是要更新數據庫值操作生成消息,暫存到消息隊列中(例如使用 Kafka 消息隊列);
當刪除緩存值或者是更新數據庫值操作成功時,把這些消息從消息隊列中去除(丟棄),以免重復操作;
當刪除緩存值或者是更新數據庫值操作失敗時,執行失敗策略,重試服務從消息隊列中重新讀取(消費)這些消息,然后再次進行刪除或更新;
刪除或者更新失敗時,需要再次進行重試,重試超過的一定次數,向業務層發送報錯信息。
b.訂閱 Binlog 變更日志
創建更新緩存服務,接收數據變更的 MQ 消息,然后消費消息,更新/刪除 Redis 中的緩存數據;
使用 Binlog 實時更新/刪除 Redis 緩存。利用 Canal,即將負責更新緩存的服務偽裝成一個 MySQL 的從節點,從 MySQL 接收 Binlog,解析 Binlog 之后,得到實時的數據變更信息,然后根據變更信息去更新/刪除 Redis 緩存;
MQ+Canal 策略,將 Canal Server 接收到的 Binlog 數據直接投遞到 MQ 進行解耦,使用 MQ 異步消費 Binlog 日志,以此進行數據同步;
不管用 MQ/Canal 或者 MQ+Canal 的策略來異步更新緩存,對整個更新服務的數據可靠性和實時性要求都比較高,如果產生數據丟失或者更新延時情況,會造成 MySQL 和 Redis 中的數據不一致。因此,使用這種策略時,需要考慮出現不同步問題時的降級或補償方案。
B. 高并發情況
使用以上策略后,可以保證在單線程/無并發場景下的數據一致性。但是,在高并發場景下,由于數據庫層面的讀寫并發,會引發的數據庫與緩存數據不一致的問題(本質是后發生的讀請求先返回了)
(1) 先刪除緩存,再更新數據庫
假設線程 A 刪除緩存值后,由于網絡延遲等原因導致未及更新數據庫,而此時,線程 B 開始讀取數據時會發現緩存缺失,進而去查詢數據庫。而當線程 B 從數據庫讀取完數據、更新了緩存后,線程 A 才開始更新數據庫,此時,會導致緩存中的數據是舊值,而數據庫中的是最新值,產生“數據不一致”。其本質就是,本應后發生的“B 線程-讀請求” 先于 “A 線程-寫請求” 執行并返回了。
或者
解決策略:
a.設置緩存過期時間 + 延時雙刪
通過設置緩存過期時間,若發生上述淘汰緩存失敗的情況,則在緩存過期后,讀請求仍然可以從 DB 中讀取最新數據并更新緩存,可減小數據不一致的影響范圍。雖然在一定時間范圍內數據有差異,但可以保證數據的最終一致性。
此外,還可以通過延時雙刪進行保障:在線程 A 更新完數據庫值以后,讓它先 sleep 一小段時間,確保線程 B 能夠先從數據庫讀取數據,再把缺失的數據寫入緩存,然后,線程 A 再進行刪除。后續,其它線程讀取數據時,發現緩存缺失,會從數據庫中讀取最新值。
redis.delKey(X) db.update(X) Thread.sleep(N) redis.delKey(X)sleep 時間:在業務程序運行的時候,統計下線程讀數據和寫緩存的操作時間,以此為基礎來進行估算:
注意:如果難以接受 sleep 這種寫法,可以使用延時隊列進行替代。
先刪除緩存值再更新數據庫,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。
(2) 先更新數據庫,再刪除緩存
如果線程 A 更新了數據庫中的值,但還沒來得及刪除緩存值,線程 B 就開始讀取數據了,那么此時,線程 B 查詢緩存時,發現緩存命中,就會直接從緩存中讀取舊值。其本質也是,本應后發生的“B 線程-讀請求” 先于 “A 線程-刪除緩存” 執行并返回了。
或者,在”先更新數據庫,再刪除緩存”方案下,“讀寫分離 + 主從庫延遲”也會導致不一致:
解決方案:
a.延遲消息
憑借經驗發送「延遲消息」到隊列中,延遲刪除緩存,同時也要控制主從庫延遲,盡可能降低不一致發生的概率
b.訂閱 binlog,異步刪除
通過數據庫的 binlog 來異步淘汰 key,利用工具(canal)將 binlog 日志采集發送到 MQ 中,然后通過 ACK 機制確認處理刪除緩存。
c.刪除消息寫入數據庫
通過比對數據庫中的數據,進行刪除確認 先更新數據庫再刪除緩存,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力,也就是緩存穿透的問題。針對緩存穿透問題,可以用緩存空結果、布隆過濾器進行解決。
d.加鎖
更新數據時,加寫鎖;查詢數據時,加讀鎖 保證兩步操作的“原子性”,使得操作可以串行執行。“原子性”的本質是什么?不可分割只是外在表現,其本質是多個資源間有一致性的要求,操作的中間狀態對外不可見。
建議:
優先使用“先更新數據庫再刪除緩存”的執行時序,原因主要有兩個:
先刪除緩存值再更新數據庫,有可能導致請求因緩存缺失而訪問數據庫,給數據庫帶來壓力;
業務應用中讀取數據庫和寫緩存的時間有時不好估算,進而導致延遲雙刪中的 sleep 時間不好設置。
2.2 針對讀寫緩存(更新數據庫+更新緩存)
讀寫緩存:增刪改在緩存中進行,并采取相應的回寫策略,同步數據到數據庫中
同步直寫:使用事務,保證緩存和數據更新的原子性,并進行失敗重試(如果 Redis 本身出現故障,會降低服務的性能和可用性)
異步回寫:寫緩存時不同步寫數據庫,等到數據從緩存中淘汰時,再寫回數據庫(沒寫回數據庫前,緩存發生故障,會造成數據丟失) 該策略在秒殺場中有見到過,業務層直接對緩存中的秒殺商品庫存信息進行操作,一段時間后再回寫數據庫。
一致性:同步直寫 > 異步回寫 因此,對于讀寫緩存,要保持數據強一致性的主要思路是:利用同步直寫 同步直寫也存在兩個操作的時序問題:更新數據庫和更新緩存
A. 無并發情況
B. 高并發情況
有四種場景會造成數據不一致:
針對場景 1 和 2 的解決方案是:保存請求對緩存的讀取記錄,延時消息比較,發現不一致后,做業務補償 針對場景 3 和 4 的解決方案是:對于寫請求,需要配合分布式鎖使用。寫請求進來時,針對同一個資源的修改操作,先加分布式鎖,保證同一時間只有一個線程去更新數據庫和緩存;沒有拿到鎖的線程把操作放入到隊列中,延時處理。用這種方式保證多個線程操作同一資源的順序性,以此保證一致性。
其中,分布式鎖的實現可以使用以下策略:
2.3 強一致性策略
上述策略只能保證數據的最終一致性。要想做到強一致,最常見的方案是 2PC、3PC、Paxos、Raft 這類一致性協議,但它們的性能往往比較差,而且這些方案也比較復雜,還要考慮各種容錯問題。如果業務層要求必須讀取數據的強一致性,可以采取以下策略:
(1)暫存并發讀請求
在更新數據庫時,先在 Redis 緩存客戶端暫存并發讀請求,等數據庫更新完、緩存值刪除后,再讀取數據,從而保證數據一致性。
(2)串行化
讀寫請求入隊列,工作線程從隊列中取任務來依次執行
修改服務 Service 連接池,id 取模選取服務連接,能夠保證同一個數據的讀寫都落在同一個后端服務上
修改數據庫 DB 連接池,id 取模選取 DB 連接,能夠保證同一個數據的讀寫在數據庫層面是串行的
(3)使用 Redis 分布式讀寫鎖
將淘汰緩存與更新庫表放入同一把寫鎖中,與其它讀請求互斥,防止其間產生舊數據。讀寫互斥、寫寫互斥、讀讀共享,可滿足讀多寫少的場景數據一致,也保證了并發性。并根據邏輯平均運行時間、響應超時時間來確定過期時間。
public?void?write()?{Lock?writeLock?=?redis.getWriteLock(lockKey);writeLock.lock();try?{redis.delete(key);db.update(record);}?finally?{writeLock.unlock();} }public?void?read()?{if?(caching)?{return;}//?no?cacheLock?readLock?=?redis.getReadLock(lockKey);readLock.lock();try?{record?=?db.get();}?finally?{readLock.unlock();}redis.set(key,?record); }2.4 小結
針對讀寫緩存時:同步直寫,更新數據庫+更新緩存:
針對只讀緩存時:更新數據庫+刪除緩存:
較為通用的一致性策略擬定:
在并發場景下,使用 “更新數據庫 + 更新緩存” 需要用分布式鎖保證緩存和數據一致性,且可能存在”緩存資源浪費“和”機器性能浪費“的情況;一般推薦使用 “更新數據庫 + 刪除緩存” 的方案。如果根據需要,熱點數據較多,可以使用 “更新數據庫 + 更新緩存” 策略。
在 “更新數據庫 + 刪除緩存” 的方案中,推薦使用推薦用 “先更新數據庫,再刪除緩存” 策略,因為先刪除緩存可能會導致大量請求落到數據庫,而且延遲雙刪的時間很難評估。在 “先更新數據庫,再刪除緩存” 策略中,可以使用“消息隊列+重試機制” 的方案保證緩存的刪除。并通過 “訂閱 binlog” 進行緩存比對,加上一層保障。
此外,需要通過初始化緩存預熱、多數據源觸發、延遲消息比對等策略進行輔助和補償。【多種數據更新觸發源:定時任務掃描,業務系統 MQ、binlog 變更 MQ,相互之間作為互補來保證數據不會漏更新】
3. 數據一致性中需要注意的其他問題有哪些?
(1) k-v 大小的合理設置
Redis key 大小設計:由于網絡的一次傳輸 MTU 最大為 1500 字節,所以為了保證高效的性能,建議單個 k-v 大小不超過 1KB,一次網絡傳輸就能完成,避免多次網絡交互;k-v 是越小性能越好Redis 熱 key:(1) 當業務遇到單個讀熱 key,通過增加副本來提高讀能力或是用 hashtag 把 key 存多份在多個分片中;(2)當業務遇到單個寫熱 key,需業務拆分這個 key 的功能,屬于設計不合理- 當業務遇到熱分片,即多個熱 key 在同一個分片上導致單分片 cpu 高,可通過 hashtag 方式打散——[引自騰訊云技術分享]
(2 )避免其他問題導致緩存服務器崩潰,進而簡直導致數據一致性策略失效
緩存穿透、緩存擊穿、緩存雪崩、機器故障等問題:
(3)方案選定的思路
確定緩存類型(讀寫/只讀)
確定一致性級別
確定同步/異步方式
選定緩存流程
補充細節
參考
https://xie.infoq.cn/article/1322475e05c11bd2aacd8bc73
https://www.infoq.cn/article/Hh4IOuIiJHWB4X46vxeO
https://time.geekbang.org/column/article/217593
https://xie.infoq.cn/article/ab2599366009928a17fe498fb
緩存與數據庫一致性保證
https://time.geekbang.org/column/article/295812
https://blog.csdn.net/chengh1993/article/details/112685774
https://juejin.cn/post/6850418120201666568
最近熱文:
開發常用的縮寫 你能看懂幾個?
TencentOCR 斬獲 ICDAR 2021 三項冠軍
微信終端自研 C++協程框架的設計與實現
總結
以上是生活随笔為你收集整理的认识 MySQL 和 Redis 的数据一致性问题的全部內容,希望文章能夠幫你解決所遇到的問題。
- 上一篇: TencentOCR 斩获 ICDAR
- 下一篇: 从根本上了解异步编程体系