文章目錄
- Pre
- 業務場景
- 緩存存儲數據的時機與常見問題解決方案
- 1. 緩存讀取與存儲邏輯
- 2. 高并發下的緩存問題及解決方案
- 3. 緩存預熱(減少冷啟動問題)
- 緩存更新策略(雙寫問題)
- 1. 先更新緩存,再更新數據庫(不推薦)
- 2. 先刪除緩存,再更新數據庫(不推薦)
- 3. 先更新數據庫,再更新緩存(不推薦)
- 4. 先更新數據庫,再刪除緩存(Cache-Aside模式 推薦?)
- 5. 延遲雙刪(先刪緩存→更新DB→再刪緩存)(最佳實踐?)
- 總結:如何選擇緩存更新策略
- 最終建議
- 緩存高可用設計核心要點與監控方案
- 1、緩存高可用設計的5大核心要點
- 2、緩存監控關鍵指標與工具
- 3、總結
Pre
Java避坑案例 - 高并發場景下的分布式緩存策略
Redis - 緩存設計深度解析:穿透、并發、雪崩與熱點策略
深入理解分布式技術 - 先更新數據庫,還是先更新緩存
架構思維:讀緩存 - 減少數據庫讀操作壓力
業務場景
某系統商品詳情頁因疊加推薦、成交記錄、優惠活動等功能,導致每次訪問需執行數十條SQL,平均響應時間從3.61秒惡化至15.53秒。初期考慮本地緩存(如Guava),但測算發現5萬商品數據需750GB內存(30節點×25GB),成本過高。最終采用分布式緩存方案,將數據集中存儲(如Redis),所有服務節點共享同一緩存源,避免冗余存儲,顯著提升訪問速度至毫秒級,同時降低硬件成本。優化后,異步加載非核心數據(如成交記錄)進一步減輕實時查詢壓力。
技術選型完成后,開始考慮緩存的一些具體問題,先從緩存何時存儲數據入手
緩存存儲數據的時機與常見問題解決方案
1. 緩存讀取與存儲邏輯
- 先查緩存:請求到達時,優先從緩存(如Redis)讀取數據。
- 緩存未命中:若數據不存在或過期,則查詢數據庫,并將結果寫入緩存。
- 返回數據:最終返回緩存中的數據給調用方。
2. 高并發下的緩存問題及解決方案
這種邏輯唯一麻煩的地方是,當用戶發來大量的并發請求時,它們會發現緩存中沒有數據,那么所有請求會同時擠在第2)步,此時如果這些請求全部從數據庫讀取數據,就會讓數據庫崩潰。
數據庫的崩潰可以分為3種情況。
-
1)單一數據過期或者不存在,這種情況稱為緩存擊穿。
解決方案:第一個線程如果發現Key不存在,就先給Key加鎖,再從數據庫讀取數據保存到緩存中,最后釋放鎖。如果其他線程正在讀取同一個Key值,那么必須等到鎖釋放后才行。關于鎖的問題前面已經講過,此處不再贅述。
-
2)數據大面積過期或者Redis宕機,這種情況稱為緩存雪崩。
解決方案:設置緩存的過期時間為隨機分布或設置永不過期即可。
-
3)一個惡意請求獲取的Key不在數據庫中,這種情況稱為緩存穿透。
比如正常的商品ID是從100000到1000000(10萬到100萬之間的數值),那么惡意請求就可能會故意請求2000000以上的數據。這種情況如果不做處理,惡意請求每次進來時,肯定會發現緩存中沒有值,那么每次都會查詢數據庫,雖然最終也沒在數據庫中找到商品,但是無疑給數據庫增加了負擔。這里給出兩種解決辦法。
①在業務邏輯中直接校驗,在數據庫不被訪問的前提下過濾掉不存在的Key。
②針對惡意請求的Key存放一個空值在緩存中,防止惡意請求騷擾數據庫。
故,總結如下:
-
緩存擊穿(單一Key失效)
- 問題:熱點Key失效時,大量請求同時穿透到數據庫。
- 解決:加鎖(如Redis分布式鎖),僅允許一個線程查詢DB并回填緩存,其余線程等待。
-
緩存雪崩(大量Key同時失效或Redis宕機)
- 問題:緩存大面積失效,導致數據庫被壓垮。
- 解決:
- 設置隨機過期時間(如30分鐘±5分鐘)。
- 緩存永不過期,后臺異步更新(如定時任務)。
-
緩存穿透(惡意查詢不存在的數據)
- 問題:惡意請求查詢不存在的Key,導致頻繁訪問DB。
- 解決:
- 業務層校驗:如商品ID范圍檢查(100000~1000000)。
- 緩存空值:對不存在的Key存儲
null
或占位符,并設置較短過期時間。
3. 緩存預熱(減少冷啟動問題)
上面這些邏輯都是在確保查詢數據的請求已經過來后如何適當地處理,如果緩存數據找不到,再去數據庫查詢,最終是要占用服務器額外資源的。那么最理想的就是在用戶請求過來之前把數據都緩存到Redis中。這就是緩存預熱。
其具體做法就是在深夜無人訪問或訪問量小的時候,將預熱的數據保存到緩存中,這樣流量大的時候,用戶查詢就無須再從數據庫讀取數據了,將大大減小數據讀取壓力。
故,總結如下:
- 時機:在低峰期(如凌晨)提前加載熱點數據到緩存。
- 方式:
- 定時任務掃描DB并寫入緩存。
- 啟動時自動加載核心數據。
緩存更新策略(雙寫問題)
關于緩存何時存數據的問題就討論完了,接下來開始討論更新緩存的問題,這部分內容因涉及雙寫(緩存+數據庫),所以會花費一些篇幅。
- 先更新DB還是緩存?
- Cache-Aside(旁路緩存):先更新DB,再刪除緩存(推薦)。
- Write-Through(寫穿透):先更新緩存,再同步到DB(一致性高,但性能較低)。
- Write-Behind(寫回):先更新緩存,異步刷回DB(高性能,但可能丟數據)。
在緩存更新時,我們需要考慮 數據庫與緩存的一致性,同時避免 并發問題 和 性能瓶頸。以下是 5種常見的緩存更新策略,分析它們的優缺點,并給出推薦方案。
1. 先更新緩存,再更新數據庫(不推薦)
對于這個組合,會遇到這種情況:假設第二步更新數據庫失敗了,要求回滾緩存的更新,這時該怎么辦呢?Redis不支持事務回滾,除非采用手工回滾的方式,先保存原有數據,然后再將緩存更新回原來的數據,這種解決方案有些缺陷。
這里簡單舉個例子。
1)原來緩存中的值是a,兩個線程同時更新庫存。
2)線程A將緩存中的值更新成b,且保存了原來的值a,然后更新數據庫。
3)線程B將緩存中的值更新成c,且保存了原來的值b,然后更新數據庫。
4)線程A更新數據庫時失敗了,它必須回滾,那現在緩存中的值更新成什么呢?理論上應該更新成c,因為數據庫中的值是c,但是,線程A里面無從獲得c這個值。
如果在線程A更新緩存與數據庫的整個過程中,先把緩存及數據庫都鎖上,確保別的線程不能更新,是否可行?當然是可行的。但是其他線程能不能讀取?
假設線程A更新數據庫失敗回滾緩存時,線程C也加入進來,它需要先讀取緩存中的值,這時又返回什么值?
看到這個場景,是不是有點兒熟悉?不錯,這就是典型的事務隔離級別場景。所以就不推薦這個組合,因為此處只是需要使用一下緩存,而這個組合就要考慮事務隔離級別的一些邏輯,成本太大。接著考慮別的組合。
故, 總結如下:
流程:
- 更新緩存
- 更新數據庫
問題:
- 事務回滾困難:如果數據庫更新失敗,緩存無法自動回滾(Redis不支持事務回滾)。
- 并發問題:
- 線程A更新緩存為
b
,線程B更新緩存為c
,最終緩存可能是b
或c
,而數據庫可能是另一個值。 - 需要加鎖,但會降低性能。
- 線程A更新緩存為
結論:? 不推薦,容易導致數據不一致。
2. 先刪除緩存,再更新數據庫(不推薦)
使用這種方案,即使更新數據庫失敗了也不需要回滾緩存。這種做法雖然巧妙規避了失敗回滾的問題,卻引出了兩個更大的問題。
1)假設線程A先刪除緩存,再更新數據庫。在線程A完成更新數據庫之前,后執行的線程B反而超前完成了操作,讀取Key發現沒有數據后,將數據庫中的舊值存放到了緩存中。線程A在線程B都完成后再更新數據庫,這樣就會出現緩存(舊值)與數據庫的值(新值)不一致的問題。
2)為了解決一致性問題,可以讓線程A給Key加鎖,因為寫操作特別耗時,這種處理方法會導致大量的讀請求卡在鎖中。
以上描述的是典型的高可用和一致性難以兩全的問題,如果再加上分區容錯就是CAP(一致性Consistency、可用性Availability、分區容錯性Partition Tolerance)了,這里不展開討論,接下來繼續討論另外3種組合
故, 總結如下:
流程:
- 刪除緩存
- 更新數據庫
問題:
- 緩存與數據庫不一致(舊數據問題):
- 線程A刪除緩存 → 線程B查詢緩存未命中 → 從DB讀取舊數據并寫入緩存 → 線程A更新DB,導致緩存是舊數據。
- 高并發下緩存擊穿:大量請求穿透到數據庫。
解決方案:
- 加鎖(如分布式鎖),但會影響性能。
結論:? 不推薦,容易導致緩存與數據庫不一致。
3. 先更新數據庫,再更新緩存(不推薦)
對于組合3,同樣需要考慮兩個問題。
1)假設第一步(更新數據庫)成功,第二步(更新緩存)失敗了怎么辦?
因為緩存不是主流程,數據庫才是,所以不會因為更新緩存失敗而回滾第一步對數據庫的更新。此時一般采取的做法是重試機制,但重試機制如果存在延時還是會出現數據庫與緩存不一致的情況,不好處理。
2)假設兩個線程同時更新同一個數據,線程A先完成了第一步,線程B先完成了第二步怎么辦?線程A把值更新成a,線程B把值更新成b,此時數據庫中的最新值是b,因為線程A先完成了第一步,后完成第二步,所以緩存中的最新值是a,數據庫與緩存的值還是不一致,這個邏輯還是有問題的。
因此,也不建議采用這個組合
故, 總結如下:
流程:
- 更新數據庫
- 更新緩存
問題:
- 緩存更新失敗:如果第二步失敗,緩存仍是舊數據。
- 并發問題:
- 線程A更新DB為
a
,線程B更新DB為b
→ 線程B先更新緩存為b
,線程A后更新緩存為a
,導致緩存是a
,而DB是b
。
- 線程A更新DB為
結論:? 不推薦,仍可能不一致。
4. 先更新數據庫,再刪除緩存(Cache-Aside模式 推薦?)
針對組合4,先看看它能不能解決組合3的第二個問題。
假設兩個線程同時更新同一個數據,線程A先完成第一步,線程B先完成第二步怎么辦?
線程A把值更新成a,線程B把值更新成b,此時數據庫中的最新值是b,因為線程A先完成了第一步,所以第二步誰先完成已經不重要了,因為都是直接刪除緩存數據。這個問題解決了。
那么,它能解決組合3的第一個問題嗎?假設第一步成功,第二步失敗了怎么辦?
這種情況的出現概率與組合3相比明顯低不少,因為刪除比更新容易多了。雖然這個組合方案不完美,但出現一致性問題的概率較低。
故, 總結如下:
流程:
- 更新數據庫
- 刪除緩存
優點:
- 緩存刪除失敗概率低(刪除比更新更簡單)。
- 并發問題較少:
- 即使線程A更新DB后未及時刪除緩存,線程B讀取舊數據,但下次查詢會重新加載最新數據。
問題:
- 短暫不一致:
- 線程A更新DB后,未刪除緩存前,線程B可能讀到舊數據。
- 緩存擊穿:刪除緩存后,大量請求穿透到DB。
解決方案:
- 延遲雙刪(組合5)可進一步降低不一致概率。
- 緩存預熱減少擊穿影響。
結論:? 推薦,相比前3種方案更可靠。
5. 延遲雙刪(先刪緩存→更新DB→再刪緩存)(最佳實踐?)
除了組合3會碰到的問題,組合4還會碰到別的問題嗎?
是的。假設線程A要更新數據,先完成第一步更新數據庫,在線程A刪除緩存之前,線程B要訪問緩存,那么取得的就是舊數據。這是一個小小的缺陷。
那么,以上問題有辦法解決嗎?
還有一個方案,就是先刪除緩存,再更新數據庫,再刪除緩存。這個方案其實和先更新數據庫,再刪除緩存差不多,因為還是會出現類似的問題:假設線程A要更新數據庫,先刪除了緩存,這一瞬間線程C要讀緩存,先把數據遷移到緩存;然后線程A完成了更新數據庫的操作,這一瞬間線程B也要訪問緩存,此時它訪問到的就是線程C放到緩存里面的舊數據。
不過組合5出現類似問題的概率更低,因為要剛好有3個線程配合才會出現問題(比先更新數據庫,再刪除緩存的方案多了一個需要配合的線程)。
但是相比于組合4,組合5規避了第二步刪除緩存失敗的問題——組合5是先刪除緩存,再更新數據庫,假設它的第三步“再刪除緩存”失敗了,也沒關系,因為緩存已經刪除了。
其實沒有一個組合是完美的,它們都有讀到臟數據(這里指舊數據)的可能性,只不過概率不同。根據以上分析,組合5相對來說是比較好的選擇。
不過這個組合也有一些問題要考慮,具體如下。
- 1)刪除緩存數據后變相出現緩存擊穿,此時該怎么辦?此問題在前面已經給出了方案。
- 2)刪除緩存失敗如何重試?這個重試可以做得復雜一點,也可以做得簡單一點。簡單一點就是使用try…catch…,假設刪除緩存失敗了,在catch里面重試一次即可;復雜一點就是使用一個異步線程不斷重試,甚至用到MQ。不過這里沒有必要大動干戈。而且異步重試的延時大,會帶來更多的讀臟數據的可能性。所以僅僅同步重試一次就可以了。
- 3)不可避免的臟數據問題。雖然這個問題在組合5中出現的概率已經大大降低了,但是還是有的。關于這一點就需要與業務溝通,畢竟這種情況比較少見,可以根據實際業務情況判斷是否需要解決這個瑕疵。
任何一個方案都不是完美的,但如果剩下1%的問題需要花好幾倍的代價去解決,從技術上來講得不償失,這就要求架構師去說服業務方,去平衡技術的成本和收益。
故, 總結如下:
流程:
- 刪除緩存
- 更新數據庫
- 延遲幾百毫秒后,再次刪除緩存
優點:
- 減少不一致窗口:第二次刪除能清理可能的臟數據。
- 避免緩存更新失敗問題:即使第二次刪除失敗,緩存已被第一次刪除,不會長期存儲舊數據。
問題:
- 短暫不一致仍存在(但概率更低)。
- 實現稍復雜:需要引入延遲任務(如MQ、定時任務)。
適用場景:
- 對一致性要求較高的電商、金融等業務。
結論:? 最佳實踐,比方案4更可靠。 延遲雙刪通過兩次刪除操作建立安全窗口,在工程實踐上實現了性能與一致性的最佳平衡,是分布式系統緩存更新的首選方案 .
總結:如何選擇緩存更新策略
方案 | 描述 | 一致性 | 推薦度 |
---|---|---|---|
1?? 先更新緩存,再更新DB | 易回滾問題 | ? 差 | ? 不推薦 |
2?? 先刪緩存,再更新DB | 舊數據問題 | ? 差 | ? 不推薦 |
3?? 先更新DB,再更新緩存 | 并發問題 | ?? 一般 | ? 不推薦 |
4?? 先更新DB,再刪緩存 | 較可靠 | ? 較好 | ? 推薦 |
5?? 延遲雙刪 | 最可靠 | ? 最佳 | ??? 最佳 |
最終建議
- 普通業務:使用 方案4(先更新DB,再刪緩存),簡單可靠。
- 高一致性業務(如支付、庫存):使用 方案5(延遲雙刪),減少不一致窗口。
- 補充優化:
- 緩存預熱減少擊穿影響。
- 異步重試(如MQ)處理刪除失敗情況。
- 加鎖(如Redis分布式鎖)防止并發問題。
沒有完美方案,但 方案4和5 在大多數場景下能平衡 性能與一致性。
緩存高可用設計核心要點與監控方案
1、緩存高可用設計的5大核心要點
-
負載均衡(讀擴展)
- 目標:通過多節點分攤讀請求壓力。
- 方案:
- 使用代理層(如Twemproxy、Redis Cluster)自動分配請求。
- 客戶端分片(如一致性哈希)直接路由請求。
-
數據分片(寫擴展)
- 目標:分散數據存儲與寫壓力。
- 方案:
- Redis Cluster:自動分片(16384個槽),支持動態擴縮容。
- Codis:Proxy層分片,兼容原生Redis協議。
-
數據冗余(容災)
- 目標:單節點故障時數據不丟失。
- 方案:
- 主從復制(Replication):主節點寫,從節點讀+備份。
- 多副本存儲:如Redis Cluster的每個分片包含主從節點。
-
故障自動轉移(Failover)
- 目標:節點宕機時自動切換,保障服務可用。
- 方案:
- 哨兵模式(Sentinel):監控主節點,自動選舉新主。
- Redis Cluster內置Failover:主節點宕機時,從節點自動升級。
-
一致性保證
- 目標:數據分片、故障恢復時避免臟數據。
- 挑戰:Redis默認異步復制,可能丟失少量數據。
- 解決方案:
- WAIT命令:同步復制(犧牲性能)。
- 業務層補償:如定時校對DB與緩存。
推薦架構:
- 中小規模:Redis Sentinel + 主從復制。
- 大規模:Redis Cluster(內置分片、Failover)。
2、緩存監控關鍵指標與工具
-
核心監控指標
- 命中率:
keyspace_hits / (keyspace_hits + keyspace_misses)
,低于80%需優化緩存策略。 - 內存使用:
used_memory
,避免超過最大內存(maxmemory
)觸發淘汰。 - 慢查詢:
slowlog
分析耗時命令(如KEYS *
、大Value操作)。 - 延遲:
redis-cli --latency
,超過1ms需排查網絡或阻塞命令。 - 連接數:
connected_clients
,防止連接泄漏。
- 命中率:
-
開源監控工具
- RedisLive:實時儀表盤展示關鍵指標。
- Prometheus + Grafana:自定義報警與可視化。
- Redis-exporter:為Prometheus提供Redis指標。
-
自研監控建議
- 定時巡檢腳本:檢查
INFO
命令輸出的關鍵指標。 - 自動化報警:如內存使用率超90%、命中率低于70%時觸發告警。
- 定時巡檢腳本:檢查
3、總結
- 高可用核心:分片擴展寫、冗余保障讀、自動Failover。
- 監控關鍵點:命中率、內存、慢查詢、延遲。
- 工具選型:
- 快速上手:RedisLive。
- 長期運維:Prometheus+Grafana。
最終建議:根據業務規模選擇Redis Sentinel或Cluster,并配套監控告警體系,確保緩存穩定支撐業務高峰。