核心答案
常见方案是 Cache Aside:读请求先查缓存,未命中再查数据库并回填;写请求先更新数据库,再删除缓存。它通常保证最终一致性,而不是严格强一致。
为什么是删除而不是更新缓存?
更新缓存可能依赖多张表计算,也可能被低频读取造成无效写入。删除缓存让下一次读取根据最新数据库状态重新构建,逻辑更简单。
并发窗口
即使先更新数据库再删除缓存,也可能出现旧读请求在删除之后把旧值重新写回缓存。实际系统会根据一致性等级选择过期时间、延迟二次删除、互斥重建或基于消息的失效通知。
消息与 Binlog 方案
写入数据库后发送消息删除缓存,需要处理消息发送失败和重复消费。更可靠的做法是使用事务消息、Outbox,或者订阅数据库 Binlog,把数据变更转成缓存失效事件。
如何选择方案?
- 商品详情等读多写少场景:Cache Aside + 合理 TTL。
- 库存、余额等强约束数据:数据库或专门一致性服务作为判断依据,不能只信缓存。
- 大规模派生缓存:CDC/Binlog + 消息队列异步失效。
- 热点 key:增加互斥重建、逻辑过期和限流,防止击穿。
面试回答建议
先说明业务需要的是强一致还是最终一致,再给出读写流程、失败补偿、监控指标和降级方案。脱离业务直接说“延迟双删就能保证一致”是不严谨的。
核心考点清单
- 先确认业务需要强一致、最终一致,还是允许短暂脏读。
- Cache Aside 写路径通常是更新数据库后删除缓存。
- 删除失败要有可靠重试,可使用 Outbox、消息队列或订阅 Binlog。
- 热点 Key 要防击穿,批量过期要防雪崩,无效查询要防穿透。
- TTL 只能限制不一致时间,是兜底而不是一致性保证。
并发时序为什么重要
先删缓存再写数据库时,另一个线程可能在写库前读到旧值并回填。先写数据库再删缓存仍有极小窗口,但发生条件更苛刻,也更容易通过 TTL 和可靠删除补偿。延迟双删可以降低部分并发回填风险,但等待时间难以精确设置,不能代替可靠重试。
高频追问与参考回答
追问 1:为什么删除缓存而不是更新缓存?
删除是幂等操作,容易重试;直接更新还要处理并发写入顺序,并可能为不再读取的数据做无效维护。
追问 2:如何做到真正强一致?
旁路缓存天然适合最终一致。关键读可绕过缓存,或使用支持事务的一致性存储,但必须接受更高延迟或更低可用性。
追问 3:分布式锁能解决全部一致性问题吗?
不能。它能限制并发回源,却无法原子覆盖数据库提交与缓存删除,还要处理锁超时、进程暂停和故障切换。
机制全景图
下面把「缓存与数据库一致性如何保证?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。
flowchart LR
A["业务写入事实库"]
A --> B["事务提交形成版本"]
B --> C["删除/更新缓存"]
C --> D["并发读回源与回填"]
D --> E["可靠重试和对账收敛"]
完整链路:从输入到结果
沿着「业务写入事实库 → 事务提交形成版本 → 删除/更新缓存 → 并发读回源与回填 → 可靠重试和对账收敛」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。
1. 业务写入事实库
先明确数据库或事件日志是事实源,缓存只能从可恢复事实派生。
2. 事务提交形成版本
事务提交点给数据一个确定版本,失败时不能提前让缓存暴露未提交状态。
3. 删除/更新缓存
Cache Aside 常在提交后删除缓存,删除失败必须进入可观测重试而不是忽略。
4. 并发读回源与回填
并发慢查询可能在删除后回填旧值,版本校验、短 TTL 或延迟双删用于缩小窗口。
5. 可靠重试和对账收敛
消息、CDC 和周期对账让长期差异收敛,并明确最大陈旧时间与人工处置。
源码与实现定位
| 入口 | 阅读重点 |
|---|---|
| 数据库 commit/binlog | 事实版本 |
| 缓存 value.source_version | 旧值识别 |
源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。
参数配置与可复现实验
UPDATE entity ...; COMMIT; DELETE cache:key;
固定慢回填、删缓存失败和乱序 CDC,测最大陈旧窗口。
验证步骤与预期结果
1. 固定输入和基线
先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「缓存/源版本差」为主基线,记录值应满足「记录活动/稳态基线」;同时保存 源版本与缓存版本差、删除/更新重试积压,使后续变化能够回到同一时间轴比较。
2. 从实现入口确认路径
在「数据库 commit/binlog」确认请求确实进入「事实版本」对应的实现,再沿「缓存 value.source_version」观察「旧值识别」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。
3. 注入本文特有的失败模式
优先复现「缓存故障时无限回源压垮数据库」,并把单一变量逐级放大,直到「缓存/源版本差」越过「超过容量或 SLO」。随后再分别验证「旧事件晚到覆盖新版本」和「一致性修复无审计导致重复副作用」,三类故障分开执行,避免多个变量同时变化而无法归因。
4. 执行止损和根因修复
第一轮只应用「提交后删并可靠重试」,确认它能控制影响范围;第二轮应用「回填带版本条件」,验证核心链路恢复;最后落实「强一致读绕缓存」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。
5. 通过退出条件
实验只有同时满足三项才算通过:「缓存/源版本差」回到「记录活动/稳态基线」、「端到端 P99」回到「小于预算」、「状态差异」回到「0」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。
量化基线
| 指标 | 样例基线/口径 | 风险线 | 结论 |
|---|---|---|---|
| 缓存/源版本差 | 记录活动/稳态基线 | 超过容量或 SLO | 触发降级 |
| 端到端 P99 | 小于预算 | 突破预算 | 停止扩量 |
| 状态差异 | 0 | 任意非零 | 补偿并对账 |
这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。
事故复盘:用户资料更新后不同接口读到两个版本
写服务更新数据库后删除一个缓存 Key,但聚合服务维护另一份派生缓存且未收到事件。通过统一版本事件、所有缓存记录 source_version,并用对账扫描补发后,跨服务缓存才收敛。
| 失败模式 | 首要证据 | 第一处置动作 |
|---|---|---|
| 缓存故障时无限回源压垮数据库 | 源版本与缓存版本差 | 提交后删并可靠重试 |
| 旧事件晚到覆盖新版本 | 删除/更新重试积压 | 回填带版本条件 |
| 一致性修复无审计导致重复副作用 | 陈旧读取比例 | 强一致读绕缓存 |
发布与回滚检查点
- 发布前:确认「数据库 commit/binlog」对应实现和上述配置在目标版本仍然有效,并保存「缓存/源版本差」基线。
- 灰度中:同时观察 源版本与缓存版本差、删除/更新重试积压、陈旧读取比例;任一指标越过表中风险线,就停止继续扩量。
- 回滚时:先执行「提交后删并可靠重试」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
- 发布后:至少覆盖一个完整峰值周期,确认「缓存故障时无限回源压垮数据库」没有再次出现,才关闭变更观察窗口。
方案对比与选型
| 方案 | 更适合的场景 | 主要收益 | 代价与边界 |
|---|---|---|---|
| Cache Aside | 通用读多写少 | 简单、按需加载 | 删除失败与并发回填窗口 |
| 写穿/写后同步 | 写后读必须快速一致 | 缓存及时更新 | 双写失败和顺序复杂 |
| CDC/事件驱动 | 多缓存或多写入口 | 以提交日志可靠收敛 | 延迟、乱序和运维成本 |
选型至少带上 峰值流量、数据规模、依赖容量、一致性目标和故障预算,并用上面的量化基线验证;未知数据应明确为待测假设。
设计边界与工程取舍
缓存一致性的目标不是追求抽象的“绝对同步”,而是定义事实源、可接受陈旧窗口、失败恢复和最终验证;不能容忍陈旧的操作应绕过缓存。
工程落地遵循:先定义 SLO 与不变量,再通过限流、隔离、异步和补偿保护核心链路。回答时直接引用「数据库 commit/binlog」、配置实验和事故数据,比复述固定模板更有说服力。