Redis 快取設計模式:穿透、擊穿、雪崩與生產級實戰
快取不是加了就快
我看過太多這樣的對話:「介面慢?加個 Redis 快取。」然後快取加完,問題沒解決,甚至多了一堆更詭異的 bug:資料改了不生效、半夜快取同時過期把資料庫打掛、有人用一個不存在的 ID 把 DB 拖垮。
快取是加速器,不是魔法。它引入了一致性、失效、雪崩這些新命題。這篇文章把生產環境裡真正會咬人的幾個模式講清楚,附上能直接抄的程式碼。
一、三種讀寫策略
Cache Aside(旁路快取)——最常用
讀:先查快取,命中直接返回;未命中查庫,寫入快取再返回。 寫:先更新資料庫,再刪除快取(不是更新快取)。
// 讀
async function getUser(id: string) {
const cached = await redis.get(`user:${id}`);
if (cached) return JSON.parse(cached);
const user = await db.users.find(id);
if (user) await redis.set(`user:${id}`, JSON.stringify(user), "EX", 300);
return user;
}
// 寫:更新庫 + 刪快取
async function updateUser(id: string, data: Partial<User>) {
await db.users.update(id, data);
await redis.del(`user:${id}`); // 刪,而不是 set
}
為什麼「刪」而不是「更新」快取?因為並發寫時,先更新快取再更新庫可能導致快取是舊值;而「刪快取」讓下一次讀回源,自然拿到最新。代價是短暫的資料空洞,但更安全。
Read/Write Through(通讀/通寫)
應用只和快取打交道,快取負責和資料庫同步。讀寫都經快取層,對呼叫方透明。適合把快取當「主儲存」的場景,但實作複雜度高。
Write Behind(寫回)
寫只落快取,由快取非同步批量刷庫。效能最好,但丟資料風險最大——程序崩了,沒刷碟的寫入就沒了。除非對一致性要求極低,否則慎用。
二、快取穿透:查一個根本不存在的 key
攻擊者或髒資料反覆查 id=-1、id=99999999,快取裡沒有(因為查 DB 也沒),每次都打到資料庫。量大了就是 DoS。
解法一:快取空值
查 DB 沒命中,也在快取裡放一個短 TTL 的空標記。
const user = await db.users.find(id);
if (!user) {
await redis.set(`user:${id}`, "NULL", "EX", 60); // 空值也快取一會兒
return null;
}
注意空值 TTL 要短(比如 60s),避免 DB 裡真插了這筆資料後長時間讀到舊的「不存在」。
解法二:布隆過濾器
把所有合法 ID 放進布隆過濾器,查詢前先問它「這個 ID 可能存在嗎」。不存在就直接返回,根本不碰快取和 DB。
// 偽代碼
if (!bloom.mightContain(id)) return null; // 一定不存在
const cached = await redis.get(`user:${id}`);
// ...
布隆過濾器的代價是有一點誤判率(說「可能存在」其實沒有),但絕不會把存在的判成不存在。對穿透防護足夠。
三、快取擊穿:一個熱點 key 剛好過期
和雪崩不同,擊穿是單個極高熱點 key(比如首頁爆款商品)在失效瞬間,海量請求同時打到 DB。
解法:互斥鎖(singleflight)
第一個請求發現快取沒了,先拿鎖,去查 DB 並回填快取,其他請求等它完成或短暫休眠後讀快取。
async function getHotProduct(id: string) {
const cached = await redis.get(`product:${id}`);
if (cached) return JSON.parse(cached);
const locked = await redis.set(`lock:${id}`, "1", "EX", 5, "NX");
if (!locked) {
await sleep(50); // 沒搶到鎖,稍等重試
return getHotProduct(id);
}
try {
const product = await db.products.find(id);
await redis.set(`product:${id}`, JSON.stringify(product), "EX", 300);
return product;
} finally {
await redis.del(`lock:${id}`); // 釋放鎖
}
}
這也叫 singleflight 思路——同一 key 同一時刻只放一個請求回源。Go 裡 golang.org/x/sync/singleflight 就是幹這個的。
解法二:邏輯過期
不給 key 設物理 TTL,而是在 value 裡放一個 expireAt。讀的時候如果「邏輯過期」,返回舊值的同時非同步觸發刷新。用戶永遠不等待回源,體驗最好,但會短暫讀到舊資料。
四、快取雪崩:大量 key 同一時刻失效
如果所有快取 TTL 都是 300s,且同一時刻寫入,那麼它們會同一時刻集體過期,瞬間所有請求穿透到 DB——資料庫直接被沖垮。
解法一:隨機化 TTL
const ttl = 300 + Math.floor(Math.random() * 60); // 300~360s 抖動
await redis.set(key, value, "EX", ttl);
一行程式碼,規避集體過期。
解法二:多級快取
本地快取(如 LRU Map、Caffeine)+ Redis + DB。Redis 掛了或集體過期,還有本地這一層扛住大部分流量。
function get(key: string) {
const local = localCache.get(key);
if (local) return local; // 第一層
const redisVal = redis.get(key);
if (redisVal) { localCache.set(key, redisVal); return redisVal; } // 第二層
return dbQueryAndFill(key); // 第三層
}
解法三:高可用 + 限流降級
Redis 本身做主從/哨兵/叢集,避免單點。入口加限流(令牌桶)和降級(返回預設/靜態頁),DB 壓力大時保護它。
五、資料一致性:快取和庫對不上
Cache Aside 下有個經典競態:請求 A 讀未命中→查庫→準備寫快取;同時請求 B 更新了庫並刪快取;然後 A 把舊值寫回快取。結果快取是髒的。
緩解辦法:
- 延遲雙刪:更新庫後刪快取,隔幾百毫秒再刪一次,清掉這種「舊值回填」。
- 訂閱 binlog:用 Canal 等訂閱資料庫 binlog,非同步淘汰快取。這樣快取失效和業務解耦,最乾淨。
- 接受「最終一致」:絕大多數業務不需要快取和庫強一致,給快取一個合理 TTL 讓它自動過期即可。
六、記憶體與淘汰策略
Redis 是記憶體資料庫,記憶體有限。配 maxmemory 和 maxmemory-policy:
| 策略 | 行為 |
|---|---|
noeviction |
寫滿就報錯(預設,危險) |
allkeys-lru |
所有 key 裡淘汰最久沒用的 |
allkeys-lfu |
所有 key 裡淘汰最少使用的(4.0+,更準) |
volatile-lru |
只淘汰帶 TTL 的 key |
allkeys-random |
隨機淘汰 |
別用預設 noeviction 上生產。快取場景一般 allkeys-lru 或 allkeys-lfu。如果某些 key 絕不能丟(如熱點配置),給它們不设 TTL 並配合 volatile-* 策略。
七、熱點 key 與 big key
- 熱點 key:單個 key QPS 極高(如爆款)。風險是單節點 CPU 打滿。解法:本地快取 + key 打散(同資料拆成
product:{id}:1~:N多個副本分流)。 - big key:單個 value 過大(如存了整個商品列表的巨型 JSON)。風險是序列化慢、阻塞、網路包大。解法:拆分、壓縮、或只快取必要欄位。用
redis-cli --bigkeys定期巡檢。
八、Pipeline、批量與 Lua 原子操作
多次命令往返有網路開銷。批量用 Pipeline:
const pipeline = redis.pipeline();
for (const id of ids) pipeline.get(`user:${id}`);
const results = await pipeline.exec();
需要「讀-改-寫」原子性(比如扣庫存)用 Lua 腳本,避免並發競態:
-- 扣庫存:庫存>0 才減
local stock = tonumber(redis.call('GET', KEYS[1]))
if stock > 0 then
redis.call('DECR', KEYS[1])
return 1
end
return 0
九、實戰:商品詳情頁快取架構
一個能扛高併發的商品詳情頁,通常這樣疊:
- CDN / 靜態層:頁面骨架、圖片。
- 本地快取:熱門商品,毫秒級、零網路。
- Redis:商品主資料(JSON 或 Hash),TTL 帶隨機抖動;庫存用單獨的 string + Lua 扣減。
- 資料庫:回源。
- 防穿透:商品 ID 進布隆過濾器。
- 防擊穿:熱點商品用互斥鎖或邏輯過期。
- 防雪崩:隨機 TTL + 本地快取兜底 + 限流。
偽代碼骨架:
async function getProductDetail(id: string) {
if (!bloom.mightContain(id)) return null; // 防穿透
const local = localCache.get(id);
if (local) return local; // 第一層
const redisVal = await redis.get(`product:${id}`);
if (redisVal) {
localCache.set(id, redisVal);
return JSON.parse(redisVal);
}
// 快取未命中 → 互斥鎖回源(見第三節)
const product = await loadWithLock(id);
return product;
}
常見問題
Q1:Cache Aside 為什麼是「刪快取」而不是「更新快取」?
並發寫時,先寫快取再寫庫有極小機率讓快取殘留舊值;刪快取讓下次讀自然回源拿最新,雖然短暫空洞但更安全。若對一致性要求極高,再疊加延遲雙刪或 binlog 訂閱。
Q2:空值快取不會把 DB 壓垮嗎?
空值只在「確實查過 DB 且不存在」時才寫,且 TTL 很短。它防的是「反覆查同一個不存在的 ID」。攻擊者若用大量不同的非法 ID,仍需布隆過濾器兜底。
Q3:TTL 設多長合適?
看資料變更頻率和業務容忍度。商品詳情 5~10 分鐘,配置類可更長,秒殺庫存要極短或即時。核心原則:TTL 必須帶隨機抖動。
Q4:Redis 宕機了怎麼辦?
多級快取裡本地層能扛一部分;同時限流降級,DB 加連線池保護。Redis 自身用哨兵/叢集保證高可用。不要把「快取未命中」當成「必須打 DB」——可以返回稍舊的本地副本或預設頁。
Q5:快取和資料庫雙寫,怎麼保證不丟?
快取不是持久層,不要指望它不丟。關鍵寫必須落庫;快取只加速讀。用 binlog 訂閱做非同步失效,比在業務代碼裡同步雙寫更可靠。
工具推薦
和快取裡的 key、JSON 打交道時,這些 ToolsKu 工具順手:
- 雜湊摘要 — 給快取 key 生成穩定指紋,或做布隆過濾器前的雜湊
- JSON 最小化 — 把塞進 Redis 的 JSON 壓掉空白,省記憶體
- JSON 格式化 — 排查快取裡存的 JSON 到底對不對
快取設計的核心,是承認「快取和資料庫終究會不一致」,然後選一種你能接受的不一致視窗。與其追求強一致,不如把失效、抖動、降級想清楚——這才是生產級快取。
本站提供瀏覽器本地工具,免註冊即可試用 →