缓存与数据库一致性
数据库已经提交新价格,为什么“写库后删缓存”都成功,页面仍可能读到旧价格?本篇先构造旧回填的可达历史,再说明版本水位能防什么、依赖什么。缓存服务的原子执行不能跨越数据库提交,也不能凭空知道尚未送达的变更。
高级必会要求能手推旧读、回填和删除时序;架构师进阶要求定义传播窗口、版本内容契约、水位寿命与恢复边界。Redis 类型、单次执行和运行期错误的实际实现见 Redis 执行原子域,恢复丢失条件见 Redis 持久化与切换。这里的协议与 Lua 负例是教学模型,不是 Redis 引擎原码。
Cache-aside 的旧值回填竞争
事实源为数据库 (price=100,version=1),缓存暂空。读 R 先查出 v1 后暂停;写 W 提交 price=200/v2,并成功删除缓存;R 恢复,把 v1 写进空缓存。这条历史里没有“删除失败”,仍然出现旧缓存。
| 时刻 | 读 R | 写 W | 事实库 / 缓存 |
|---|---|---|---|
| t0 | 查库取得 v1,暂停 | — | DB=v1 / 空 |
| t1 | 暂停 | 提交 v2 | DB=v2 / 空 |
| t2 | 暂停 | 删除缓存成功 | DB=v2 / 空 |
| t3 | 用旧快照回填 v1 | — | DB=v2 / C=v1 |
另一类失败是库已经提交,删除请求超时或进程崩溃,删除根本没有可靠落地。它与上表的“删除成功后旧读回填”是两个不同窗口:可靠重试删除可以修复前者,不能单凭一次成功删除排除后者。
TTL 从缓存写入开始计时。设 W 在 t=0 提交,旧读 R 暂停 600 秒后回填,再设置 1 秒 TTL;在没有其他修复时,旧缓存的过期末端可到提交后 601 秒,即 601000ms。TTL 只在“此后没有更多旧回填、过期读取正确处理”等条件下约束该次值的寿命,不能把它直接当作数据库提交后的最大不一致时间。延迟双删要求等待超过所有旧读生命周期,长 GC、排队或网络分区会打破这个自然上界。
允许短暂旧展示、删除通知可重放、数据库能承受失效回源时,cache-aside 仍简单实用。下单价、余额和权限裁决则要在可靠事实源执行条件写或授权检查,并明确价格确认/权限生效时点。Cache-aside 参考解释使用背景,不替代上述故障历史。
带水位的回填协议:有效条件与反例
为同一投影的业务键保留不可回退版本水位 F,缓存 C 保存 (version,payload_hash,payload)。版本由事实源产生、同一对象上可比较;各应用机器的本地时间不能直接承担这个顺序。Redis Cluster 中协议涉及的 F/C 必须同槽,其他部署也须在同一个原子执行域内操作。
正常协议维护两条不变量:F 只增不减;每次操作结束后,C 要么为空,要么 C.version >= F。缓存值的版本、摘要和内容作为一份完整记录读写,不能分别读取后拼成来自不同时刻的值。
invalidate(v),一次原子操作:
F = max(F, v)
若 C 存在且 C.version < F,则删除 C
fill(v, hash, payload),一次原子操作:
若 v < F,或 C 存在且 v < C.version,则拒绝
若 C 存在且 v == C.version 且 hash != C.hash,则报冲突
否则写 C=(v, hash, payload),设置有界 TTL
guarded_read(),一次原子操作:
若 C 不存在或 C.version < F,则返回 miss
否则返回完整 CF2 已安装时,迟到的 fill1 必须拒绝;C 已是 v3 时,即使 F 只有 2,迟到 fill2 也不能覆盖 v3。失效事件重复或乱序时用 max,不能把 F3 改回 F2。删除事件同样携带 v3 等 tombstone 版本,拒绝迟到的 v2 create;事实源还必须规定 v3 对应删除语义,不能用同一版本重新生成活对象。
协议保护的是“相对于已经安装的水位不回退”,不是“数据库一提交所有读立刻最新”。DB2 已提交但事件尚未到达,缓存仍只知道 F1,正常读 v1 仍可能发生。持久 Outbox或 CDC 帮助通知可恢复与重放,不能抹掉传播时间,也不能把 Lua 扩展成数据库与 Redis 的共同提交。
正常交错中,分两步读取会不会必然读旧
不能仅因 GET F → GET C 是两条命令,就断言失效穿插必然产生错误。先限定:上节的 invalidate/fill 每次都完整原子执行;无绕过写入、F 丢失或脚本半执行;读 C 取得完整记录。此时不变量一直成立,在 GET C 的瞬间 C 已满足当时的 F,之前读到的旧 F 并不会让旧 C 凭空重新出现。
从 F1/C1 开始,失效 I2 与两次读取可归为下列情况;fill1 和 fill2 插入后也遵守同一不变量。
| 交错 | 第二次读取可见什么 | 为什么合法 |
|---|---|---|
| GET F → GET C1 → I2 | C1 | 读可线性化在 GET C1;响应时已经发生 I2 也不要求回溯撤销返回值 |
| GET F1 → I2 → GET C | 空,或后续合法填入的 C2/更高版本 | I2 已删除低于 F2 的 C1,fill1 不能重新放回 |
| I2 → GET F2 → GET C | 空,或 C2/更高版本 | 正常状态不含低于当前 F2 的 C1 |
| GET F1 → fill2 → GET C2 | C2 | 版本可超过旧 F,读取不要求一定命中旧值 |
因此,在这些前提下,两步读可在 GET C 处线性化为一次符合水位缓存规范的读取。它不是数据库事实源的线性一致读,也不是“响应发出时仍为最新”:读完成与响应送达之间仍可发生新提交。若要求读己写或至少观察某次提交,需下节的最小版本契约。
保留原子 guarded_read 的价值在于:它把检查收进一个操作,还可以防御正常协议之外形成的坏状态。不能倒因为果,用人为注入的 C1/F2 宣称正常 invalidate/fill 本身能产生该状态。模型会分别枚举正常历史与额外故障历史。
Lua 先写后错:额外故障怎样变成可达历史
Redis 脚本执行期间不与普通命令交错,但运行期错误不会回滚已经写入的值。假设缓存仍是合法的 F1/C1,另有错误类型的辅助计数键 aux=not-an-integer。下面这个有意写错的失效脚本先写 F,再计数,原计划最后删 C:
-- 有意包含故障的教学脚本,不能作为生产失效实现。
redis.call('SET', KEYS[1], ARGV[1]) -- 本例 ARGV[1] 为 2
redis.call('INCR', KEYS[3]) -- aux 不是整数:运行期报错
redis.call('DEL', KEYS[2]) -- 未执行,旧 C1 留下现在坏状态 C1/F2 有了具体可达前提:写后可能报错的脚本 + 无效辅助键。若 R 先 GET F1,脚本随后运行并出错,R 再 GET C1 用旧 F1 校验,就可能放行 1;一个之后执行的原子 guarded_read 同时看到 F2/C1,会返回 miss。直接注入 C1/F2 也能检验守卫,但只证明它识别坏状态,不能证明坏状态来自正常协议。
实际实现要在写之前验证输入、键类型和必要条件,避免在关键状态变更之间插入可能失败的统计命令;收到脚本错误或超时应保留待恢复意图,检查/修复状态并安全重放。预检减少已知错误路径,却不等于 Redis 承诺任意故障都带事务回滚。恢复脚本、绕过协议的后台写入或备份恢复也可能破坏 C/F 不变量,应有审计与修复路径。
相同版本的内容契约与水位寿命
同一对象同一版本必须代表固定投影。先固定 projection_id、投影 schema 和规范化编码,再对指定业务字段生成摘要;不能让服务 A/B 用不同字段、无序 JSON、当前时间或不同计算逻辑生成同一版本的“同一内容”。现存 C2/hash-A 遇到 v2/hash-B,应报冲突并回查事实源,不能因为版本相等就静默覆盖。
仅比较现存 C 的 hash 有一个边界:C 过期后,本地不再记得旧 hash。完整保证来自事实源/不可变事件对 (对象,投影,版本) 内容的唯一约定,或另存可靠版本摘要;单个整数 F 无法凭空识别同版本异参。删除版本的 tombstone 和活对象也要服从同一约定,新增投影 schema 可使用独立命名空间,避免合法升级被误判为冲突。
还有三个寿命与恢复分支不能省略:
- 通知未到:DB2 已提交而 F 仍为 1,读取 v1 未违反本地水位;观察不到数据库新事实。
- F 被过期或淘汰:F2 消失后,暂停旧请求把 v1 填回空 C;“缓存空”不代表任何旧版本都可以写。
- 故障切换丢失 F2:异步恢复到 F1 后可能产生同样旧填;即使旧主上的脚本完全正确,也不能据此证明新主保有水位。
F 的保留要覆盖旧读最大生命周期、事件重放窗口,并与容量、淘汰和切换保证匹配。若旧请求可以任意久暂停,任何有限 TTL 都不能单独证明安全。F/C 组合的恢复不能只看“恢复了多少键”,还要看恢复后是否仍满足版本与内容契约。
一种收敛方式是在重建 generation 切换后拒绝旧 generation 请求;但 generation 的授权、当前值和恢复自身必须可信,不能清空 F/C 后又接受所有旧请求。另一种读取契约是由调用者携带最小事实版本 min_version=2,缓存低于它就查能满足该版本的事实源,并且达不到时明确等待或失败;读一个落后的数据库副本仍不满足契约。若要求所有提交立即全局可见,需要能提供相应裁决语义的存储与读取路径,不能仅增加缓存检查。
可检查模型与条件变化
完整模型文件只用 Python 标准库,公开入口为:
python3 labs/data-senior/models.py --json cache先手算预期再看输出:旧回填为 1,600 秒暂停加 1 秒 TTL 得到 601000ms;F2 拒绝 fill1,合法 fill2 可读 2;事件未到仍可读 1;丢失 F2 可重新填入 1;删除水位 F3 拒绝 v2。新模型把 GET F、GET C、invalidate2、fill1、fill2 的所有合法顺序枚举,约束 GET F 在 GET C 之前,共 60 条,正常不变量违规为 0。这是有限输入下的检查,不是对所有实现的形式化证明。
额外故障输出与正常枚举分开:partial_script_failure 应为 floor=2、cache=1、split_read=1、guarded_read=null;same_version 的 first/duplicate 为 true,conflict 为 false,payload 保留 canonical。旧的 stale_read=null 是人为放入 C1/F2 后验证守卫,不能混入正常可达历史当作反例。
需要实际 Redis 时,按 Redis 实验说明运行 缓存协议脚本,并下载同目录依赖 run_local.py:
python3 labs/data-senior/redis/cache_consistency.py --host 127.0.0.1 --port 63792026-10-23 已在本机 Redis 8.6.4 standalone 运行:填入依次返回 STALE、APPLIED、DUPLICATE、CONFLICT,当前读为 2,删除水位拒绝旧值;正常 GET F1→invalidate2→GET C 得到空;先写 F2 再 INCR 非整数 aux 确实报错并保留 C1,分步读返回 1、原子守卫返回 null;删除 F 后旧值再次可读 1。脚本仅使用 UUID 专用的三个键,finally 精确清理后剩余 0,未改变全局配置。固定源码基线仍为 7.2.5,不能把本机运行版本写成基线实测。
模型与单节点脚本均不实现 CDC 必达、多副本切换、掉电或长期容量,也不能当作这些场景已验证。条件变化要触发不同验证:展示改为读己写,验证最小版本不足时回源;已知短旧请求改为无界暂停,验证代际拒绝;单节点改异步副本,验证 F/C/摘要/generation 的恢复;固定投影升级,验证 schema 命名空间与冲突处理。正常顺序、重复乱序、半执行错误、删除后迟到 create 和 F 丢失都要覆盖,不能只跑一次成功更新。
三轮追问:从旧展示到业务裁决
第一轮:“删缓存重试成功能解决全部问题吗?”不能,上文 R 旧读暂停后回填发生在成功删除之后,需回填版本协议;通知未落地还需可靠重放。评分:分开两个窗口 1 分、对应机制 1 分。
第二轮:“GET F 后 GET C,必然要用 Lua 才安全吗?”在正常原子 invalidate/fill 保持 C≥F 且无丢失/绕过写入时,不成立;但半执行坏状态能让旧 F 检查漏过,需要具体前提与原子守卫。评分:正常证明 1 分、额外反例 1 分。
第三轮:“下单必须读到刚改的价格,怎样改?”携带最小业务版本,从足够新且可信的事实源确认并条件提交,定义价格生效/确认点;缓存命中率和 TTL 不承担该裁决。评分:版本读取 1 分、提交边界 1 分。
闭卷验收
本页 6 道闭卷题收进了下方自测面板:先回忆,再展开参考答案与评分点,最后给三档自评。