可以设想一个很常见的场景:商品详情接口,数据库单机能扛两千 QPS,加了 Redis 之后命中率长期在 99% 以上,看起来已经高枕无忧。直到某次 Redis 主从切换,三十秒内所有请求同时回源,连接池打满,接着是超时、重试、再超时,恢复花了十几分钟。事后排查,Redis 配置没问题,代码也没写错,问题在于整条链路上只有一层缓存,而这一层失效时没有任何东西替它挡一下。
所以真正要讨论的不是「要不要加缓存」,而是「缓存分几层、每层负责挡住哪种失效、失效之后下一层还能不能扛住」。需要说明的是,这次没有拿到具体的站内帖子数据,因此本文不引用任何线上个案指标,只讨论机制、取舍和可执行的判断标准;涉及数字的地方都是量级示意,不是实测结论。
一、先给结论
缓存不是一层,而是一组按「失效模式」分工的防御层:本地缓存挡住热点 key 和网络往返,Redis 挡住跨实例共享与数据库压力,布隆过滤器和空值缓存挡住「查不存在的数据」,单飞重建挡住「热点 key 过期瞬间的并发回源」,最后再由限流、熔断、降级兜住最坏情况。少了任何一层,其余层的压力都会被放大。
核心判断:缓存设计的第一性问题不是「怎么存得更快」,而是「这一层挂了,下一层还撑不撑得住」。
这个结论有明确边界。它成立的前提是「读多写少、能接受短时间不一致」。如果是余额扣减、库存判定这类强一致场景,缓存只能用于展示,真正的判断必须回到数据库或原子脚本;如果是写多读少,缓存的收益会被频繁失效吃掉,反而增加复杂度。后面会单独用一节讨论这些不该用缓存的情况。
二、三种失效其实发生在不同层
缓存穿透指的是查询一个根本不存在的数据,请求每次都越过缓存直达数据库。典型来源是恶意刷接口或爬虫遍历 ID。它的特点是缓存永远不命中,因为你压根没存过这条数据。常见做法是参数合法性校验、空值缓存(用一个短 TTL 的 NULL 占位)、以及布隆过滤器在入口先判一次「这个 key 有没有可能存在」。
缓存击穿针对的是单个热点 key。它在过期的那一瞬间失效,此时如果有几千个并发同时到达,就会同时回源,数据库瞬间承受几千次相同的查询。注意它和穿透的区别:数据是存在的,只是重建过程被并发了。解法是让重建串行化,比如分布式锁、进程内的 singleflight,或者干脆逻辑过期——不删除 key,只标记过期时间,由一个线程异步刷新,其余线程继续返回旧值。
缓存雪崩则是大面积同时失效,或者 Redis 整体不可用。前者常见于批量预热时给同一批 key 设了同样的 TTL,后者是基础设施故障。很多人以为给 TTL 加个随机抖动就解决了雪崩,但那只覆盖了「同时过期」这一半;如果 Redis 集群整体不可用,没有本地缓存兜底的系统依然会瞬间打穿数据库。这三种问题的解法并不通用,用 TTL 抖动去防穿透是无效的,用布隆过滤器去防击穿也是无效的。
三、分层方案与取舍
单层 Redis 的问题不只是「挂了怎么办」,还有每次读都要走一次网络往返。在热点集中的场景里,同一个商品被几万次每秒地读,这些请求全部打到 Redis,Redis 的 CPU 和网络带宽会成为新的瓶颈。本地缓存的价值就在这里:把最热的那一小部分数据放进进程内存,读路径缩短到纳秒级,Redis 只承担「未命中时的共享层」职责。
| 方案 | 主要挡住 | 代价 | 适用场景 |
|---|---|---|---|
| 单层 Redis | 常规读放大 | Redis 抖动直接传导到数据库 | 流量中等、可接受短毛刺 |
| Caffeine + Redis | 热点 key、网络往返 | 多实例本地数据存在不一致窗口 | 读多写少、热点集中 |
| 再加布隆 + 空值缓存 | 不存在 key 的反复查询 | 布隆有误判、删除需重建 | 有被刷接口风险 |
| 再加单飞重建 | 热点 key 过期瞬间的并发回源 | 实现复杂、要处理锁超时 | 秒杀、爆款详情 |
引入本地缓存后,最大的代价是一致性窗口。假设有 20 个实例,每个实例的本地缓存各自持有旧值,写操作发生后,需要通知所有实例失效。常见做法是通过 Redis Pub/Sub 或消息队列广播失效消息,但广播可能丢消息、实例可能重启错过通知,所以本地缓存必须有兜底 TTL,通常控制在秒级到分钟级。本地缓存不能作为唯一数据源,它只是「允许短暂陈旧」的加速层,这个定位必须在设计阶段就写清楚。
四、写路径:删除策略与不一致窗口
写路径上最经典的争议是「先更新数据库再删缓存」还是「先删缓存再更新数据库」。前者的问题是删除失败会留下脏数据,后者的问题是在删除之后、更新之前的这段时间里,读请求会把旧值重新回填进缓存。工程上更常用的是先更新数据库,再删除缓存,删除失败则投递到消息队列重试,因为它的失败模式更可控,且不会引入「更新缓存」带来的并发乱序问题。
至于「延迟双删」,它解决的是上面说的回填时序问题:更新完数据库后,先删一次,等一小段时间再删一次。但它有一个绕不过去的缺陷——第二次删除的延迟时间给不出精确值,通常按经验拍 500 毫秒到 1 秒,这个数字既不保证覆盖所有回填,又会让写路径变慢。更稳妥的做法是订阅数据库的 binlog 变更,由独立消费者异步删除缓存,把「删除」从业务代码里解耦出去,业务侧只负责写库。
无论用哪种策略,最终兜底的都是 TTL。设缓存 TTL 为 ,主从复制延迟为 ,那么最坏情况下读到旧值的时间窗口大致是:
这个式子说明:TTL 不是「缓存多久刷新一次」的调优参数,而是不一致窗口的上限承诺。你要先想清楚业务能接受多长的陈旧时间,再去定 TTL,而不是反过来。
五、落地顺序与检查清单
动手之前先量化三件事:当前缓存命中率、数据库还剩多少 QPS 余量、热点 key 的分布是否集中。如果数据库余量还有好几倍,先别急着上多级缓存,把 TTL 抖动和空值缓存补上就够了;如果热点高度集中且数据库余量不足一倍,本地缓存的收益才会明显。顺序错了,复杂度会先于收益到来。
流程图
渲染中...
对应的检查清单可以按这个顺序推进:
- 统计命中率、数据库余量与热点 key 分布,确认瓶颈在哪一层
- 所有缓存 key 的 TTL 加随机抖动,避免批量同时过期
- 补上参数校验与空值缓存,必要时加布隆过滤器
- 热点 key 引入本地缓存,TTL 控制在分钟级以内
- 回源路径加单飞或分布式锁,防止击穿
- 下游依赖配置熔断降级,故障时返回兜底数据而不是打穿数据库
读路径的骨架大致如下,重点是「本地 → Redis → 单飞回源」这个顺序不能颠倒:
Java
public Product detail(long id) {
String key = "product:" + id;
Product hit = local.getIfPresent(key);
if (hit != null) {
return hit;
}
String cached = redis.get(key);
if (cached != null) {
Product p = Json.decode(cached, Product.class);
local.put(key, p);
return p;
}
return singleFlight.run(key, () -> {
Product p = mapper.selectById(id);
if (p == null) {
redis.setex(key, 60, "NULL");
} else {
redis.setex(key, 300 + random.nextInt(120), Json.encode(p));
local.put(key, p);
}
return p;
});
}
这段代码里有两个容易被忽略的细节:空值缓存的 TTL 必须比正常数据短,否则新增数据后会有较长时间查不到;单飞重建要设置超时,否则第一个线程卡住会把后续所有等待线程一起拖垮。
六、什么情况下不该这么做
第一类是写多读少的场景。比如订单状态频繁变更,缓存刚写进去就被更新掉,命中率上不去,反而多了一层需要维护一致性的组件。这类场景更适合直接读库加索引优化,或者用读写分离分摊压力。
第二类是强一致判定的场景。库存扣减、余额变更这类操作,缓存只能用于页面展示,真正的判断必须走数据库行锁或 Lua 原子脚本。把「缓存里有库存」当作可以下单的依据,是很多超卖事故的起点。
第三类是数据量小且可全量加载的场景。如果整张表只有几万行、变更不频繁,直接加载进进程内存并订阅变更消息,比维护一套 Redis 加本地缓存的双层结构简单得多。复杂度本身也是成本,能不加的层就不加。
七、总结与讨论
回顾一下:第一,穿透、击穿、雪崩发生在不同层,解法不能互相替代,判断标准是「这一层失效时下一层还能不能扛住」。第二,本地缓存带来性能,也带来不一致窗口,必须用短 TTL 兜底,且不能作为唯一数据源。第三,写路径优先选「更新库 + 删缓存 + 失败重试」,延迟双删的延迟时间是经验值而非保证,binlog 订阅更可靠,但 TTL 始终是最终的不一致上限。
最后留一个具体的问题给社区:你们线上用了本地缓存的系统,实例间的失效通知是走 Redis Pub/Sub、消息队列广播,还是干脆只靠短 TTL 自然过期?前两者能更快收敛,但会引入消息丢失和重复消费的处理成本;后者实现最简单,代价是把不一致窗口完全交给 TTL 决定。你们实际选的是哪条路,又踩过什么坑?