返回首页

Redis 工程实践6.3:缓存一致性与雪崩、击穿、穿透

适用版本:Redis 7.2.x

缓存是提升性能的利器,但只要把 Redis 放进核心链路,就绕不开三个高频问题:一致性、雪崩、击穿、穿透。很多线上事故其实并不是 Redis 挂了,而是缓存策略不完整:缓存和数据库不一致、热点 key 同时过期、无效请求不断穿透数据库,最终把主存储拖垮。

这篇文章专注于工程实践,按“问题现象 -> 成因 -> 方案 -> 风险”的方式梳理常见缓存故障与治理思路。


一、先厘清:缓存一致性到底在说什么

只要系统里同时存在两份数据:

  • Redis 中一份;
  • 数据库中一份;

那么就必然存在一致性问题。区别只在于:

  • 是不是允许短时间不一致;
  • 不一致的时间窗口有多大;
  • 业务能不能感知和接受这种不一致。

1. 三种常见一致性要求

强一致

写入后,所有读请求都必须立刻读到最新值。

这类场景一般不适合依赖普通缓存来保证结果,比如:

  • 账户余额;
  • 核心支付状态;
  • 不可逆转的状态机节点。

最终一致

允许短时间旧数据,只要最终能收敛到一致即可。

这是大多数 Redis 缓存场景的默认模型,例如:

  • 商品详情;
  • 用户资料;
  • 内容页聚合信息;
  • 列表页展示数据。

会话一致 / 读己之写

用户自己刚修改完,希望下一次至少自己能看到新数据。

这往往需要在架构层做定向处理,比如:

  • 更新后短时间绕过缓存;
  • 对写请求返回新值并直接覆盖前端状态;
  • 请求路由到同一实例并结合本地短期缓存。

二、最常见的一致性模式:先更新数据库,再删除缓存

这是 Cache-Aside 模式中的写路径,也是工程里最常见的做法。

标准流程

  1. 更新数据库;
  2. 删除缓存;
  3. 后续读请求缓存未命中,再回源数据库构建新缓存。

为什么这样做最常见

因为它比“更新数据库后再更新缓存”更简单、更稳妥:

  • 删缓存比更新缓存简单;
  • 不用关心缓存对象是否是聚合视图;
  • 避免缓存更新逻辑与数据库更新逻辑双重耦合。

示例

public void updateUserProfile(UserProfileCmd cmd) {
    userDao.update(cmd);
    redis.del("user:profile:" + cmd.getUserId());
}

但要注意,这种做法不是“天然完全一致”,它仍然存在并发窗口。


三、典型一致性问题:并发读写导致旧值回写

场景复现

  1. 线程 A 更新数据库成功;
  2. 线程 B 读取缓存,发现未命中;
  3. 线程 B 查询数据库时,恰好读到了更新前旧值;
  4. 线程 A 删除缓存;
  5. 线程 B 把旧值写回缓存。

结果:缓存里重新出现旧数据。

这类问题严重吗

多数业务里,它是短时间、低概率但真实存在的问题。是否严重,取决于业务能否接受短时旧值。

常见缓解方案

方案一:延迟双删

流程变成:

  1. 更新数据库;
  2. 删除缓存;
  3. 延迟一小段时间;
  4. 再删一次缓存。

示意代码:

public void updateUserProfile(UserProfileCmd cmd) {
    userDao.update(cmd);
    String key = "user:profile:" + cmd.getUserId();
    redis.del(key);
    asyncExecutor.submit(() -> {
        sleep(500);
        redis.del(key);
    });
}

**优点:**实现简单。

风险:

  • 延迟时间不好拍;
  • 不是严格保证;
  • 第二次删除任务可能丢失或失败。

方案二:基于消息队列 / binlog 异步失效

数据库变更后,通过消息通知缓存失效,或消费 binlog 做统一失效。

优点:

  • 更适合复杂系统;
  • 适合跨服务缓存统一治理;
  • 易于补偿和重试。

风险:

  • 链路更复杂;
  • 消息延迟本身也会带来一致性窗口;
  • 需要幂等消费与失败补偿。

四、缓存雪崩:为什么一批 key 过期会拖垮整个系统

1. 什么是缓存雪崩

缓存雪崩是指:大量缓存 key 在同一时间集中失效,导致海量请求同时回源数据库或下游服务

这类问题的可怕之处不在于 Redis 本身,而在于“Redis 失去缓冲作用后,流量被完整打回主存储”。

2. 常见触发方式

  • 系统初始化时批量写入缓存,TTL 完全一致;
  • Redis 节点故障,整批数据不可用;
  • 发布时误删某类前缀 key;
  • 某大促活动数据统一设置同一过期时间。

3. 典型现象

  • Redis 命中率突然下降;
  • 数据库 QPS 飙升;
  • 下游连接池耗尽;
  • 应用线程堆积;
  • RT、超时率、错误率同时上升。

4. 应对方案

方案一:过期时间加随机值

最基础、也最有效的一步。

int ttl = 1800 + ThreadLocalRandom.current().nextInt(300);
redis.setex(key, ttl, value);

这样做能显著降低同一时刻集中过期的概率。

方案二:热点数据提前预热

例如:

  • 首页配置;
  • 热门商品;
  • 大促活动页核心数据;
  • 排行榜。

在流量高峰到来前,提前把缓存准备好。

方案三:缓存失效时限流与降级

即使雪崩发生,也不要把所有请求都放回数据库:

  • 接口层做令牌桶 / 漏桶限流;
  • 服务层做熔断、快速失败;
  • 返回兜底值、旧值、静态页或降级提示。

方案四:多级缓存

使用本地缓存 + Redis 两级方案,在 Redis 出现短暂波动时,本地缓存还能挡住一部分流量。

5. 风险提示

  • 只加随机 TTL 不能解决 Redis 整体宕机;
  • 预热数据如果范围过大,会增加无效内存占用;
  • 降级方案必须提前演练,不然线上切不过去。

五、缓存击穿:为什么单个热点 key 过期也会出事故

1. 什么是缓存击穿

缓存击穿指的是:某一个高并发热点 key 在失效瞬间,大量请求同时穿过缓存,集中访问数据库。

它和雪崩的区别是:

  • 雪崩:很多 key 一起失效;
  • 击穿:某个特别热的 key 失效。

2. 典型场景

  • 热门商品详情;
  • 热门直播间;
  • 首页 banner 配置;
  • 单个爆款内容页。

3. 常见治理方案

方案一:互斥锁重建缓存

思路是:

  • 缓存失效后,只允许一个线程回源数据库并重建缓存;
  • 其他线程短暂等待、快速失败或返回旧值。

示例伪代码:

String val = redis.get(cacheKey);
if (val != null) {
    return val;
}

boolean locked = tryLock(lockKey);
if (locked) {
    try {
        String again = redis.get(cacheKey);
        if (again != null) {
            return again;
        }
        Data data = dao.query(id);
        redis.setex(cacheKey, 1800, toJson(data));
        return toJson(data);
    } finally {
        unlock(lockKey);
    }
}

Thread.sleep(50);
return redis.get(cacheKey);

**优点:**简单直接。

风险:

  • 竞争过高时等待线程会堆积;
  • 锁设计不当又会引入死锁、误删锁等问题。

方案二:逻辑过期

缓存 value 中保存逻辑过期时间:

  • 未过期:直接返回;
  • 已过期:返回旧值,同时异步刷新;
  • 刷新期间不阻塞大部分请求。

优点:

  • 读请求不容易被阻塞;
  • 对热点 key 很友好。

风险:

  • 业务必须接受短时旧数据;
  • 异步刷新失败时旧值可能保留较久。

方案三:永不过期 + 主动更新

对极少数超热点、更新频率低的数据,可以不设 TTL,而是通过:

  • 后台定时刷新;
  • 数据变更时主动删除或覆盖;
  • 运维任务巡检。

风险:

  • 主动失效链路一旦漏掉,脏数据会长期存在;
  • 运维治理要求更高。

六、缓存穿透:为什么不存在的数据也能把数据库打挂

1. 什么是缓存穿透

缓存穿透指的是:请求查询的数据本身不存在,因此缓存和数据库都查不到,导致每次请求都直接打到数据库。

2. 常见触发方式

  • 恶意攻击,构造大量不存在的 ID;
  • 参数校验缺失,请求随便传主键;
  • 数据已被删除,但缓存失效策略不完整;
  • 热门接口暴露了可枚举 ID。

3. 典型现象

  • Redis 命中率正常,但数据库某类空查询暴增;
  • 慢 SQL 中大量 where id = ? 返回空结果;
  • 某接口流量不高,但数据库查询次数异常高。

4. 常见治理方案

方案一:空值缓存

即使数据库查不到,也在 Redis 中缓存一个空标记,并设置较短 TTL。

if (data == null) {
    redis.setex(key, 60, "NULL");
    return null;
}

优点:

  • 实现成本低;
  • 对随机穿透很有效。

风险:

  • 如果后续这条数据被新建,短时间内仍可能读到空值;
  • 空值 key 太多会占用内存。

方案二:布隆过滤器

在真正查 Redis / DB 前,先判断请求 ID 是否可能存在。

  • 不存在:直接返回;
  • 可能存在:再走后续读取流程。

优点:

  • 适合大规模主键校验;
  • 可显著减少无效请求穿透。

风险:

  • 布隆过滤器有误判率,只能说“可能存在”;
  • 数据新增时要同步更新过滤器;
  • 删除处理需要单独设计。

方案三:接口参数校验与访问控制

很多穿透问题,根源其实是入口没拦住。

例如:

  • 非法 ID 直接拒绝;
  • 用户权限不足直接拦截;
  • 对异常频率请求做黑名单或限流。

5. 工程建议

最常见的组合是:

  • 参数校验;
  • 空值缓存;
  • 高风险接口加布隆过滤器;
  • 异常流量监控与限流。

七、一个完整的读写治理方案示例

下面给出一个比较常见、能在中大型业务里落地的方案组合。

读路径

  1. 先查本地缓存(可选);
  2. 再查 Redis;
  3. 命中空值标记则直接返回;
  4. 未命中时尝试互斥重建;
  5. 重建失败或等待超时则走降级;
  6. 成功回源后写 Redis,并附加随机 TTL。

写路径

  1. 更新数据库;
  2. 删除 Redis;
  3. 关键场景补充延迟双删;
  4. 跨服务缓存用 MQ / binlog 通知失效;
  5. 本地缓存通过消息广播失效。

监控指标

  • Redis 命中率;
  • 缓存重建次数;
  • 空值命中次数;
  • 热点 key 访问分布;
  • 数据库回源 QPS;
  • 限流触发次数;
  • 删除缓存失败次数。

八、典型问题排查思路

问题一:用户反馈“刚改的数据怎么没生效”

优先排查:

  • 更新数据库后是否删除了缓存;
  • 删除的是不是正确的 key;
  • 是否存在本地缓存未失效;
  • 是否误用了空值缓存;
  • 是否走到了读从库导致延迟可见。

问题二:数据库 QPS 突然飙升

优先排查:

  • Redis 是否有大量 key 集中过期;
  • 某热点 key 是否刚失效;
  • 是否出现大量不存在 ID 的请求;
  • 是否有发布或运维脚本误删缓存;
  • 限流和降级是否生效。

问题三:缓存命中率很高,但数据库仍然压力大

可能原因:

  • 少量高频 miss 正在击穿;
  • 某类空查询没有缓存;
  • 查询链路中有旁路逻辑绕过了缓存;
  • 本地缓存或 Redis 的 key 设计不统一,导致复用率差。

九、实践中的取舍原则

缓存治理没有绝对标准答案,关键是按业务取舍。

如果业务更看重性能

可以倾向:

  • 更长 TTL;
  • 逻辑过期;
  • 多级缓存;
  • 返回旧值兜底。

如果业务更看重一致性

可以倾向:

  • 更短 TTL;
  • 更新后强制删除缓存;
  • 关键写后短时绕过缓存;
  • 关键场景直接查主库。

如果业务更担心故障冲击数据库

应重点建设:

  • 随机 TTL;
  • 互斥重建;
  • 空值缓存;
  • 限流、熔断、降级;
  • 热点预热与监控告警。

十、小结

缓存一致性与雪崩、击穿、穿透,其实是同一类工程问题的不同侧面:缓存一旦失去“隔离流量”和“屏蔽复杂性”的能力,下游数据库就会立刻承压。

落到实践里,可以记住下面这组对应关系:

  • 一致性问题:重点关注写路径、删除缓存、异步失效;
  • 雪崩问题:重点关注随机 TTL、预热、限流、降级;
  • 击穿问题:重点关注热点 key、互斥锁、逻辑过期;
  • 穿透问题:重点关注空值缓存、布隆过滤器、入口校验。

如果要给一个简化版落地建议,我会推荐:

  1. 默认采用“更新数据库后删除缓存”;
  2. TTL 一律加随机抖动;
  3. 空值缓存作为基础能力;
  4. 热点 key 配互斥重建或逻辑过期;
  5. 关键链路必须有回源限流与降级;
  6. 通过监控和日志把问题显性化。

缓存从来不只是“快”,更重要的是:快得稳定、快得可控、出问题时也能兜得住。


📝 版权声明:本文为原创技术博客,转载请注明出处。

如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!

上一篇

Redis 工程实践6.2:分布式锁实践

下一篇

Redis 工程实践6.4:生产常见问题排查