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);

  // 用 SET NX 抢锁
  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#缓存#高并发#缓存穿透#缓存雪崩