价格改了,用户端还是旧价——这是后端值班里最容易被截图甩进群的一类问题。团队的反应通常很快:把“先删缓存再更新数据库”改成“先更新数据库再删缓存”,发布上线,观察两天,好像好了。但过一阵子,脏数据又在大促压测或者主从切换之后冒出来一次,频率低到无法复现,高到每次都能上客诉。
本文想回答的不是“哪种写法最正确”,而是三个更具体的问题:这个写法在什么并发时序下会失效,失效窗口有多大,把它压小需要付出什么代价。需要先说明信息边界:本次拿到的站内参考资料为空,没有具体帖子文本可供引用,因此下文不会虚构某个帖子或某位用户,只归纳这类问题在社区里反复出现的提问形态;机制部分基于通用的并发模型与缓存模型推演,不涉及任何具体线上事故。
先给结论
缓存一致性不是“选对一行代码”的问题,而是“你能容忍多长的脏读窗口、窗口期内谁来保底”的问题。 任何“更新库 + 删缓存”的组合都不是原子的:只要读请求的回填和写请求的删除之间可能交错,窗口就存在。工程上能做的是把窗口压到业务可接受,并为窗口期准备好兜底手段。
核心判断:不要先问“先删还是后删”,先问“这份数据脏 3 秒会赔多少钱”。答案决定了你该用延迟双删、binlog 订阅,还是干脆不缓存。
这个结论有明确边界。如果业务是余额变动、库存扣减这类写路径,正确做法往往不是“把缓存做得更一致”,而是让这条路径根本不读缓存,改走主库加行锁或版本号。把强一致需求塞进缓存层,等于用应用层的时序假设去承担数据库本该承担的责任。
问题是怎么形成的:三类交错时序
缓存不一致的根因不是“删晚了”,而是“读请求的回填”与“写请求的删除”这两步之间没有顺序保证。最经典的一种时序是:读请求 miss,读到 v1;写请求把库更新为 v2 并删掉缓存;读请求这时才把 v1 回填进去。缓存里于是留下一个比数据库更旧、而且看起来“干净”的值。
流程图
渲染中...
图里第 2 步和第 5 步之间隔了一次数据库更新,读请求手里握着的是 v1,它并不知道这期间值已经变了。这个窗口的长度大致等于“读库 + 序列化 + 回填”的耗时,通常几毫秒到几十毫秒。看起来很小,但在 QPS 上千的商品详情页上,意味着每次改价都可能有若干个请求落进窗口,具体数量取决于写操作发生时正在飞行中的读请求有多少。
第二种是“先删缓存再更新库”。它的窗口反而更大:删完缓存到库更新完成之间,任何读请求都会 miss 并回填旧值,窗口从“一次回填耗时”变成了“一次数据库写耗时”。这就是社区讨论里“先删缓存”逐渐被“后删”取代的原因——不是它错,而是它的失效区间更长、更难控制。
第三种最容易被忽略:主从延迟。写请求更新主库并删掉缓存,紧接着的读请求走从库,从库还没同步到 v2,于是读到 v1 又回填进缓存。此时缓存是“刚写过的”,看起来没有任何异常,后续所有读都会命中这个旧值,直到 TTL 到期。这类脏数据存活时间最长,也最难在测试环境复现,因为测试库通常没有复制延迟。
常见方案的取舍
| 方案 | 一致性强度 | 主要代价 | 适用场景 |
|---|---|---|---|
| 先更新库再删缓存 | 窗口毫秒级但非零 | 实现简单,依赖每处写都记得删 | 绝大多数展示类数据 |
| 延迟双删 | 窗口压到百毫秒到秒级 | 延迟时间靠估算,依赖延迟队列 | 中等敏感、写不频繁 |
| 订阅 binlog 异步失效 | 百毫秒到秒级 | 多一套中间件要运维 | 写入入口多、易漏删 |
| 读写加分布式锁 | 接近强一致 | 吞吐下降,锁服务成为新故障点 | 极少数强一致热点 |
延迟双删的“延迟”到底该设多少,是这套方案最尴尬的地方。它必须大于一次“读库 + 回填”的耗时,还要覆盖主从延迟,而这两个值都在抖动:平时 20 毫秒,大促时可能 200 毫秒,主从延迟在批量写入时会突然涨到秒级。设小了压不住窗口,设大了只是把删除任务推给延迟队列,队列积压时同样失效。所以它更适合被理解成“缩小窗口的工程手段”,而不是“解决一致性”。
binlog 订阅把失效动作从业务代码里拿出来,挂在数据库变更流上。好处是任何写入路径——运营后台、数据修复脚本、其他服务的直连写——都会触发失效,不会因为某个开发忘了写删除逻辑而漏掉。代价是多一套中间件要运维,且失效本身是异步的,仍有百毫秒到秒级延迟。它换来的不是更短窗口,而是更低的漏删概率,这个区别在选型时经常被混淆。
加分布式锁让读写互斥,理论上可以做到接近强一致,但锁粒度、锁超时、锁服务高可用都会变成新的故障点。Redisson 的看门狗续期能缓解超时问题,代价是持锁期间该 key 的所有读请求排队。对于单 key 热到几千 QPS 的详情页,这是用吞吐换正确性,通常不划算;对配置开关这类低 QPS 数据,则完全可接受。
缓存层还有三个更常见的坑
很多被归因为“缓存不一致”的故障,其实来自另外三个机制。穿透:查询一个不存在的 key,缓存永远不命中,请求全部打到库上,典型场景是用不存在的商品 ID 刷接口。击穿:某个热点 key 恰好过期,瞬间大量请求同时回源重建。雪崩:大批 key 在同一时刻过期,或者 Redis 整体不可用,回源流量直接压垮数据库。
它们和一致性问题的关系在于:团队常常为了“减少不一致”把 TTL 设得很短,结果把击穿和雪崩的概率一起抬高了。TTL 越短,脏数据存活越短,但回源次数越多、数据库压力越大,这是同一枚硬币的两面。一个具体的数量感是:假设 10 万个 key 的 TTL 都设 300 秒且均匀过期,平均每秒约 333 个 key 失效;但如果这 10 万个 key 是同一批预热任务写进去的,过期就会集中在同一个几秒内——这正是雪崩的典型成因。加 0 到 60 秒的随机抖动,就能把这条尖峰摊平。
- 穿透:缓存空值(短 TTL)+ 布隆过滤器前置拦截
- 击穿:互斥重建,或逻辑过期 + 异步刷新
- 雪崩:TTL 加随机抖动 + 多级缓存 + 熔断降级
落地:一份可执行的检查清单
- 先给字段分级:哪些脏 5 秒可以接受,哪些必须强一致
- 写路径统一收口到一个入口,所有写都经过同一个删缓存逻辑
- 读路径加空值缓存,避免穿透
- 热点 key 用逻辑过期 + 异步刷新,避免击穿
- 所有 TTL 加随机抖动,避免雪崩
- 本地缓存 TTL 明显短于 Redis,并用广播失效
Java
public String getPrice(long skuId) {
String key = "sku:price:" + skuId;
String local = localCache.getIfPresent(key);
if (local != null) return local;
String value = redis.get(key);
if (value == null) {
value = loadFromDb(skuId);
int ttl = 300 + ThreadLocalRandom.current().nextInt(60);
redis.setex(key, ttl, value);
}
localCache.put(key, value); // 本地 TTL 设 5s,比 Redis 短得多
return value;
}
这段代码的关键不在读,而在“本地 TTL 5 秒、Redis 300 秒加抖动”这个配置组合:最坏情况下本地缓存比 Redis 多脏 5 秒。这不是缺陷,而是一笔明确的预算——你知道了最坏窗口是 5 秒,就能回答“这份数据脏 5 秒会怎样”。写路径则要先更新库,再删 Redis,再通过消息广播让所有实例清掉本地缓存;漏掉广播,多实例部署下每个节点都会各自脏满一个本地 TTL。
什么时候不该追求缓存一致性
读多写少、结果允许分钟级陈旧的场景——商品描述、类目树、配置项、推荐位——把窗口控制在秒级就够了,继续投入一致性成本收益很低。反过来,余额、库存扣减、优惠券核销这类写路径,正确做法通常是不缓存:读主库,用行锁或乐观锁版本号保证并发正确,把缓存只留给旁路的展示字段。判断标准是“这条数据的错误会不会直接变成钱”,而不是“它在不在热点路径上”。
跨服务的强一致更不该靠缓存补。订单和库存分属两个服务时,用 Seata 的 AT/TCC,或者本地消息表加事件驱动来保证最终一致,是另一个复杂度层级的问题;把希望寄托在“删缓存删得够快”上,等于用应用层的时序假设去覆盖分布式事务该解决的问题,一旦有第三方脚本直连写库就会失效。
总结与讨论
- 缓存不一致的根因是读回填与写删除之间没有顺序保证,任何“更新 + 删”组合都只能压小窗口,不能消除窗口。
- 选型的分水岭是“脏多久会出事”,而不是“哪种写法更高级”;延迟双删、binlog 订阅、分布式锁分别对应不同的窗口长度与运维成本。
- 用缩短 TTL 来换一致性,会同时抬高击穿和雪崩的风险,这三个问题要放在一张账上算。
最后一个值得讨论的问题:在你们的系统里,有哪一个字段的“可容忍脏读时间”是被明确写下来、并且和业务方对齐过的?如果从来没有写过,那它现在实际上是多少——是那个 TTL 值,还是根本没人知道?