4. 缓存失效策略设计
缓存失效策略的核心问题只有一个:当数据库中的数据发生变化时,如何让缓存中的旧数据尽快、可靠地退出服务,同时不让数据库被瞬间打垮。围绕这个目标,工程上形成了从被动到主动、从简单到复杂的多种手段。本节按“单键失效 → 批量失效 → 失效风暴 → 缓存雪崩”的递进关系展开,并给出可落地的参数与代码示例。
一、TTL:最基础的被动失效
TTL(Time To Live)是给缓存键设置过期时间,到期后 Redis 自动删除该键,下次读取时回源数据库并重建缓存。它的优点是实现简单、无需业务代码感知数据变更;缺点是过期前缓存与数据库可能不一致,一致性窗口最长等于 TTL。
TTL 的取值需要在“一致性”和“数据库压力”之间权衡。TTL 越短,不一致窗口越小,但回源频率越高。工程上常用经验值:热点商品详情 30~120 秒,用户会话 30 分钟,配置类数据 5~10 分钟。对于强一致要求的数据,TTL 只能作为兜底,不能作为主要手段。
设置 TTL 时要注意两点:一是避免大量键在同一时刻过期,否则会形成“同一秒集体回源”;二是过期时间应加随机抖动,例如基础 TTL 加 0~300 秒随机值,把回源压力摊平到时间轴上。
// 设置带抖动的 TTL,基础 600 秒,抖动 0~300 秒
int baseTtl = 600;
int jitter = ThreadLocalRandom.current().nextInt(300);
redisTemplate.opsForValue().set(key, value, baseTtl + jitter, TimeUnit.SECONDS);
二、主动失效:数据变更时立即删除缓存
主动失效是在写数据库成功后,由业务代码显式删除(或更新)缓存键,使下一次读请求回源。相比 TTL,它把不一致窗口从“最长 TTL”压缩到“删除操作完成”这一瞬间,是大多数业务的首选。
主动失效有两种写法:删除缓存和更新缓存。更新缓存看似更“及时”,但存在并发写覆盖问题:两个写请求 A、B 先后更新数据库,若 B 的缓存更新先于 A 到达,缓存中会残留 A 的旧值。因此业界更推荐“删除缓存”,让读请求按需重建。
删除时机也有讲究。常见做法是先更新数据库,再删除缓存(Cache-Aside)。如果反过来先删缓存再更新数据库,在“删缓存成功、更新数据库前”这段窗口内,读请求会把旧值重新加载进缓存,导致长期不一致。先更新库再删缓存虽然仍有极小概率的不一致(读请求恰好在删除前加载了旧值),但概率低得多,且可通过延迟双删进一步收敛。
三、延迟双删:收敛并发窗口
延迟双删针对的是“先更新库再删缓存”仍可能残留旧值的场景。其做法是:更新数据库 → 删除缓存 → 延迟一段时间后再次删除缓存。第二次删除用于清掉在第一次删除之后、由并发读请求重新加载进来的旧值。
延迟时间需要覆盖“读请求从数据库取旧值并写回缓存”的最长耗时。工程上常取 300~1000 毫秒,具体取决于数据库查询与网络往返时间。延迟双删的代价是引入一次异步延迟删除,通常用延时队列或定时任务实现。
// 延迟双删示例(伪代码)
public void updateProduct(Product p) {
productDao.update(p); // 1. 更新数据库
redis.delete("product:" + p.getId()); // 2. 第一次删除
delayQueue.offer("product:" + p.getId(), 500); // 3. 500ms 后第二次删除
}
延迟双删不是万能的:延迟期间若仍有写请求,仍可能产生新的不一致。因此它更适合“读多写少、对一致性要求较高”的场景,且应配合版本号或消息队列做最终校验。
四、失效风暴与缓存雪崩
当大量缓存键在同一时间失效,所有请求同时回源数据库,数据库连接池被瞬间占满,响应变慢甚至宕机,这就是缓存雪崩。若雪崩由单个热点键失效引发,大量请求同时重建同一个键,则称为失效风暴(也叫热点 Key 击穿)。
两者的区别在于范围:雪崩是“面”,大量键同时过期;风暴是“点”,单个热点键被高并发重建。应对手段也不同:雪崩靠“打散过期时间 + 多级缓存 + 限流降级”,风暴靠“互斥重建 + 逻辑过期 + 热点永不过期”。
互斥重建是解决失效风暴最直接的办法:只允许一个请求去数据库加载数据并写回缓存,其余请求短暂等待后重试读缓存。实现上可用 Redis 的 SETNX 作为分布式锁。
// 互斥重建:只让一个线程回源
String lockKey = "lock:product:" + id;
if (redis.setIfAbsent(lockKey, "1", 10, TimeUnit.SECONDS)) {
try {
Product p = productDao.findById(id);
redis.set("product:" + id, p, 600, TimeUnit.SECONDS);
} finally {
redis.delete(lockKey);
}
} else {
Thread.sleep(50);
return redis.get("product:" + id); // 重试读缓存
}
逻辑过期则是不给键设置真实 TTL,而是在值中附带一个逻辑过期时间。读请求发现逻辑过期后,异步触发重建,同时先返回旧值。这样请求永远不会因等待重建而阻塞,适合对可用性要求极高的热点数据。
五、工程应对清单
| 问题 | 触发条件 | 应对手段 | 关键参数示例 |
|---|---|---|---|
| 单键不一致 | 数据变更未通知缓存 | 主动删除 + TTL 兜底 | TTL 600s + 抖动 300s |
| 并发残留旧值 | 删缓存后读请求回填旧值 | 延迟双删 | 延迟 500ms |
| 失效风暴 | 热点键高并发重建 | 互斥重建 / 逻辑过期 | 锁 10s,重试 50ms |
| 缓存雪崩 | 大量键同时过期 | TTL 抖动 + 多级缓存 + 限流 | 抖动 0~300s,本地缓存 5s |
最后需要强调:任何失效策略都无法做到绝对强一致,工程目标是把不一致窗口控制在业务可接受范围内。选择策略时先问三个问题:数据变更频率多高?能容忍多长的不一致?数据库能承受多大的回源压力?答案决定了 TTL 长短、是否双删、是否加锁。把这些参数写进配置中心,配合监控观察回源 QPS 和缓存命中率,才能让策略真正落地。