4. 缓存一致性问题的根源:数据源更新与缓存更新之间的时间窗口、并发写冲突
缓存一致性问题的本质,是数据源(数据库)与缓存(Redis / Local Cache)之间状态不一致。这种不一致并非系统故障,而是由两个核心因素共同作用产生的:时间窗口与并发写冲突。理解这两个根源,是设计一致性闭环架构的前提。
4.1 时间窗口:更新顺序的天然间隙
任何缓存更新策略都无法避免一个基本事实:数据源更新和缓存更新不是原子操作。无论先更新数据库还是先更新缓存,两者之间必然存在一个微小的时间窗口。在这个窗口内,其他请求可能读到旧数据。
| 更新顺序 | 时间窗口风险 | 典型场景 |
|---|---|---|
| 先更新数据库,后删除/更新缓存 | 数据库已更新,缓存尚未失效,读请求命中旧缓存 | 读多写少,缓存过期前短暂不一致 |
| 先删除/更新缓存,后更新数据库 | 缓存已清空,数据库尚未更新,并发读请求回填旧数据 | 高并发写入,缓存击穿后回填脏数据 |
| 先更新缓存,后更新数据库(极少用) | 缓存写入成功,数据库写入失败,缓存成为脏数据 | 最终一致性要求极低的非关键数据 |
关键结论:时间窗口的大小取决于网络延迟、数据库事务耗时、缓存操作耗时。在分布式环境下,这个窗口通常为 1ms ~ 50ms,足以让多个并发请求观察到不一致状态。
4.2 并发写冲突:多线程/多实例下的竞态条件
当多个写请求同时操作同一份数据时,时间窗口被进一步放大,形成并发写冲突。这是缓存一致性问题的第二个根源,也是最难处理的场景。
4.2.1 典型冲突模式:读-改-写回填
以下流程展示了并发写冲突如何导致缓存永久不一致:
- 线程A 更新数据库:将库存从 100 改为 90。
- 线程A 删除缓存(或标记失效)。
- 线程B 发起读请求,缓存未命中,从数据库读到旧值 100(此时线程A的事务可能尚未提交,或数据库主从延迟)。
- 线程B 将旧值 100 写回缓存。
- 后续所有读请求都命中缓存中的旧值 100,直到缓存过期。
危害:这种“旧值回填”一旦发生,缓存将长期持有脏数据,且无法通过常规的过期策略快速修复。在库存、余额、状态等强一致场景中,可能导致业务逻辑错误。
4.2.2 并发写冲突的三种形态
| 冲突形态 | 描述 | 典型后果 |
|---|---|---|
| 写-写冲突 | 两个写线程同时更新同一数据,后更新者覆盖前者 | 缓存与数据库最终值不一致,丢失更新 |
| 读-写冲突 | 读线程在写线程更新间隙回填旧数据 | 缓存永久持有脏数据,需等待过期 |
| 删-写冲突 | 一个线程删除缓存,另一个线程立即回填旧数据 | 缓存删除失效,旧数据重新占据缓存 |
4.3 并发写冲突的放大效应
在分布式系统中,并发写冲突的破坏力会被以下因素放大:
- 主从延迟:数据库主库写入后,从库尚未同步,读请求从从库读到旧值。
- 缓存穿透:缓存被删除后,大量并发读请求同时回填,加剧冲突概率。
- 多级缓存:本地缓存 + 分布式缓存 + 数据库,每一级都可能引入新的时间窗口。
- 重试机制:写失败后的重试可能覆盖其他线程的正确写入。
实践建议:在业务允许的范围内,优先采用“先更新数据库,后删除缓存”策略,并配合延迟双删、异步补偿或版本号校验来消除时间窗口。对于强一致场景,需引入分布式锁或事务消息。
4.4 根源总结:一致性问题的本质模型
将时间窗口和并发写冲突统一建模,可以得到缓存一致性问题的本质公式:
不一致概率 P = f(Δt, N, T)
其中:
Δt = 数据库更新与缓存更新之间的时间窗口长度
N = 并发读写请求的数量
T = 缓存过期时间(或失效策略的生效延迟)
当 Δt 趋近于 0(如使用分布式事务或同步双写),或 N 被控制在 1(如串行化访问),或 T 足够短(如极短 TTL),不一致概率可以降到可接受范围。但完全消除 Δt 和 N 在分布式环境下几乎不可能,因此需要设计闭环补偿机制来兜底。