缓存旁路模式实践
审校日期:2026-09-05。Cache-Aside 让应用管理数据库与缓存;它本身不保证二者一致。是否使用缓存,取决于允许的陈旧时间、读取负载与失效机制。
一、基本读写流程
读请求先查缓存;未命中时读取数据库,再把结果写入缓存并设置 TTL。更新请求先提交数据库事务,再删除对应缓存,让后续读请求按需重建。
读:缓存命中 → 返回
缓存未命中 → 查库 → 回填 → 返回
写:数据库事务提交 → 删除缓存
“数据库写成功”应指事务已经提交。在事务提交前删除缓存,其他请求仍可能读到旧的已提交数据并回填。
二、为什么通常删除而不直接更新
删除缓存能避免应用同时维护两份完整对象,并减少多个写入方乱序更新缓存的问题。但它不能彻底消除旧数据回填。直接更新缓存也并非永远错误,需要额外的版本校验、顺序或原子性约束。
Redis DEL 的复杂度取决于键数量与值类型;不能笼统说任何删除都是 O(1)。大集合释放可能带来额外耗时,需结合实际数据结构测量。
三、先更库后删缓存仍有竞态
下面的时间线中,读请求在数据库更新前取得旧值,在删除缓存后才回填:
| 顺序 | 读请求 A | 写请求 B |
|---|---|---|
| 1 | 缓存未命中,读数据库得到 old | |
| 2 | 暂停,尚未回填 | 提交 new |
| 3 | 仍暂停 | 删除缓存 |
| 4 | 把 old 写回缓存 | 已完成 |
| 5 | 后续请求命中 old |
因此,“先更库后删缓存只会短暂读到旧缓存”遗漏了慢读回填场景。旧值可能保持到过期、再次失效或补偿生效;没有 TTL 和修复机制时,不能保证何时收敛。
四、可复现案例
在仓库根目录运行:
node --test examples/consistency.test.js
database-first invalidation still permits stale refill 使用 Promise 屏障固定上述时序:先读 old,再写 new 和删缓存,最后允许读请求回填。测试断言后续仍读到 old;测试通过意味着成功复现风险。
第二个测试加入内存中的原子版本检查,阻止旧值回填,后续读取为 new;已经在途的第一个读仍返回 old。生产中把“数据库查版本”和“Redis 写入”拆成两步并不原子,不能直接照搬这个内存判断。实验说明见 并发一致性实验。
五、按问题选择缓解方式
| 问题 | 可以采取的措施 | 仍需验证的边界 |
|---|---|---|
| 删除缓存失败 | 可恢复的失效事件、重试与监控,TTL 兜底 | 数据库提交后进程崩溃是否会丢失失效任务? |
| 慢读回填旧值 | 版本化写入、协调读写或缩短陈旧窗口 | 比较与回填是否原子,版本信息是否也会丢失? |
| 从库延迟 | 关键读取按需要走主库或等待复制位点 | 从主库读取也不消除所有并发回填竞态 |
| 热点重建 | 请求合并、单键重建协调、过期时间扰动 | 协调失效时是否压垮数据库? |
| 不存在的键被反复查询 | 空值缓存、适用时使用布隆过滤器 | 空值的过期与新记录创建后失效 |
| 业务不能接受陈旧数据 | 关键决策读取权威存储,并设计事务隔离 | 禁用缓存不自动解决所有并发事务问题 |
延迟双删是降低特定竞态概率的手段;固定等待 500ms 无法覆盖无界的暂停或复制延迟。逻辑过期通常主动接受短时间旧值,也不等同于强一致。
六、与其他模式的区别
| 模式 | 谁管理缓存与数据源 | 主要代价 |
|---|---|---|
| Cache-Aside | 应用在读未命中时回填,在更新后失效 | 应用负责失败补偿与一致性边界 |
| Read-Through / Write-Through | 缓存层代理读取或同步写入 | 保证程度仍取决于实现及故障处理 |
| Write-Behind | 先确认缓存写入,之后异步落库 | 要处理丢失、乱序、重放与积压 |
不能仅凭 Write-Through 名称就认定它适合强一致业务。先明确需要保证的读写语义,再检查具体实现能否满足。
七、验收与参考资料
- 数据库提交之后才触发失效。
- 删除失败有可观察、可恢复的处理路径。
- 已复现慢读回填和从库延迟场景。
- 有明确 TTL、允许陈旧时间与关键读取例外。
- 缓存不可用时已验证数据库承载能力。