后端服務(wù)緩存總結(jié)
背景
最近再思考之前做的一個項目的時候, 有遇到一個問題, 那就是多級緩存的一致性問題.
之前在更新策略里面提到了DB和緩存的一些更新時的操作, 本文探討存在并發(fā)場景下緩/多級緩存可能存在的問題.
并發(fā)場景下的集中緩存
在絕大多數(shù)以HTTP為核心的請求情況下, 由于不同的負載均衡策略, 可能會導(dǎo)致有不同的請求落在集中緩存中.
<span style="font-weight: bold;" class="bold">讀</span>
一個讀緩存的請求結(jié)果可能有以下三種:
- 緩存命中, 且數(shù)據(jù)有效 (可以直接使用數(shù)據(jù))
- 緩存命中, 但數(shù)據(jù)無效 (使用兜底處理 / 請求DB )
- 緩存未命中 (讀DB -> 寫緩存)
筆者的技術(shù)有限, 在這里提供幾種方式
分布式鎖
分布式鎖的主要目的是為了控制到達DB的流量
在緩存失效的情況下, 多個進程同時請求DB, 在極端情況下會導(dǎo)致 緩存擊穿, 此時使用一個分布式鎖來控制并發(fā)是一個不錯的選擇
考慮到這個場景下, 對于分布式鎖的要求是能夠過濾大多數(shù)并發(fā)請求即可, 無需追求etcd帶來的強一致性控制.
如果能夠獲取到鎖, 則請求DB并返回. 如果無法獲取到鎖, 則使用 (本地緩存 -> 兜底返回 -> 錯誤返回)
問題
-
<span style="font-weight: bold;" class="bold">在這種策略下, 可以保證對于DB的流量控制, 但是可能會導(dǎo)致出現(xiàn)同時出現(xiàn)較多的無效返回, 可能帶來一些不好的體驗?</span>
如果我們的需求是盡可能的<span style="font-weight: bold;" class="bold">返回數(shù)據(jù)</span>而不是<span style="font-weight: bold;" class="bold">返回有效數(shù)據(jù),</span> 可以添加一個本地緩存(一定要注意<span style="font-weight: bold;" class="bold">本地緩存的TTL</span>)
-
<span style="font-weight: bold;" class="bold">如果存在非常大的流量, 這種策略會不會有問題?</span>
如果我們的分布式緩存都沒法承擔(dān)這種熱點, 這意味著我們還需要一個本地的鎖來控制
即保證在同一段時間內(nèi), 本地只有一個請求去嘗試獲取分布式鎖.
實現(xiàn)可以是通過 <span style="font-weight: bold;" class="bold">實例Hash</span> 獲取本地鎖. 如果能夠獲取到鎖, 則嘗試請求分布式鎖, 如果獲取不到, 則按照 (本地緩存 -> 兜底返回 -> 錯誤返回)
-
<span style="font-weight: bold;" class="bold">本地緩存TTL應(yīng)該怎么設(shè)置?</span>
個人建議是選擇 30% - 50%的集中緩存的TTL, 在加上一些隨機的偏移值(本地緩存的 10% 以內(nèi))
NO TTL
即然造成失效的源頭是緩存失效, 那么只要不失效就好了
對于一些活動類需求, 設(shè)計一套完美的方案是非常浪費時間的, 因為可能你的緩存TTL都比活動時間長
在這種情況下, 我建議你直接做緩存預(yù)熱 + NOTTL
在活動結(jié)束后手動刪除.
問題
-
<span style="font-weight: bold;" class="bold">怎么刪除?</span>
如果能夠單拎出緩存實例最好, 如果不能, 那就設(shè)置一些共同的前綴, 然后再業(yè)務(wù)的低峰進行刪除.
-
<span style="font-weight: bold;" class="bold">怎么不對其他業(yè)務(wù)產(chǎn)生影響?</span>
不要使用
*Keys*命令, 這會直接導(dǎo)致線上問題!不要使用<span style="font-weight: bold;" class="bold">
***Keys***</span>命令, 這會直接導(dǎo)致線上問題!**不要使用<span style="font-weight: bold;" class="bold">
***Keys***</span>命令, 這會直接導(dǎo)致線上問題!<span style="font-weight: bold;" class="bold">你可以寫一個腳本 使用</span>SCAN + UNLINK的<span style="font-weight: bold;" class="bold">方式來處理
這種方案的好處是能夠比較好的保證不阻塞主業(yè)務(wù)邏輯 以及 不會因為大量刪除Key 導(dǎo)致出現(xiàn)Slots Rebalance.
package main import ( "context" "github.com/redis/go-redis/v9" ) var c redis.Client func main() { ctx := context.Background() var cursor uint64 = 0 var keys []string for { var err error keys, cursor, err = c.Scan(ctx, cursor, "biz_prefix*", 100).Result() if err != nil { break } if len(keys) == 0 { break } c.Unlink(ctx, keys...) } }
</span>寫 / 更新<span style="font-weight: bold;" class="bold">
這兩種操作帶來的問題其實會比較多, 原因就出在這個并發(fā)環(huán)境以及一致性保證上了.
為什么? 歸根到底只有一點, 怎么保證并發(fā)環(huán)境下的一致性?
我們需要確定這個幾個基本的規(guī)則:
- </span>如果DB中沒有寫入, 那么必不能從緩存中讀取出.<span style="font-weight: bold;" class="bold">
- </span>盡可能的返回及時的內(nèi)容.<span style="font-weight: bold;" class="bold">
- </span>保證對底層DB有較小的讀負載.<span style="font-weight: bold;" class="bold">
有幾種方案, 我們挨個分析下
更緩存 -> 更DB
</span>我根本不推薦你使用這種方案, 因為不出現(xiàn)問題還好, 一旦出現(xiàn)問題就是巨大的麻煩.<span style="font-weight: bold;" class="bold">
這種方案的優(yōu)點是很快, 進程A更新后, 后續(xù)的進程可以無感知的直接使用新的緩存.
能較好的保證規(guī)則2, 規(guī)則3
但是問題是, 如果更DB失敗了呢?
那么導(dǎo)致的結(jié)果會非常嚴重: 持久化出現(xiàn)問題 </span>相比給出一個過時的返回, 給一個錯誤的返回可能造成更大的風(fēng)險.<span style="font-weight: bold;" class="bold">
請一定慎重!!!
刪緩存 -> 更DB
這個方案聽起來好像沒有什么問題, 但是請不要忽略了一件事情, 在并發(fā)環(huán)境下, 一切皆有可能, 溝槽的并發(fā).
在正常情況下, 我們的想法是刪除緩存, 更新DB, 然后其他的請求過來, 通過上述的</span>讀緩存操作<span style="font-weight: bold;" class="bold">獲得了最新的DB, 然后DB緩存也一致, 完美!
sequenceDiagram
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
A ->> DB: Update
DB ->> A: OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
B ->> Cache: Set Cache
但是實際情況不然
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
A ->> DB: Update
DB ->> A: OK
B ->> Cache: Set Cache
在這種情況下, 刪完緩存, 有另外一個B直接從DB獲取給刷進去了, 那么就壞事兒了.
更DB -> 刪緩存
這個方案是一個比較可用的方案, 先更新DB, 然后再刪除緩存, 這樣不就能保證一致性了嗎?
正常處理如下
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> DB: Update
DB ->> A: OK
A ->> Cache: Delte Cache
Cache -->> A: Delte OK
B --x Cache: Load
B ->> DB: Load
DB ->> B: OK
B ->> Cache: Set Cache
但是實際可能出現(xiàn)一些特殊的情況: 如果刪緩存失敗了呢?
sequenceDiagram
participant A
participant Cache
participant DB
participant B
A ->> DB: Update
DB ->> A: OK
A ->> Cache: Delte Cache
Cache -X A: Delte Error!
B ->> Cache: Load
Cache ->> B: OK
問題
-
</span>如果沒法刪除緩存呢?<span style="font-weight: bold;" class="bold">
那么后果就是要等到一個緩存TTL, 才能獲取到最新的數(shù)據(jù).
-
</span>怎么改善?<span style="font-weight: bold;" class="bold">
其實緩存了一個老的數(shù)據(jù)并不是一個無法接受的事情.
我們可以通過降低TTL的方式來提高一致性, 刪緩存失敗的出現(xiàn)頻率有多高呢?
另外就是如果第一次刪緩存失敗, 等待重試能夠解決大部分問題.
延遲雙刪
刪緩存, 更DB, 再刪緩存.
示意圖:
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
A->>DB: Update Data
DB-->>A: Update OK
A->>+Cache: Delayed Delete Cache (after some time)
Cache-->>-A: Delete OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
改善了 刪緩存 -> 更DB的影響范圍, 我們前面提到了, 如果先刪除緩存在更新DB, 有可能在更新DB前, 被其他的進程給寫了老的緩存.
在延遲雙刪的情況下:
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
A->>DB: Update Data
DB-->>+A: Update OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
A->>-Cache: Delayed Delete Cache (after some time)
Cache-->>A: Delete OK
看起來一切都很美好, 但是在極端的情況下可能出現(xiàn)
sequenceDiagram
participant A as Client A
participant Cache as Cache
participant DB as Database
participant B as Client B
A->>Cache: Delete Cache
Cache-->>A: Delete OK
B--xCache: Load Data
B->> DB: Load Data
DB ->>B: Return Data
B->>Cache: Set Cache
A->>DB: Update Data
DB-->>+A: Update OK
A-x-Cache: Delayed Delete Cache (after some time)
即在最壞的情況下, 可能會回退到 更DB -> 刪緩存 的情況.
思考
-
</span>延遲雙刪改進了什么?<span style="font-weight: bold;" class="bold">
比較 刪緩存 -> 更DB, 解決了中間請求導(dǎo)致的不一致問題
比較 更DB -> 刪緩存, 因為第一次刪除的存在, 保證在 (Delete緩存, Update DB] 中間只要沒有請求就能保證一致性
只有在存在中間請求, 且最后一次刪除失敗的情況下才會出現(xiàn)回退到到 </span>更DB -> 刪緩存<span style="font-weight: bold;" class="bold">的策略.
-
</span>延遲雙刪的延遲改怎么選擇?**
我的建議是選擇業(yè)務(wù) 2 * P99, 這只是一個經(jīng)驗之談, 請盡量考慮自己的業(yè)務(wù)場景以及對不一致請求數(shù)量容忍性來考慮
延遲設(shè)置的越高, 整體一致性越好, 同時中間請求數(shù)量越多
延遲設(shè)置的越低, 中間請求數(shù)量越少, 同時越有可能出現(xiàn)中間請求
last SetCache的情況, 進而導(dǎo)致出現(xiàn)數(shù)據(jù)不一致情況, 一致性破壞可能性越高.
事件驅(qū)動的緩存同步
事件驅(qū)動的緩存同步是指使用事件來觸發(fā)和協(xié)調(diào)緩存與數(shù)據(jù)源之間的數(shù)據(jù)同步。
這種模式特別適合于大規(guī)模、高性能的系統(tǒng)架構(gòu),因為它能夠減少系統(tǒng)組件之間的耦合,提高數(shù)據(jù)處理的效率。
實現(xiàn)
事件驅(qū)動的緩存同步通常涉及以下組件:
- <span style="font-weight: bold;" class="bold">事件生產(chǎn)者</span>:一般是基于業(yè)務(wù)操作產(chǎn)生事件的服務(wù). 通常來說是MongoDB oplog, MySQL binlog (row模式)
- <span style="font-weight: bold;" class="bold">事件傳遞系統(tǒng)</span>:負責(zé)事件的傳輸,常用的有消息隊列系統(tǒng),如 Kafka、RabbitMQ、Redis pub/sub等。
- <span style="font-weight: bold;" class="bold">事件消費者</span>:訂閱事件并執(zhí)行緩存同步操作的服務(wù)。
實現(xiàn)步驟:
- <span style="font-weight: bold;" class="bold">捕獲數(shù)據(jù)變更事件</span>:當(dāng)數(shù)據(jù)源中的數(shù)據(jù)發(fā)生變更時,要生成一個事件。(通常使用Debezium)
- <span style="font-weight: bold;" class="bold">發(fā)布事件</span>:將捕獲的事件發(fā)布到消息隊列系統(tǒng)中。這樣可以解耦生產(chǎn)者和消費者,提高系統(tǒng)的伸縮性和容錯能力。
- <span style="font-weight: bold;" class="bold">處理事件</span>:實現(xiàn)一個或多個事件消費者,它們從消息隊列中訂閱并接收事件,然后根據(jù)事件內(nèi)容來更新緩存中的數(shù)據(jù)。
優(yōu)劣
<span style="font-weight: bold;" class="bold">優(yōu)點:</span>
- <span style="font-weight: bold;" class="bold">低耦合</span>:生產(chǎn)者和消費者之間通過事件解耦,降低了系統(tǒng)間的直接依賴。
- <span style="font-weight: bold;" class="bold">高可擴展性</span>:系統(tǒng)各部分可以獨立擴展,滿足不同的性能要求。
- <span style="font-weight: bold;" class="bold">實時性</span>:可以實現(xiàn)接近實時的緩存更新,提高系統(tǒng)響應(yīng)速度。
- <span style="font-weight: bold;" class="bold">容錯性</span>:消息隊列系統(tǒng)通常具有容錯機制,可以在某個組件失敗時繼續(xù)保持系統(tǒng)的穩(wěn)定。
<span style="font-weight: bold;" class="bold">缺點:</span>
- <span style="font-weight: bold;" class="bold">復(fù)雜性</span>:引入了額外的組件和系統(tǒng)復(fù)雜性,需要更多的維護工作。
- <span style="font-weight: bold;" class="bold">一致性挑戰(zhàn)</span>:可能會出現(xiàn)數(shù)據(jù)不一致的情況,特別是在分布式系統(tǒng)中。
- <span style="font-weight: bold;" class="bold">消息積壓</span>:在高負載情況下,如果處理不及時,可能會導(dǎo)致消息堆積。
- <span style="font-weight: bold;" class="bold">延遲波動</span>:系統(tǒng)的性能可能會受到網(wǎng)絡(luò)延遲和消息隊列性能的影響。
并發(fā)情況下的多級緩存
以下內(nèi)容不適用
通常來說是不需要多級緩存的, 為什么?
在經(jīng)典的后端場景下, 底層DB的典型QPS在1W, 緩存中間件的QPS在10W
通常來說, 很少有能夠觸碰到10W QPS的場景.
但是在極少數(shù)情況下, 例如鑒權(quán)/用戶信息等等接口, 可能出現(xiàn)一些超高請求量的情況, 我個人是比較建議使用 緩存Cluster 來解決這個問題, 如果經(jīng)濟實力允許的話, 能夠最高支撐到 200 * 10W, 但是那個時候基礎(chǔ)設(shè)施的建設(shè)已經(jīng)超出我的能力理解范圍外了.
本章節(jié)指代的多級緩存是由 LocalCache -> Redis Cache -> DB 的三級, <span style="font-weight: bold;" class="bold">高性能的集中緩存, 有一層就夠了.</span>
什么場景需要?
能夠保證同一個Key(用戶/IP)的請求都能落在一個服務(wù)實例上, 比如配置的IP 負載均衡, 或者是使用長連接的情況下, 如果一個Key是隨機的落在任意實例上的, 那么此時的本地緩存是沒有什么效果的.
-
超他媽的高的流量, 我的Redis頂不住了
OK, 你可以試一試, 但是我沒有遇到過, 所以你就聽一聽就好了.
總之, 保證你的請求的內(nèi)聚性.
主要問題
引入了本地緩存之后, 主要的問題就是一致性問題, 即本地緩存 -> 集中緩存 -> DB的一致性.
但是細細想來, 其實也沒有那么復(fù)雜.
解決方案
集中緩存 -> DB的一致性
這里的解決方案和章節(jié)2所示的一模一樣, 沒有任何改動
本地緩存的一致性
讀: 與集中緩存的一致性
看到這個標(biāo)題, 你可能會有疑問, 為什么是本地緩存和集中緩存的一致性?
原因很簡單, 因為我們通常認為本地緩存的TTL要遠小于集中緩存, 如果頻繁的請求DB, 則可以直接取消掉集中緩存的存在.
<span style="font-weight: bold;" class="bold">為什么?</span>
因為每個服務(wù)實例都存在一個本地緩存, 如果你設(shè)置了一個長的TTL, 你還需要集中緩存嗎?
如果TTL過期了, 直接請求DB, 你還需要集中緩存嗎?
OK, 總之, 我們確定了一點:
本地緩存的更新應(yīng)該總是按照: 集中緩存 -> DB這樣的邏輯來處理.
寫: 與DB的一致性
看到這個標(biāo)題, 你可能又有疑問, 為什么是本地緩存和DB的一致性? 不應(yīng)該是本地緩存 -> 集中緩存(Redis)的一致性嗎?
因為集中緩存不論如何都是有可能出現(xiàn)不一致的情況的, 這一點在上面的標(biāo)題也提到了.
最準(zhǔn)確的結(jié)果應(yīng)該總是從DB中獲取.
請不要忘記了一件事情, 如果是寫 或 更新的情況下, 我們一定可以知道最新的數(shù)據(jù)情況, 那么此時直接繞過Redis進行更新, 會有什么問題?
答案: 不存在任何問題.
因為你的本地緩存一定是最新的內(nèi)容, 本地緩存和DB的一致性一定要比 集中緩存和DB的一致性要高.
所以, 這種情況下, 你可以為寫/更新 導(dǎo)致的緩存更新設(shè)置一個比較高的TTL.
我的建議是, 設(shè)置為讀更新TTL的兩倍, 具體視你的業(yè)務(wù)場景來處理
如果是一個寫頻率稍高的場景, 可以將TTL設(shè)置的稍小一些. 反之, 就可以設(shè)置的大一點.
總結(jié)
其實看到這兒, 你也明白了, 本地緩存的引入其實會帶來非常多的問題.
維護兩個緩存其實是一件不那么容易的事情, 我的建議是能不用本地緩存, 就不用本地緩存, 99.9%情況下,你不需要本地緩存.
當(dāng)然, 如果基本不寫入, 是一些比較靜態(tài)的數(shù)據(jù), 那么本地緩存還是非常推薦的.
一個真實的場景
背景
- 一個IM即時通信的場景, C/S維持長連接, 一個需求是用戶的每個消息, 需要查看之前的歷史記錄.
- 用戶發(fā)送消息的頻率還是比較高的, 所以可以認為是一個讀多寫多的場景.
- 有其他的服務(wù)需要查看歷史記錄, 但是對一致性要求不高.
設(shè)計
- DB存儲使用MongoDB, 每一個請求都會落MongoDB, 進行持久化處理
- Server端為每個Client維護一個 context, 在context中維護一個List, 用來存放歷史記錄
- 消息持久化之后, 異步的放入Redis List中.
分析
這就是典型的可以使用本地緩存的場景, 為什么?
因為在這中情況下, 可以最高效率的利用本地緩存, 幾乎所有的本地緩存都可以使用到,而且除非斷線重連.
如果不是因為其他的服務(wù)需要一個快速的而且允許不一致的數(shù)據(jù)訪問方式, Redis可以直接去掉的.
總結(jié)
在多級緩存架構(gòu)中,一致性問題成為了一個關(guān)鍵的挑戰(zhàn),尤其是在高并發(fā)場景下。一致性問題主要來源于緩存與數(shù)據(jù)庫狀態(tài)不同步的問題,以及不同級別的緩存之間狀態(tài)不一致的問題。處理這些問題的策略主要圍繞數(shù)據(jù)更新路徑的設(shè)計,以及緩存失效和更新機制。
單級緩存一致性的解決方案
讀操作
- <span style="font-weight: bold;" class="bold">分布式鎖</span>:用于控制并發(fā)下對數(shù)據(jù)庫的訪問,以避免緩存擊穿。但需注意,分布式鎖可能導(dǎo)致大量的無效返回和流量沖擊。
- <span style="font-weight: bold;" class="bold">NO TTL</span>:對于短期的高流量活動,可以采用緩存預(yù)熱加上不設(shè)置TTL的方法,活動結(jié)束后手動刪除緩存。
寫操作
- <span style="font-weight: bold;" class="bold">更緩存 -> 更DB</span>:不推薦,因為一旦更新數(shù)據(jù)庫失敗,緩存中的數(shù)據(jù)將是陳舊的,造成數(shù)據(jù)不一致.
- <span style="font-weight: bold;" class="bold">刪緩存 -> 更DB</span>:可能會導(dǎo)致在刪除緩存和更新數(shù)據(jù)庫之間的窗口期內(nèi),其他請求將老數(shù)據(jù)寫回緩存,造成不一致.
- <span style="font-weight: bold;" class="bold">更DB -> 刪緩存</span>:更為安全的方法,即使刪除緩存失敗,也只會導(dǎo)致數(shù)據(jù)短暫的不一致.
- <span style="font-weight: bold;" class="bold">延遲雙刪</span>:結(jié)合刪緩存 -> 更DB 和 更DB -> 刪緩存 的方法,通過延遲刪除來盡量減少不一致窗口期的大小.
- <span style="font-weight: bold;" class="bold">事件驅(qū)動:</span> 性能強, 侵入低, 但是引入了新的組件, 可能會降低可維護性.
多級緩存一致性問題
在某些極端高流量情況下,可能需要采用多級緩存來分擔(dān)集中緩存的壓力。這時,除了要處理集中緩存與數(shù)據(jù)庫之間的一致性問題外,還要處理本地緩存與集中緩存之間的一致性問題。
集中緩存和DB的一致性
對于集中緩存和數(shù)據(jù)庫的一致性問題,解決方案與單級緩存一致性的解決方案相同。
本地緩存的一致性
- <span style="font-weight: bold;" class="bold">本地緩存與集中緩存</span>:本地緩存的TTL應(yīng)該小于集中緩存的TTL,確保數(shù)據(jù)的更新能夠及時反映到本地緩存中。
- <span style="font-weight: bold;" class="bold">本地緩存與DB</span>:寫操作時,可以直接更新本地緩存與數(shù)據(jù)庫,保證本地緩存的數(shù)據(jù)總是最新的。設(shè)置合理的TTL以確保數(shù)據(jù)一致性。
最終總結(jié)
多級緩存一致性問題的處理是一個復(fù)雜的挑戰(zhàn),特別是在高并發(fā)、讀寫頻繁的背景下。每一種緩存一致性策略都有其適用場景和潛在風(fēng)險。
在設(shè)計緩存更新機制時,需要全面考慮業(yè)務(wù)需求、性能和一致性的平衡。
在大多數(shù)情況下,單級緩存足矣。而在特定高流量場景下,引入本地緩存是必要的,但這需要仔細設(shè)計以確保數(shù)據(jù)一致性,并盡量減少復(fù)雜性。