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=-1id=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 是記憶體資料庫,記憶體有限。配 maxmemorymaxmemory-policy

策略 行為
noeviction 寫滿就報錯(預設,危險)
allkeys-lru 所有 key 裡淘汰最久沒用的
allkeys-lfu 所有 key 裡淘汰最少使用的(4.0+,更準)
volatile-lru 只淘汰帶 TTL 的 key
allkeys-random 隨機淘汰

別用預設 noeviction 上生產。快取場景一般 allkeys-lruallkeys-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

九、實戰:商品詳情頁快取架構

一個能扛高併發的商品詳情頁,通常這樣疊:

  1. CDN / 靜態層:頁面骨架、圖片。
  2. 本地快取:熱門商品,毫秒級、零網路。
  3. Redis:商品主資料(JSON 或 Hash),TTL 帶隨機抖動;庫存用單獨的 string + Lua 扣減。
  4. 資料庫:回源。
  5. 防穿透:商品 ID 進布隆過濾器。
  6. 防擊穿:熱點商品用互斥鎖或邏輯過期。
  7. 防雪崩:隨機 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 到底對不對

快取設計的核心,是承認「快取和資料庫終究會不一致」,然後選一種你能接受的不一致視窗。與其追求強一致,不如把失效、抖動、降級想清楚——這才是生產級快取。

本站提供瀏覽器本地工具,免註冊即可試用 →

#Redis#缓存#高并发#缓存穿透#缓存雪崩