后端开发

Redis 缓存设计:穿透、击穿、雪崩与一致性

2025-04-25 wangjun 15 min read
{}

缓存为何是"双刃剑"

Redis 把热点数据放在内存,能扛住几十倍于数据库的读流量。但缓存层一旦设计不当,会引入三个经典问题:穿透、击穿、雪崩,每个都能把数据库打挂。

缓存穿透:查询不存在的数据

恶意请求大量查询不存在的 key,缓存永远不命中,压力全打到数据库。两种解法:

// 方案一:空值缓存(简单直接)
Object v = redis.get(key);
if (v == null) {
  Object db = queryDb(key);
  redis.set(key, db == null ? EMPTY_MARK : db, 60s);  // 空值也缓存
}


if (!bloom.mightContain(key)) {
  return "不存在";   // 过滤掉绝大多数无效 key
}

布隆过滤器空间小、查询 O(1),但不能删除(误判率会累积),适合 key 集合稳定的场景。

缓存击穿:热点 key 过期

一个超热点 key 刚好过期,瞬间海量请求同时打到数据库。解法是"单飞去查":

// 互斥锁:只放行一个请求去查库
String lockKey = "lock:" + key;
String token = UUID.randomUUID().toString();
if (redis.setIfAbsent(lockKey, token, 2s)) {   // SET NX EX
  try {
    Object db = queryDb(key);
    redis.set(key, db, 30min);
    return db;
  } finally {
    if (token.equals(redis.get(lockKey))) redis.del(lockKey);
  }
} else {
  Thread.sleep(50); // 等锁期间可重试,或先返回旧值
}




缓存雪崩:大量 key 同时过期

很多 key 同一时刻过期(或 Redis 宕机),请求瞬间全部穿透。预防:

// 1. 过期时间加随机抖动,避免整齐划一
redis.set(key, value, baseTtl + random(0, 600));






缓存与数据库一致性

读多写少是缓存的前提。写入侧最常用的 Cache Aside:更新数据库后删缓存(而不是更新缓存),下次读取再回填:

// 更新流程:先写库,再删缓存
updateDb(user);
redis.del("user:" + id);


updateDb(user);
redis.del(key);
sleep(500);        // 等待可能的并发读回填
redis.del(key);    // 再删一次,兜底

为什么"删缓存"优于"更新缓存"?因为删除是幂等的、且避免频繁写入缓存(写多读少的场景缓存会白写)。强一致场景应引入消息队列串行化,弱一致(最终一致)对绝大多数业务足够。

监控是缓存的生命线

必看指标:
- 命中率(>90% 健康,<80% 需排查)
- 穿透率 / 击穿次数(突发尖刺往往就是事故前兆)
- 大 key 与热 key 分布(大 key 拖垮单实例)
- 内存淘汰数(maxmemory 策略是否在频繁逐出)
缓存设计不是"加一层 Redis",而是把流量、一致性与可用性放在一起通盘思考 -- 先想清楚"挂了怎么办",再谈优化。