面试考察点

  • 是否先明确允许多长时间的不一致。
  • 能否分析“先删缓存还是先写数据库”的竞态。
  • 是否会用消息、重试和版本号完成最终一致闭环。

核心答案

常见 Cache Aside 模式是读未命中时查库并回填,写请求先提交数据库再删除缓存。它降低了把旧值长期写回缓存的概率,但仍是最终一致方案,需要删除失败重试、TTL 兜底和必要的版本校验。

严格强一致通常意味着把缓存排除在关键读路径,或引入串行化协调,成本显著更高。

竞态分析

若先删缓存再写库,并发读可能在数据库更新前回填旧值。先写库再删缓存也存在删除失败问题,可通过事务消息、Outbox 或订阅 Binlog 异步失效,并保证消费幂等。

Cache Aside 的完整读写路径

读:缓存命中 -> 返回
        缓存未命中 -> 查数据库 -> 写缓存(带 TTL/版本) -> 返回

写:校验与本地事务更新数据库 -> 提交成功 -> 删除/失效缓存
        删除失败 -> 可靠事件重试 -> 对账修复

数据库是事实源,缓存是可丢失的派生副本。把这个层级明确下来,很多争论会更容易判断:缓存更新失败不能回滚已提交数据库;缓存数据错了,应该能通过失效和回填最终恢复。

两种典型竞态时间线

先删后写:请求 A 删除缓存,请求 B 缓存未命中并读取旧数据库值回填,然后 A 提交新值,缓存长期是旧数据直到 TTL。先写后删:A 提交新值后删缓存失败,旧缓存继续存在。后者的窗口通常更可控,因为删除操作可可靠重试,且 TTL 仍是最后兜底。

“延迟双删”是在第一次删后写库,再延迟删一次,试图覆盖旧读回填。它只能降低概率,延迟时间无法准确覆盖线程调度、数据库提交、GC 和网络波动;不能作为高一致性系统的唯一保证。

版本化回填防止旧值覆盖

缓存值可包含业务版本:

{"version": 42, "payload": {"status": "PAID"}}

回填前比较数据库版本或使用 Lua 脚本仅在新版本更高时覆盖。这样即使一个慢读请求晚于新写完成,也不会把版本 41 覆盖版本 42。版本必须来自单调、可信的事实源,不能随意使用多机时钟。

删除失败如何可靠恢复

在数据库本地事务内同时写业务表和 outbox 事件;提交后由投递器删除缓存或发布变更。事件带唯一 ID、对象 key 和版本,消费者幂等处理;失败持续重试并进入死信或人工处理队列。也可以订阅 Binlog/CDC 统一失效,但需要处理消费延迟、重复、顺序和补偿。

对高价值数据设置定期对账:从数据库抽取版本与缓存抽样比较,发现漏删或错版本时重新失效。这是最后的收敛机制,而不是把所有请求都变成强校验。

不同数据不同一致性

商品浏览量可以允许分钟级最终一致;库存、余额、权限和支付状态通常不能只依赖缓存。对强一致读,直接读数据库、使用主库/事务快照或建立专门的协调协议;不要用“加 Redis 锁”掩盖业务一致性要求。

热点更新的策略

热点 Key 每次写后删缓存会导致频繁回源,可用写穿、异步批量刷新、短逻辑过期或按版本增量更新,但都要明确写入顺序和失败恢复。性能优化不能牺牲该数据类别的正确性边界。

工程实践

缓存值可携带数据版本,回填时拒绝旧版本覆盖新版本。热点 Key 重建要限制并发;对账任务、删除重试队列和不一致监控共同构成可恢复机制。

常见误区

延迟双删中的固定等待时间无法覆盖所有数据库提交和请求耗时,只是概率性补偿。分布式锁也很难覆盖所有绕过缓存的写入口,且会增加延迟和可用性风险。

高频追问与参考回答

追问:为什么通常删除缓存而不是更新缓存?

删除让下一次真实读取按统一逻辑重建,避免复杂值计算、部分更新顺序和无效更新;代价是下一次读会回源,应对热点做重建保护。

追问:缓存和数据库能做到绝对强一致吗?

可以通过同步协调和把缓存纳入严格协议接近强一致,但延迟、可用性和复杂度显著上升。多数业务选择明确窗口的最终一致,并对关键读绕过缓存。

追问:删除缓存使用 DEL 还是设置短 TTL?

删除可让下次读立即回填,短 TTL 仍可能在窗口内返回旧值。选择取决于业务容忍度;无论哪种,都要有失败重试和版本保护。

追问:读缓存命中后还需要校验数据库版本吗?

对普通最终一致读不需要,否则缓存价值会消失。只在高价值、风险敏感或发生疑似不一致的特定路径做校验。

总结

一致性设计从业务容忍度出发,用数据库作为事实源,以失效、重试、版本和对账保证可收敛。

机制全景图

下面把「Redis 缓存与数据库一致性怎么保证?」从输入到结果压缩成一条可复述的主链路。面试时先用图建立全局坐标,再进入局部实现,能避免只背零散结论。

flowchart LR
    A["业务写数据库"]
    A --> B["提交事务"]
    B --> C["删除或更新缓存"]
    C --> D["并发读回源"]
    D --> E["通过重试/消息最终收敛"]

完整链路:从输入到结果

沿着「业务写数据库 → 提交事务 → 删除或更新缓存 → 并发读回源 → 通过重试/消息最终收敛」观察输入、状态与输出,下面每个阶段都对应一个可以在源码、日志或系统表中验证的位置。

1. 业务写数据库

数据库通常是事实源,写操作先提交数据库可避免缓存成功而数据库失败造成虚假新值。

2. 提交事务

Cache Aside 常选择删除而非更新,因为更新可能浪费且并发顺序更难控制。

3. 删除或更新缓存

删除失败会留下旧值,需要可靠重试、事务消息或 CDC 补偿,不能只记录一条日志。

4. 并发读回源

删除与并发慢查询存在回填旧值窗口,短 TTL、延迟双删或版本校验可缩小窗口。

5. 通过重试/消息最终收敛

最终一致必须定义最大陈旧时间、可观测差异和人工/自动修复,而不是一句“下次过期就好”。

源码与实现定位

入口 阅读重点
MONITOR/应用事件仅测试使用 写删顺序证据
Key 版本字段/CDC offset 拒绝旧回填

源码或系统表应按上表顺序追踪:先确认入口实际走到哪条路径,再用运行时数据验证,而不是仅凭类名或配置推测。

参数配置与可复现实验

local version = tonumber(redis.call('HGET', KEYS[1], 'version') or '-1')
if tonumber(ARGV[1]) >= version then return redis.call('HSET', KEYS[1], 'version', ARGV[1], 'data', ARGV[2]) end

固定“慢查询回填旧值”和“删除失败”调度,验证版本写、Outbox 重试与最大陈旧时间。

验证步骤与预期结果

1. 固定输入和基线

先在没有故障注入的环境执行上述配置,固定数据规模、并发度、运行时版本和预热时间。以「版本差」为主基线,记录值应满足「稳态 0」;同时保存 缓存与数据库版本差、删除重试积压,使后续变化能够回到同一时间轴比较。

2. 从实现入口确认路径

在「MONITOR/应用事件仅测试使用」确认请求确实进入「写删顺序证据」对应的实现,再沿「Key 版本字段/CDC offset」观察「拒绝旧回填」。如果入口路径都未命中,就不应继续调整下游参数,而应先检查调用条件、版本或路由是否与假设一致。

3. 注入本文特有的失败模式

优先复现「先删缓存再写库导致旧值回填」,并把单一变量逐级放大,直到「版本差」越过「持续非零」。随后再分别验证「删除失败无重试」和「消息乱序让旧版本覆盖新版本」,三类故障分开执行,避免多个变量同时变化而无法归因。

4. 执行止损和根因修复

第一轮只应用「提交后删除并可靠重试」,确认它能控制影响范围;第二轮应用「回填携带版本」,验证核心链路恢复;最后落实「强一致操作绕过缓存」,消除同类问题再次出现的条件。每一步都保留变更前后数据,不用“感觉变快了”替代测量。

5. 通过退出条件

实验只有同时满足三项才算通过:「版本差」回到「稳态 0」、「删除重试」回到「快速清空」、「陈旧窗口」回到「<业务上限」,并且业务结果差异为零。若性能恢复但结果不一致,仍应视为失败;若指标恢复后很快再次越线,则说明只完成了临时止损,没有消除根因。

量化基线

指标 样例基线/口径 风险线 结论
版本差 稳态 0 持续非零 同步故障
删除重试 快速清空 单调积压 消息链路
陈旧窗口 <业务上限 越界 TTL/补偿失效

这些数值是实验口径或示例告警线,不是可复制到所有系统的固定答案;上线阈值应由本系统稳态、峰值和故障演练共同确定。

事故复盘:更新成功后用户仍看到旧资料

数据库提交后删除缓存超时,接口仍返回成功,旧值持续到一小时 TTL。将删除事件写事务 Outbox 并异步重试,同时把 TTL 缩短到业务可接受窗口后,失败可被观测并最终修复。

失败模式 首要证据 第一处置动作
先删缓存再写库导致旧值回填 缓存与数据库版本差 提交后删除并可靠重试
删除失败无重试 删除重试积压 回填携带版本
消息乱序让旧版本覆盖新版本 陈旧读取比例 强一致操作绕过缓存

发布与回滚检查点

  • 发布前:确认「MONITOR/应用事件仅测试使用」对应实现和上述配置在目标版本仍然有效,并保存「版本差」基线。
  • 灰度中:同时观察 缓存与数据库版本差、删除重试积压、陈旧读取比例;任一指标越过表中风险线,就停止继续扩量。
  • 回滚时:先执行「提交后删除并可靠重试」控制影响,再回退代码或参数;涉及持久状态时必须额外核对结果差异。
  • 发布后:至少覆盖一个完整峰值周期,确认「先删缓存再写库导致旧值回填」没有再次出现,才关闭变更观察窗口。

方案对比与选型

方案 更适合的场景 主要收益 代价与边界
Cache Aside 删除 通用读多写少 实现简单、按需加载 有删除失败与并发回填窗口
写穿/同步更新 写后读必须快速看到新值 缓存立即更新 双写顺序和失败处理复杂
CDC 驱动缓存 多写入源或需可靠收敛 与数据库提交日志绑定 链路延迟、乱序和运维成本

选型至少带上 Key 数量、Value 大小、命令复杂度、热点分布和过期速率,并用上面的量化基线验证;未知数据应明确为待测假设。

设计边界与工程取舍

缓存一致性不是二选一算法,必须把事实源、陈旧窗口、失败补偿和版本顺序共同定义;强一致需求应减少或绕过缓存。

工程落地遵循:Redis 是高性能数据结构服务,不应被当成没有容量边界的黑盒。回答时直接引用「MONITOR/应用事件仅测试使用」、配置实验和事故数据,比复述固定模板更有说服力。