适用版本:Redis 7.2.x
缓存是提升性能的利器,但只要把 Redis 放进核心链路,就绕不开三个高频问题:一致性、雪崩、击穿、穿透。很多线上事故其实并不是 Redis 挂了,而是缓存策略不完整:缓存和数据库不一致、热点 key 同时过期、无效请求不断穿透数据库,最终把主存储拖垮。
这篇文章专注于工程实践,按“问题现象 -> 成因 -> 方案 -> 风险”的方式梳理常见缓存故障与治理思路。
一、先厘清:缓存一致性到底在说什么
只要系统里同时存在两份数据:
- Redis 中一份;
- 数据库中一份;
那么就必然存在一致性问题。区别只在于:
- 是不是允许短时间不一致;
- 不一致的时间窗口有多大;
- 业务能不能感知和接受这种不一致。
1. 三种常见一致性要求
强一致
写入后,所有读请求都必须立刻读到最新值。
这类场景一般不适合依赖普通缓存来保证结果,比如:
- 账户余额;
- 核心支付状态;
- 不可逆转的状态机节点。
最终一致
允许短时间旧数据,只要最终能收敛到一致即可。
这是大多数 Redis 缓存场景的默认模型,例如:
- 商品详情;
- 用户资料;
- 内容页聚合信息;
- 列表页展示数据。
会话一致 / 读己之写
用户自己刚修改完,希望下一次至少自己能看到新数据。
这往往需要在架构层做定向处理,比如:
- 更新后短时间绕过缓存;
- 对写请求返回新值并直接覆盖前端状态;
- 请求路由到同一实例并结合本地短期缓存。
二、最常见的一致性模式:先更新数据库,再删除缓存
这是 Cache-Aside 模式中的写路径,也是工程里最常见的做法。
标准流程
- 更新数据库;
- 删除缓存;
- 后续读请求缓存未命中,再回源数据库构建新缓存。
为什么这样做最常见
因为它比“更新数据库后再更新缓存”更简单、更稳妥:
- 删缓存比更新缓存简单;
- 不用关心缓存对象是否是聚合视图;
- 避免缓存更新逻辑与数据库更新逻辑双重耦合。
示例
public void updateUserProfile(UserProfileCmd cmd) {
userDao.update(cmd);
redis.del("user:profile:" + cmd.getUserId());
}
但要注意,这种做法不是“天然完全一致”,它仍然存在并发窗口。
三、典型一致性问题:并发读写导致旧值回写
场景复现
- 线程 A 更新数据库成功;
- 线程 B 读取缓存,发现未命中;
- 线程 B 查询数据库时,恰好读到了更新前旧值;
- 线程 A 删除缓存;
- 线程 B 把旧值写回缓存。
结果:缓存里重新出现旧数据。
这类问题严重吗
多数业务里,它是短时间、低概率但真实存在的问题。是否严重,取决于业务能否接受短时旧值。
常见缓解方案
方案一:延迟双删
流程变成:
- 更新数据库;
- 删除缓存;
- 延迟一小段时间;
- 再删一次缓存。
示意代码:
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. 工程建议
最常见的组合是:
- 参数校验;
- 空值缓存;
- 高风险接口加布隆过滤器;
- 异常流量监控与限流。
七、一个完整的读写治理方案示例
下面给出一个比较常见、能在中大型业务里落地的方案组合。
读路径
- 先查本地缓存(可选);
- 再查 Redis;
- 命中空值标记则直接返回;
- 未命中时尝试互斥重建;
- 重建失败或等待超时则走降级;
- 成功回源后写 Redis,并附加随机 TTL。
写路径
- 更新数据库;
- 删除 Redis;
- 关键场景补充延迟双删;
- 跨服务缓存用 MQ / binlog 通知失效;
- 本地缓存通过消息广播失效。
监控指标
- Redis 命中率;
- 缓存重建次数;
- 空值命中次数;
- 热点 key 访问分布;
- 数据库回源 QPS;
- 限流触发次数;
- 删除缓存失败次数。
八、典型问题排查思路
问题一:用户反馈“刚改的数据怎么没生效”
优先排查:
- 更新数据库后是否删除了缓存;
- 删除的是不是正确的 key;
- 是否存在本地缓存未失效;
- 是否误用了空值缓存;
- 是否走到了读从库导致延迟可见。
问题二:数据库 QPS 突然飙升
优先排查:
- Redis 是否有大量 key 集中过期;
- 某热点 key 是否刚失效;
- 是否出现大量不存在 ID 的请求;
- 是否有发布或运维脚本误删缓存;
- 限流和降级是否生效。
问题三:缓存命中率很高,但数据库仍然压力大
可能原因:
- 少量高频 miss 正在击穿;
- 某类空查询没有缓存;
- 查询链路中有旁路逻辑绕过了缓存;
- 本地缓存或 Redis 的 key 设计不统一,导致复用率差。
九、实践中的取舍原则
缓存治理没有绝对标准答案,关键是按业务取舍。
如果业务更看重性能
可以倾向:
- 更长 TTL;
- 逻辑过期;
- 多级缓存;
- 返回旧值兜底。
如果业务更看重一致性
可以倾向:
- 更短 TTL;
- 更新后强制删除缓存;
- 关键写后短时绕过缓存;
- 关键场景直接查主库。
如果业务更担心故障冲击数据库
应重点建设:
- 随机 TTL;
- 互斥重建;
- 空值缓存;
- 限流、熔断、降级;
- 热点预热与监控告警。
十、小结
缓存一致性与雪崩、击穿、穿透,其实是同一类工程问题的不同侧面:缓存一旦失去“隔离流量”和“屏蔽复杂性”的能力,下游数据库就会立刻承压。
落到实践里,可以记住下面这组对应关系:
- 一致性问题:重点关注写路径、删除缓存、异步失效;
- 雪崩问题:重点关注随机 TTL、预热、限流、降级;
- 击穿问题:重点关注热点 key、互斥锁、逻辑过期;
- 穿透问题:重点关注空值缓存、布隆过滤器、入口校验。
如果要给一个简化版落地建议,我会推荐:
- 默认采用“更新数据库后删除缓存”;
- TTL 一律加随机抖动;
- 空值缓存作为基础能力;
- 热点 key 配互斥重建或逻辑过期;
- 关键链路必须有回源限流与降级;
- 通过监控和日志把问题显性化。
缓存从来不只是“快”,更重要的是:快得稳定、快得可控、出问题时也能兜得住。
📝 版权声明:本文为原创技术博客,转载请注明出处。
如文章中存在错误或不准确之处,欢迎在评论区指正,感谢您的阅读与支持!