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 和缓存命中率,才能让策略真正落地。

缓存失效策略:从单键到雪崩的应对路径 写请求 更新数据库 删除缓存 Cache-Aside 主动失效 延迟双删 500ms 后再次删除 读请求 先查缓存 缓存命中? 命中直接返回 未命中 → 回源 重建缓存 失效风暴 热点键并发重建 互斥重建 SETNX 锁 + 重试 缓存雪崩 大量键同时过期 TTL 兜底 + 随机抖动 基础 600s + 抖动 0~300s,摊平回源压力,防止集体过期 不一致窗口 数据库压力