Redis 缓存实战:从缓存模式到三大经典问题
缓存是后端性能优化里性价比最高的手段之一,但也是线上事故的高发区。缓存穿透、击穿、雪崩这三大经典问题,几乎每个团队都踩过。本文从最常用的缓存读写模式讲起,逐一拆解这三大问题的成因与可落地的解决方案,最后给出一套生产环境的缓存设计清单。
一、两种基础缓存模式
Cache-Aside(旁路缓存)
这是使用最广泛的模式:读请求先查缓存,未命中则查数据库并回填缓存;写请求先更新数据库,再删除缓存。
import Redis from "ioredis";
const redis = new Redis();
async function getUser(id: string) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const user = await db.user.findUnique({ where: { id } });
if (user) {
// 设置过期时间,避免脏数据永久残留
await redis.set(key, JSON.stringify(user), "EX", 300);
}
return user;
}
async function updateUser(id: string, data: Partial<User>) {
// 先更新数据库,再删除缓存(而不是更新缓存)
await db.user.update({ where: { id }, data });
await redis.del(`user:${id}`);
}为什么写操作是「删除缓存」而不是「更新缓存」?两个原因:一是更新缓存在并发写场景下容易出现两个写请求交叉执行,导致缓存里留下旧值;二是如果缓存值需要复杂计算(如联表聚合),写时计算可能白白浪费——删除策略把计算推迟到真正有人读的时候(lazy loading)。
至于「先更新数据库还是先删缓存」的经典争论:先删缓存再更新数据库,在「删缓存成功但数据库更新慢」的窗口期,并发读请求可能读到旧值并回填旧缓存。先更新数据库再删缓存只存在极短的不一致窗口(缓存旧值 vs 数据库新值),且缓存本身有过期时间兜底,是工程上更稳妥的选择。要求更高的场景可以再加「延迟双删」:更新数据库后隔几百毫秒再删一次缓存。
Read/Write-Through(读写穿透)
应用只和缓存层打交道,由缓存层自己负责与数据库同步。读时缓存未命中由缓存层回源数据库;写时同步写入缓存和数据库。这个模式对应用代码更透明,但要求缓存组件支持回源逻辑,在 Redis 生态中通常需要借助 RedisGears 或自研中间层,国内实践中远不如 Cache-Aside 普及,本文后续讨论都以 Cache-Aside 为背景。
二、缓存穿透:查询不存在的数据
成因:恶意攻击或业务缺陷导致大量请求查询数据库中根本不存在的数据(如负数 ID、随机 UUID),缓存永远不会命中,压力全部打到数据库。
方案一:缓存空值
数据库查不到也往缓存里写一个空标记,设置较短的过期时间:
async function getUserSafe(id: string) {
const key = `user:${id}`;
const cached = await redis.get(key);
if (cached !== null) {
// 空值标记,直接返回 null,不再查库
return cached === "NULL" ? null : JSON.parse(cached);
}
const user = await db.user.findUnique({ where: { id } });
if (user) {
await redis.set(key, JSON.stringify(user), "EX", 300);
} else {
// 空值缓存 60 秒,挡住针对不存在 ID 的重复请求
await redis.set(key, "NULL", "EX", 60);
}
return user;
}优点是实现简单;缺点是会占用额外内存,且在「数据后来真的被创建」的场景下存在短暂不一致(可用短 TTL 缓解,或在创建数据时主动删除空值缓存)。
方案二:布隆过滤器
在缓存之前加一层布隆过滤器,把所有合法 Key 的指纹存进去。查询先过布隆过滤器:过滤器说「不存在」就一定不存在,直接返回;说「可能存在」才走缓存和数据库。
// 使用 RedisBloom 模块
async function initBloom() {
const ids = await db.user.findMany({ select: { id: true } });
for (const { id } of ids) {
await redis.call("BF.ADD", "user_bloom", id);
}
}
async function getUserWithBloom(id: string) {
const exists = await redis.call("BF.EXISTS", "user_bloom", id);
if (!exists) return null; // 确定不存在,直接拦截
return getUserSafe(id);
}布隆过滤器空间效率极高(千万级 Key 只需十几 MB),但要注意两点:它有一定的误判率(会把不存在的 Key 误判为存在,但不会漏判);且默认不支持删除,数据删除频繁的场景需要用布谷鸟过滤器或定期重建。
三、缓存击穿:热点 Key 过期瞬间被打爆
成因:某个热点 Key(如秒杀商品、热搜榜单)在过期的一瞬间,成千上万个并发请求同时未命中,全部涌向数据库回源。
方案:互斥锁(Mutex)
只允许一个请求回源数据库并回填缓存,其余请求等待后读缓存:
async function getHotProduct(id: string) {
const key = `product:${id}`;
const cached = await redis.get(key);
if (cached) return JSON.parse(cached);
const lockKey = `lock:${key}`;
// SET NX PX:原子地尝试获取锁,10 秒自动释放防死锁
const locked = await redis.set(lockKey, "1", "PX", 10000, "NX");
if (locked) {
try {
const product = await db.product.findUnique({ where: { id } });
if (product) {
await redis.set(key, JSON.stringify(product), "EX", 600);
}
return product;
} finally {
await redis.del(lockKey);
}
} else {
// 没抢到锁:短暂等待后重试,此时缓存通常已被回填
await new Promise((r) => setTimeout(r, 50));
return getHotProduct(id);
}
}对于读多写极少的超热点数据,还有两种更激进的思路:逻辑过期——缓存不设置物理 TTL,把过期时间存在 value 里,发现逻辑过期后由一个线程异步重建,其余请求先返回旧数据,保证永不回源风暴;永不过期 + 主动更新——后台任务定时刷新热点 Key,彻底绕开过期这个触发点。代价是实现复杂度和数据时效性的牺牲,只适合确认的热点场景。
四、缓存雪崩:大量 Key 同时失效
成因:两种典型情况。一是大量 Key 设置了相同的过期时间,在同一时刻集体失效;二是 Redis 实例宕机,整个缓存层消失。
针对集体过期:TTL 加随机抖动
最简单也最有效的手段——给每个 Key 的过期时间加一个随机偏移,把过期时刻打散:
function randomTtl(base: number, jitter = 60) {
// 基础 300 秒 + 0~60 秒随机抖动
return base + Math.floor(Math.random() * jitter);
}
await redis.set(key, value, "EX", randomTtl(300));如果是定时批量预热缓存的场景(如每天凌晨刷新榜单),还要避免「预热完成后所有 Key 一起开始倒计时」的隐患,预热时就该使用带抖动的 TTL。
针对缓存宕机:高可用与降级
- Redis 高可用:生产环境至少部署主从 + Sentinel,或直接上 Cluster,避免单点。
- 多级缓存:在 Redis 之前加一层进程内缓存(如 LRU Map 或 Caffeine),Redis 挂了还有本地缓存兜底一部分热点读。
- 熔断降级:缓存不可用时,对非核心读请求直接返回默认值或降级的静态数据,保护数据库不被打垮;用限流(如令牌桶)控制打到数据库的回源流量。
五、生产环境缓存设计清单
把上面的经验浓缩成一份上线前的检查清单:
- 必设过期时间:任何 Key 都不允许无 TTL,防止内存被冷数据占满,也给脏数据一个自愈的机会。
- TTL 加抖动:批量写入的 Key 统一加随机偏移,防雪崩。
- 空值缓存或布隆过滤器:对外暴露的查询接口必须考虑恶意构造不存在 ID 的情况。
- 热点 Key 识别与保护:监控缓存命中率与单 Key QPS,对热点 Key 提前上互斥锁或逻辑过期。
- 删除而非更新缓存:写路径遵循「先更库、后删缓存」,必要时延迟双删。
- 降级预案:提前演练「Redis 挂了怎么办」,确认限流、熔断、多级缓存是否生效。
- 监控告警:缓存命中率、内存使用率、慢查询、主从延迟都应有指标和告警。命中率突然下跌往往是击穿或雪崩的前兆。
总结
缓存设计的核心矛盾,是在「性能」与「一致性、可用性」之间做取舍。Cache-Aside 模式配合「先更库后删缓存」是绝大多数业务的正确起点;穿透用空值缓存或布隆过滤器挡住不存在的查询;击穿用互斥锁或逻辑过期保护热点 Key;雪崩用 TTL 抖动防集体失效,用高可用与降级防缓存宕机。没有银弹,关键是理解每种方案牺牲了什么,再结合自己业务的读写比例、数据时效性要求做选择。