This page looks best with JavaScript enabled

我的 cache 沒感覺了

 ·  ☕ 6 min read

先來說說本篇文章的背景故事,鋪墊一下為什麼會想寫這篇文章。不論是同事還是朋友常常會會遇到應用服務( Application Service )套上快取( Cache )會出現一致性問題的問題,也就是資料庫( Database server) 跟 Cache 上面的資料不一致,本篇文章會簡單的討論一下為什麼會出現這樣的問題以及要如何去盡量防範這種不一致的情況。

在這裡我想先說我的看法,如果有不一樣的想法的朋友歡迎提出討論。
我認為套上 Cache 幾乎 沒辦法保證強一致性。
可能會有朋友說有很多方式可以解決啊。

  • 在存取期間加入悲觀鎖,在併發寫的時候加鎖、讀取資料的時候不寫入 cache 。
  • 透過分散式 transaction 3PC、TCC。
  • 或是封裝 CAS 樂觀鎖,更新 cache 透過 lua 腳本去執行原子更新。

這個問題可以回到經典的 CAP 理論,我個人認為套用 Cache 的場景偏向於 CAP 中的 AP 。

CAP 理論,表示在一個分散式系統中, Consistency(一致性)、 Availability(可用性)、Partition tolerance(分區容錯性),理論上三者不可得兼。
但可以透過一些手段達成弱一致性,或是最終一致性。這邊非常建議各位朋友閱讀 Jack 大大在鐵人賽撰寫的文章分散式系統 - 在分散的世界中保持一致 ,可以獲的更多啟發。

我們的應用服務有可能是以下這種架構

Application Service
        |
        |
        |
    Cache Server
        |
        |
        |
    Database server

這邊我們要先想一下,我們常用的 Cahca Pattern,常見的是以下三種,這三種模式在本篇文章中會簡易的說明。

  1. Cache-Aside
  2. Write-behind
  3. Read/Write through

Cache-Aside

最常見的 cache 架構,注意點為,在寫請求的情況會先更新 db 在刪除 cache。

write

流程如圖所示:

  1. Application Service 收到寫資料請求
  2. 將資料寫入 db
  3. 刪除 cache 資料

read

流程如圖所示:

  1. Application Service 收到讀資料請求
  2. 先讀 cache 看看有沒有資料(does cache hit ?)
    • cache hit 直接回傳資料
  3. cahce 未命中,從 db 讀取讀取資料
  4. 把 db 讀取出來的資料寫入 cache
    • 回傳資料

Write-behind

比較少看到這種情 cache 設計,主要是對資料遺失不是特別在意才會採用的方案,注意點為, Application Service 內部在上一層 local cache ,主要是先更新 local cache 在批次的向 DB 寫入。

write

流程如圖所示:

  1. Application Service 收到寫資料請求
  2. 先更新 local cache
  3. 更新外部 cache

再有一個請求進來

  1. Application Service 收到寫資料請求

  2. 先更新 local cache

  3. 更新外部 cache
    隔一段時間後批次將cache資料寫入 db

  4. 將第一次請求得資料寫入 db

  5. 將第二次請求得資料寫入 db

read

流程如圖所示:

  1. Application Service 收到讀資料請求
  2. Application Service 會先檢查 local cache 是否存在對應的資料
    • 若是存在於 local cache 直接回傳對應資料
  3. 若是不存在 local cache 將往到外部 cache 查詢資料
    • 若是外部 cahce 有資料直接會傳
  4. 若是不存在於外部 cache 的話將會從 db 撈資料
  5. 從 DB 撈出來的資料先寫回 local cache
  6. 最後再寫入外部 cache

Read/Write through

大多數人會稱為三層架構(還是只有我這麼說xDD),注意點為, Application Service 內部在上一層 local cache 在寫請求的情況會先更新 local cache 再 db 最後才是更新 cache。

可以理解為凡事以 local cache 為優先,再逐步更新 db 以及外部 cache 。

通常會在 local cache 上 ttl

write

流程如圖所示:

  1. Application Service 收到寫資料請求
  2. Application Service 將資料寫入 local cache
  3. 將資料往 Db 送
  4. 最後更新外部 cache

read

流程如圖所示:

  1. Application Service 收到度資料請求
  2. Application Service 會先檢查 local cache 是否存在對應的資料
    • 若是存在於 local cache 直接回傳對應資料
  3. 若是不存在 local cache 將往後到外部 cache 查詢資料
    • 若是外部 cahce 有資料直接會傳
  4. 若是不存在於外部 cache 的話將會從 db 撈資料
  5. 從 DB 撈出來的資料先寫回 local cache
  6. 最後再寫入外部 cache

你可能會說:『對,我們大多數的應用程式都是長這樣子。』,那一致性問題會出現在哪呢?

思考A->更新資料時要先 update cache 還是 delete cache??

這個題目我們用 cache Aside 來觀察,從上面的寫的流程來看為 先更新資料庫 接著刪除 cache

至於為什麼要這麼設計?

example

我們來看一個有如果是先 update cache 的簡單的例子吧。

  1. A 送了一個寫入請求給 Application Service
  2. Application Service 向 DB 寫入 A 的資料
  3. B 送了一個寫入請求給 Application Service
  4. Application Service 向 DB 寫入 B 的資料
    (按照順序來說應是先更新 A 的資料到 cache 中)
  5. 因為網路問題,A的資料寫入 cache 時就 timeout 了
  6. B的資料寫入 cache
  7. A timeout 重新寫入 cache

請問這時候使用者去讀 cache 會讀到資料 A 還是資料 B 呢?

很顯然是資料A,也就是過時的資料!

solution

解法也相當簡單,只要把 update cache 的部分轉換成 delete cache 就行了,也就是標準的 cache Aside。

  • 你可能會思考這樣子就真的 ok 嗎?
    • 正如我開頭所說的,加上cache 非常 難達到所謂的強一制性。
  • 為什麼?
    • 有許多狀況我們可以思考
      • 表準的 cache Aside 是先更新 db 在刪除 cache。如果說有一種情況為,刪除 cache 失敗呢 <下面會看到例子>?

思考B->更新資料時要先操作 DB 還是先操作 cache ??

這個題目我們用 cache Aside 來觀察,從上面的寫的流程來看為 先更新資料庫 接著刪除cache

為什麼不是先刪除cache 接著才更新 DB ?

example

  1. A 送了一個寫入請求給 Application Service
  2. Application Service 向 從 Cache 刪除 A 資料
    1. A' 時刻送了一個讀請求給 Application Service
  3. 因為網路問題,A' 讀的資料先執行到了,導致以下狀況發生
    • 所以 A' 時刻讀 cache miss
    • 進一步到 DB 拉資料
    • 寫入到 cache
  4. Application Service 向 DB 寫入 A 的資料

請問這之後使用者去讀 cache 會讀到資料應該還是讀到 A' 時刻的資料,還是 A 時刻的資料?

很顯然是資料A,也就是過時的資料!

solution

解法也相當簡單,只要先操作 DB 的在操作 cache 就行了,也就是標準的 cache Aside。

  • 你可能會思考這樣子就真的 ok 嗎?
    • 正如我開頭所說的,加上cache 非常 難達到所謂的強一制性。
  • 為什麼?
    • 有許多狀況我們可以思考
      • 表準的 cache Aside 是先更新 db 在刪除 cache。如果說有一種情況為,刪除 cache 失敗呢仍然更新 DB 了呢 ?

可能有聽過的延遲雙刪除策略

可能各位朋友也有聽過一種做法叫做 延遲雙刪除策略 ,這邊就簡單的說明一下什麼是延遲雙刪除策略。

簡單來說就是更新資料的時候,先刪除 cache 相隔一段時間後再次刪除 cache。
我們想一下,如果有請求 A 寫入、請求 A 更新為 A' 以及讀取最新的資料,順序應該為下列所示。

  1. 寫入 A 到 DB
  2. 刪除 cache
  3. 寫入 A' 到 DB
  4. 刪除 cache
  5. 想要從 cache 讀取 A' (cache miss)
  6. 從 DB 讀取 A'
  7. 寫入 A' 到 cache

如果這中間發生了一些延遲,可能行為會長得像以下情形。

  1. 寫入 A 到 DB
  2. 刪除 cache
  3. 寫入 A' 到 DB
  4. 從 cache 讀取 A (cache miss)read
  5. 從 DB 讀取 Aread
  6. 刪除 cache
  7. 寫入 A 到 cacheread

這裡就發生了,cache 與DB 不一致,這時候可以透過雙刪策略去盡量別免這件事情的發生。

我們先來看一下流程圖


流程如圖所示:

  1. Application Service 收到寫資料請求
  2. 刪除 cache 資料
  3. 將資料寫入 db
  4. 刪除 cache 資料

了這樣做的好處是,在業務邏輯允許的情況下,會讓 cache 與 DB 不同步的狀況減少,用雙刪除保證一件事情….中間拿走舊資料的時間有限,第二次刪除我就會把就漏的地方控制xD。

  1. 刪除 cache write 1
  2. 寫入 A 到 DBwrite 1
  3. 刪除 cache write 1
  4. 刪除 cache[write 2]
  5. 寫入 A' 到 DB[write 2]
  6. 從 cache 讀取 A (cache miss)read
  7. 從 DB 讀取 Aread
    1. 寫入 A 到 cachewrite 2
  8. 刪除 cachewrite 2

到這裡其實還有問題,刪除有可能失敗。可能導致使用者一直以為 cache 上的是最新資料,這時候該怎麼辦?

還可以用刪除重試策略

在上一小節中提到延遲雙刪除策略,只要是 api 操作就有可能遇到 timeout 、 Packet loss 等問題,那這時候可能會採取刪除重試策略,白話文可以翻譯成刪到成功為止。

流程如圖所示:

  1. Application Service 收到寫資料請求
  2. 刪除 cache 資料
    • 如果失敗的話 Application Service 會把要 delete cache 的物件丟到 queue 裡面,用來確認之後刪除的對象
    • Queue會嘗試 delete cache 的物件(直到成功、或是某一遺忘種策略)
  3. 將資料寫入 db
  4. 刪除 cache 資料
    • 如果失敗的話 Application Service 會把要 delete cache 的物件丟到 queue 裡面,用來確認之後刪除的對象
    • Queue會嘗試 delete cache 的物件(直到成功、或是某一遺忘種策略)

如果會覺得這種方式對於對於 code 不友善(我就是不想寫扣垃xD)的話,還有什麼方式呢?

bindlog 刪除策略

在上一小節中提到我就是不想寫扣該怎麼辦,那可以試試看採用 biglog 刪除策略 ,只要將 DB 的 log訊息 bind 到 MQ 上面,由 MQ 的 ACK 機制確認 cache 有沒有正確處理訊息。

流程如圖所示:

  1. Application Service 收到寫資料請求
  2. 刪除 cache 資料
  3. 將資料寫入 db
    • 由於 DB 的 log 與 MQ 進行綁定, DB 的某些操做會 enqueue MQ
    • MQ 裡面的資料在 dequeue 時會自動向 cache 進行操作,並且以 ack 來確認是否操作成功(不成功的話不能 dequeue 或是執行 reenqueue )

Reference


Meng Ze Li
WRITTEN BY
Meng Ze Li
Kubernetes / DevOps / Backend

What's on this Page