在高并发后端系统里,“加一层缓存”几乎是最便宜的提速手段。但缓存一上线,团队往往会遇到一类反复出现的问题:数据库明明已经改了,接口为什么还在返回旧值;为什么做了缓存之后,偶发的脏数据比没做缓存时更难排查。这类问题的根源不在缓存 API 用错了,而在于缓存本质上是一份冗余副本——只要存在副本,就存在副本与主数据之间的时间窗口。
站内关于缓存策略、Caffeine、Redis、缓存穿透/击穿/雪崩的讨论很多,但大多停在“名词解释加一句方案”。本文想把这条链路拆开:一次读请求到底经过几层、脏数据在什么时刻被写回、不同更新策略各自把风险放在哪里、以及本地缓存多实例时怎样才可能被“叫醒”。需要说明信息边界:本次写作没有拿到具体的站内帖子正文,因此下文不引用任何站内统计数据或个别案例,只使用公开组件的通用行为与工程上可复现的推理;文中出现的数值均为量级示意,不是实测结果。
先给结论
一句话结论:绝大多数业务需要的并不是“缓存与数据库强一致”,而是“把不一致窗口收敛到一个可度量、可接受的值”,这靠三件事完成:单一写入口、明确的失效广播、以及短 TTL 兜底。
核心判断:缓存一致性问题的难度,不来自“怎么删缓存”,而来自“有多少个地方在各自维护副本、各自决定什么时候失效”。
这条结论的边界要写清楚:它对“读多写少、能接受秒级延迟”的业务成立;对余额、库存这类必须依赖数据库事务或专用原子结构兜底的场景,缓存只能做加速层,不能做真相层。如果你把一个金额字段塞进本地缓存并期待它“基本一致”,那么后面所有的技巧都只是在延长事故的潜伏期。
问题是怎么形成的:副本数量决定难度
缓存并不是把数据“搬快”,而是把同一份事实复制成多份。每多一份副本,就多一个“何时失效”的决策点,也多一个可能失效失败的环节。单机时代,一份 Caffeine 本地缓存加 MySQL,脏数据窗口由你的失效代码决定,逻辑上还是闭环的;一旦进入多实例部署,本地缓存就变成 N 份互不可见的副本——A 实例更新了数据库并清掉了自己的本地缓存,B 实例 JVM 里那份旧值不会收到任何通知,它只会安静地继续服务,直到自己的 TTL 到期。
更麻烦的是,三类经典故障的机制并不相同,用同一套手段硬套往往无效。下面这张表把触发条件和主要对策摆在一起,方便对照:
| 现象 | 触发机制 | 主要对策 | 常见误用 |
|---|---|---|---|
| 缓存穿透 | 查询不存在的 key,每次都落到数据库 | 空值缓存、布隆过滤器、参数校验 | 只缓存空值却不设 TTL,内存被垃圾 key 占满 |
| 缓存击穿 | 热点 key 过期瞬间大量请求同时重建 | 互斥重建或单飞、逻辑过期、热点不过期 | 用分布式锁重建但超时太短,导致反复重建 |
| 缓存雪崩 | 大批 key 同时过期,或缓存层整体不可用 | TTL 抖动、多级缓存、限流熔断降级 | 只做 TTL 抖动,忽略缓存集群故障时的降级路径 |
“击穿”和“雪崩”表面上都是“请求同时打到数据库”,但击穿的解是减少重建并发(互斥或单飞),雪崩的解是减少同一时刻的失效面(抖动加分层)。把两者混为一谈,就会写出“给所有 key 加随机 TTL 来防击穿”这种既浪费又不解决问题的代码。同样,穿透的解是把不存在的请求挡在数据库之前,本质是过滤而不是并发控制,这与前两者的着力点完全不同。
一次读请求经过谁:三级链路与不一致窗口
典型的多级缓存读链路,可以用一张流程图表示:
流程图
渲染中...
链路一长,失效就不再是“删一个 key”那么简单。假设每个实例的本地缓存 TTL 是 5 分钟,集群有 10 个实例,那么一次写操作之后,如果你只删 Redis 而不广播本地缓存失效,最长不一致窗口就是 5 分钟;即使你发了广播,消息在途延迟、实例重启期间漏消费、消息重复与乱序,都会带来新的不确定性。所以本地缓存的 TTL 其实不是“性能参数”,而是一致性兜底参数:TTL 越短,窗口越小,但穿透到 Redis 的流量越大。这个取舍无法被消灭,只能被显式选择和记录。
对比一下只做分布式缓存的情况:只剩 Redis 一份副本时,一次写只要保证“数据库提交成功后必删 Redis”,不一致窗口通常被压到毫秒级;引入本地缓存后,窗口可能放大几个数量级,但换来的是单机内存级的读取延迟和对 Redis 故障的缓冲能力。换句话说,多级缓存是用一致性换可用性和延迟,它不是纯赚的买卖,只有当“读延迟下降带来的收益”明显大于“不一致窗口带来的业务风险”时才值得上。
更新策略的取舍:先删还是先更,删还是改
Cache Aside 是最常被推荐的模式,但它的两个变体风险并不对称。“先更新数据库、再删除缓存”在正常时序下很少出问题,却存在一个经典交错:读请求恰好在缓存未命中时查到旧值,写请求随后更新数据库并删缓存,读请求再把旧值回写进缓存,于是旧值长期驻留。这个窗口的宽度大致取决于读操作耗时与回写延迟,可以用一个粗略的直觉式关系来理解:
只有当 时才可能出现脏数据。因此缩短读回写耗时(先写缓存再返回、限制缓存对象体积)和让删除晚于回写(延迟双删)都是在压缩这个窗口,而不是“彻底消除”它。理解这一点很重要:不少团队在评审时争论“到底会不会有脏数据”,正确的问法应该是“窗口有多宽、业务能不能忍”。
| 策略 | 好处 | 代价 | 适用场景 |
|---|---|---|---|
| 先更新 DB,再删缓存 | 实现简单,写少读多时命中率高 | 存在极窄的读回写脏窗口 | 大多数业务的默认选择 |
| 延迟双删 | 能覆盖读回写窗口 | 多一次延迟删除,调度需可靠 | 写频率低但一致性要求较高 |
| 删除而非更新缓存 | 幂等、避免并发写乱序 | 下一次读必然回源 | 写多读少、计算成本低 |
| 订阅 binlog 异步失效 | 与业务解耦,多写入方统一 | 引入额外组件与端到端延迟 | 多写入方、缓存分散的系统 |
这里有一个容易被忽略的点:“更新缓存”比“删除缓存”更容易出乱子。两个写请求并发时,A 先算完缓存值但晚写,B 后算完但先写,最终留在缓存里的可能是 A 的旧结果。删除是幂等的——删两次和删一次结果相同——而“更新为某个值”不是。这就是为什么大量系统宁愿承担一次回源,也要选择删除语义;回源的成本是可预算的,脏数据的影响往往不可预算。
广播失效与兜底:本地缓存怎么被叫醒
多实例下最常见的做法是用消息队列(Kafka、RocketMQ 等)或 Redis Pub/Sub 广播失效事件,各实例订阅后清理本地缓存。它的优点是解耦、吞吐高;代价是广播是尽力而为的:实例重启期间漏掉的消息、消费者组再均衡、消息乱序,都会让某个实例长期持有旧值。Redis Pub/Sub 更进一步,它不做持久化,订阅者掉线期间的事件直接丢失,恢复后不会补发。
因此工程上通常叠加三层兜底:
- 给本地缓存设置足够短的 TTL,把最坏情况下的一致性问题钉在一个已知上限内;
- 广播消息携带数据版本号或更新时间,实例收到后先比较版本再决定是否清除,避免乱序导致用旧值覆盖新值;
- 提供手动或定时全量刷新入口(清空本地缓存、按 key 前缀重载),用于线上止血而不是日常依赖。
还有一个常被低估的替代方案:把“是否进本地缓存”按一致性敏感度分级,而不是一股脑全上多级缓存。字典、枚举、配置这类几乎不变的数据可以放心使用较长 TTL;用户资料、商品价格这类会被实时修改的数据,本地缓存 TTL 应压得很短,或者干脆只用 Redis 加连接池优化。按数据的一致性敏感度分层,通常比给所有数据套同一套多级缓存更能降低系统复杂度,因为复杂的部分只留在真正需要它的地方。
实践建议:先能量化,再谈优化
给出一条可执行的分阶段路径。这些步骤假设你已经有基本的监控能力(例如 Prometheus 加 Grafana 采集指标),否则第一步就做不下去。
- 先量,后改。统计各缓存 key 的命中率、平均回源耗时、峰值 QPS,并估算不一致带来的业务损失:是“用户看到旧头像三秒”,还是“重复下单、重复扣款”。没有这个量级判断,讨论“要不要上多级缓存”只是审美问题。
- 收敛写入口。让所有写路径最终经过同一个服务或同一个事件源(统一领域服务加 binlog 订阅),这样失效逻辑只需在一处维护,审计和回滚也有唯一位置。
- 补齐三类防护。穿透用布隆过滤器加空值缓存(空值同样要设 TTL),击穿用单飞或逻辑过期,雪崩用 TTL 抖动并预设降级返回,例如返回上一次的旧值或兜底页,而不是让全部请求压向数据库。
- 给降级路径做压测。用 JMeter 或 Locust 在“缓存层假装不可用”的条件下压一轮,观察数据库连接池、慢查询、线程池是否撑得住。很多一致性事故的最终表现是数据库被打挂,而不是脏数据本身。
- 写清一致性等级文档。为每个字段明确容忍窗口,例如价格不超过 1 秒、库存以数据库为准、评价数不超过 5 分钟,把模糊的“尽量一致”变成可验收的指标和告警阈值。
不适用的场景也要讲清楚:跨服务、跨库的金额类变更,缓存层无法可靠兜底,应当依赖数据库事务、幂等键与对账任务;写多读少的数据(日志、流水)缓存命中率天然很低,投入多级缓存的收益远小于优化索引与写入路径。把这些情况提前排除,比事后给缓存打补丁便宜得多。
总结与讨论
三条可以带走的结论:第一,缓存是一份副本,副本数量直接决定一致性问题的难度,多级缓存的收益要用一致性窗口来标价,而不是只看压测曲线;第二,失效语义优先选“删除”而不是“更新”,并让写路径收敛到单一入口,这能消掉大部分脏数据来源,也让排查变得可行;第三,广播失效永远可能丢,短 TTL 与版本号才是最后的安全网,目标应从“强一致”调整为“可度量、可接受的窗口”。
留一个具体问题供讨论:如果你手上的业务必须把会频繁变动的数据放进本地缓存,你会选择极短 TTL、版本号校验,还是干脆放弃本地缓存、改用 Redis 并优化连接池与序列化?在你们真实的流量结构下,这三条路的取舍点大概落在哪里?