---
# System prepended metadata

title: 保證資料庫與快取的資料一致性及面臨的困難

---

如何保證資料庫與快取的資料一致性及可能面臨的困難 
===

最近在公司接手一個新的專案，已經是一個開發好可以動的系統了，但隨著用戶的使用也不斷在反饋系統的問題，於是就請我這個 “新人” 來解決這個專案的各種疑難雜症。

剛開始接手看到程式結構就頓感不妙，整個系統設計看似是微服務但又不那麼微服務，且竟然在 domain 層註冊路由，api 的設計也並未遵循 RESTful 設計原則，細細追蹤程式之後，果不其然底層的系統設計也一塌糊塗，因此有了今天的這篇文章。

### 那麼到底是什麼問題？

在一系列用戶反饋中，反應最多的問題大多集中在 「餘額與預期不符」一類，細查之下發現原系統設計竟將餘額這種資訊走入 Redis 這樣應被視為快取的資料庫中，Redis 單線程的特性看似所有的操作都是原子性的，能夠保證資料一致性，但他的更新策略是：先更新 Redis 快取，然後更新資料庫。

這樣的做法也就引出兩個問題：

1. 什麼樣的資料可以走快取？
2. 快取的更新策略？

快取應該儲存哪些資料？
---

知道的人都知道，快取希望做到的是盡可能降低 miss rate，讓資料總是能在快取中找到，另外就是即便快取資料與資料庫中的資料稍有不一致也不會對整個系統造成太大的影響。所以可以總結成以下兩點：

### 不常變動的資料可以存在快取！

想要降低 miss rate 最簡單直接的方式就是存放常常被讀取但又不常變更的資料，因為只要資料變更就必須要走快取更新，這也就提高了快取的 miss rate，如果這筆資料經常更新，那麼走快取的意義也不大，因為不斷的 miss，和不用快取又有什麼區別呢？

### 資料不一致？無傷大雅！

即便資料出現問題也不會有過大的影響，例如一個錯字，因為快取通常都有過期的機制，因此即便出錯，在過期之後也能重新更新成正確的資料。而什麼樣的資料會有很大的影響呢？正是上面提到的用戶餘額，如果用戶餘額資料不一致，就有可能導致用戶可以使用的餘額可能高於實際能使用的餘額，導致資料庫中的餘額變成負數等等。

應該怎麼更新快取資料？
---

準備過資工研究所考試的人相信應該比我還熟悉，快取更新的策略有兩種，write back 和 write through，但在實務上我們兩種都不用，因為我們需要考量網路錯誤問題，以及多線程同時執行的問題（俗稱併發問題）。

我們先來看看上面提到的先更新快取然後更新資料庫會有什麼問題，看圖最直觀：

![Drawing 2025-01-05 13.41.13.excalidraw](https://hackmd.io/_uploads/HJ-H2cPIye.png)

這裡我直接畫出了存在問題的點，如果快取更新成功，但更新資料庫時出現問題，那麼兩者的資料就會造成永久不一致，問題也就出現了，所以第一個方案不成立。

那麼我換成先更新資料庫，然後更新快取呢？再來看圖，這次我們來看的就是併發問題：

![Drawing 2025-01-05 13.41.13.excalidraw](https://hackmd.io/_uploads/HkzhgjPIke.svg)


顯然也不用多做解釋，這裡資料已經不一致了，網路的延遲問題或者讀寫突然卡了一下都有可能造成上圖中的情況。

所以使用「更新」的方法已經沒辦法保證資料一致性了，那麼就要使用快取的特性，我們一般使用快取時，如果沒有在快取中找到資料自然會去資料庫中找到資料，然後再寫回快取中，我們可以利用這個特性，對快取資料進行「刪除」而非「更新」。那麼現在來看先刪除快取，然後更新資料庫會有的問題：

![Drawing 2025-01-05 13.41.13.excalidraw](https://hackmd.io/_uploads/HkJhfiDL1x.svg)

這樣的情況在寫寫併發不會有問題，因為快取最終的結果都是被刪除資料，但讀寫併發就會有問題，具體問題如上，先刪除了快取，然後另一個線程因為從快取中找不到資料所以進入資料庫找，但此時資料庫資料尚未更新，該線程就把舊資料寫回快取了。

所以答案就趨向於最後一種解法，先更新資料庫，然後刪除快取，我們看一下這樣的做法究竟好不好：

![Drawing 2025-01-05 13.41.13.excalidraw](https://hackmd.io/_uploads/rkY4DoDI1x.svg)

這麼看起來讀寫併發的問題也得到了解決，只有在尚未刪除快取且又有新線程讀取資料時會有小機率可能讀取到舊資料，但在之後的讀取中資料都會是正確的。而寫寫併發方面，兩次對快取的操作都是刪除，所以最終的結果也是一樣的，這裡就不用圖示了。但事情真的這麼完美，就這麼解決了嗎？我們可以看一下極端情況下可能發生的問題：

![Drawing 2025-01-05 13.41.13.excalidraw](https://hackmd.io/_uploads/r1WHFjvUyl.svg)

這時候又會發現資料不一致了，至於為什麼說這是極端情況呢？因為大多數情況快取的讀寫速度是遠大於資料庫的，不太可能有資料庫更新然後又刪除快取操作後，寫入快取資料才完成的可能。不過這樣的情況機率小但也不到完全不可能，所以面對上面的情況，要求資料強一致的場景下會使用「延遲雙刪（Delay Double Delete）」，也就是更新資料庫資料並刪除快取後過一小段時間再刪除一次快取，否則如果沒有要求強一致性，那麼透過快取的自動過期機制也可以將舊的資料刪除。

快取資料一致性面臨的挑戰
---

經過上面的例子可以看到即便是最後的做法，還是有可能在某幾個瞬間從快取中讀到舊資料，所以本質上**餘額**這種經常變動且要求強一致的資料就不應該存在快取中。

那麼回到最開始說的公司的專案在這樣的系統設計下我又是怎麼解決問題的呢？我當然不可能直接起手改整個系統架構，所以只能在這個要微服務不微服務的系統上添加一個分布式鎖（distributed lock），所有讀寫操作都要等別人完成，拿到鎖之後才能繼續下一筆操作，當然這就違背了快取的用意反而拖慢了整個系統，但在時間不充裕又要解決問題的情況下，這麼做又何嘗不是一種好解法呢？