数据库与缓存一致性:提交、失效和旧值回填
商品标题已经在数据库中改成 new,Redis 里却又出现 old。造成这个结果的一种时序是:查询先读到旧标题,更新事务随后提交并删除缓存,查询最后才把旧标题保存进去。删除执行成功,仍然留下了过时条目。
处理这类问题,需要同时记录数据库版本、读取发生的位置和缓存写入时间。仅比较“先更新还是先删除”,覆盖不了已经在路上的查询。
读写路径与数据的新旧程度
Cache Aside 由应用协调两份数据
读取
→ GET 缓存
→ 命中:按允许的新旧程度返回
→ 未命中:查询数据库
→ 把查询结果放入缓存
→ 返回结果
更新
→ 在数据库事务中修改业务记录
→ 提交
→ 使相关缓存失效数据库保存业务记录,缓存保存可以重新生成的查询结果。应用负责连接两条路径;其他服务、批处理和人工修复如果也修改数据库,同样需要进入失效流程。模式介绍见 Cache-Aside。
Read Through 把未命中加载封装在缓存组件中,Write Through 将写源与更新缓存封装在同一个调用中,Write Behind 则异步把缓存中的变化写回来源。封装位置改变以后,仍然要处理两个存储间的失败;尤其 Write Behind 中尚未落库的变化可能是唯一副本,其恢复要求高于普通派生缓存。
具体项目先确定谁可以写业务数据、谁可以写缓存,以及缓存缺失后从哪里重建。一个 key 被多个服务用不同字段格式更新,比单纯的 TTL 长短更难维护。
不同读取要求采用不同策略
| 读取要求 | 典型操作 | 可以采用的处理 |
|---|---|---|
| 接受短期旧内容 | 商品描述、公开资料 | TTL 与提交后失效,明确允许的陈旧程度 |
| 当前用户更新后立即看见结果 | 修改昵称后的确认页 | 直接返回已提交结果,或携带最低版本读取 |
| 列表最终反映变化 | 搜索、推荐、聚合计数 | 异步更新,暴露处理进度或刷新条件 |
| 必须按当前记录作出决定 | 扣款、库存条件更新 | 在业务数据库事务中检查并修改 |
| 权限撤销后禁止继续操作 | 会话、授权决策 | 定义撤销传播和拒绝策略,不能随意旁路 |
“读自己的写入”可以只针对发起更新的调用者提供,而不要求所有列表同时刷新。更新接口返回 revision=12 后,该调用者后续要求至少版本 12;缓存只有 11 时,可以回源或等待到规定时间,再明确失败。来源本身若是异步副本,回源也要检查版本,不能机械地把一次数据库读取当成最新结果。
版本应来自业务提交所修改的记录或可靠日志顺序。不同应用服务器的本地时钟可能有偏差,时间戳相同也不表示内容相同;不要直接用机器时间替代每个业务对象的更新顺序。
TTL 从缓存写入时开始计时
假设数据库在 T1 更新,旧查询在 T2 才把结果放入缓存,设置 30 秒 TTL。条目到期时间与 T2 有关,不能据此承诺“数据库更新后最多 30 秒就没有旧值”。
T0:查询读到 revision=1
T1:数据库提交 revision=2
T2:旧查询回填 revision=1,设置 TTL=30s
T2 + 30s:该条目按这次过期设置到期旧值又被另一条延迟查询回填,或读取流程采用滑动续期时,结束时间还会改变。需要约束内容年龄时,可以保存源版本、取得快照的时间及适用期限,并限制过旧结果进入缓存;跨机器计算时间年龄还要处理时钟误差。
Redis 的 TTL 是键的服务端过期状态,更新和续期行为见 EXPIRE。过期负责让某次条目停止存活,版本控制负责决定某次写入是否足够新,两者解决的问题不同。
三种产生旧值的时序
先删缓存,提交前读到旧数据
读取者看见的是它的隔离级别允许读取的已提交数据。删除发生在事务提交前,会让缓存 miss 流量提前进入这个窗口。
实验使用 H2 的真实 JDBC 连接:写连接关闭自动提交,执行 UPDATE 后先不 commit;另一个连接查询得到旧记录,再由写连接提交。该结果针对工程配置下的 H2 行为,隔离机制说明见 H2 Features。不同数据库和隔离配置需要另行核对,不能把实验中的可见性替换成任意数据库的通用结论。
把删除放到成功提交之后,可以避开这条时序。它还会留下其他情况。
提交后删除,旧查询最后才回填
读取者 R 更新者 W
SELECT → 取得 revision=1
暂停回填
UPDATE revision=2
COMMIT
DEL 缓存
SET revision=1,重新建立条目这里的读取甚至可以在更新事务开始前就完成。应用计算、线程排队或网络等待,使回填晚于删除。
延迟双删尝试在稍后再次删除,以覆盖一部分晚到写入。但固定等待时间没有自动覆盖所有慢查询:长暂停、故障重试和读取副本的延迟都可能超过该时间。采用双删时,应把它当作带有延迟假设和可恢复任务的策略,不能把“延迟一秒”当作普遍正确的参数。
多个更新者直接写缓存也有顺序问题:版本 2 的响应晚到,可能覆盖已写入的版本 3。写入必须比较业务版本,而不是只比较网络到达顺序。
SQL 提交成功,删除被拒绝或没有完成
数据库提交和 Redis 删除分别有各自的返回结果。进程可以在两步之间退出,Redis 可以返回 NOPERM 或连接错误,异步客户端还可能在方法返回后才执行删除。
Spring 的 afterCommit 或 @TransactionalEventListener 可以把动作绑定到事务完成阶段。它们运行在应用进程中,进程退出后未完成的回调不会自行成为持久任务。事务事件的阶段和适用条件见 Transaction-bound Events。
缓存客户端超时还需要查询实际状态。服务端已经执行删除但回复缺失,与命令根本未发出,需要不同的解释。连接和回复的对照见 Redis 客户端运行链;Spring 默认异步清理与即时操作见 Spring Cache。
用版本和可靠失效限制旧值
分开保存缓存内容与最低可接受版本
一个只在 value 内保存版本的方案,在删除 value 后就失去了比较对象。为阻止已经取得旧数据的请求重新建立条目,可以额外保存版本水位:
ca11:...:{42}:floor
→ 2,已知不应接受低于 2 的版本
ca11:...:{42}:value
→ Hash:revision=2,title=new
→ 内容有 TTL失效动作先把 floor 推进到已提交版本,再删除低于该版本的缓存内容;回填动作同时比较 floor 和现存 value 的版本。相同业务版本必须表示相同内容,版本号不能被不同写入者重复用于不同结果。判断和写入放在同一次 Redis 脚本中,避免检查通过后另一个请求先改变版本。
回填过程的核心判断为:
local floor = tonumber(redis.call('GET', KEYS[1]) or '0')
local cached = tonumber(redis.call('HGET', KEYS[2], 'revision') or '0')
if incoming < floor or incoming < cached then
return 0
end
redis.call('HSET', KEYS[2], 'revision', ARGV[1], 'title', ARGV[2])
redis.call('PEXPIRE', KEYS[2], ttl)
return 1incoming、ttl 的参数验证在完整工程的脚本前部。示例限制版本为 0 到 9007199254740991 的整数,避免 Lua 数值比较超出精确整数范围。需要更大版本或复合日志位置时,应采用与格式匹配的比较方式,不能把任意 64 位序号直接转成浮点数。
KEYS[1]、KEYS[2] 都由调用者明确传入;业务字段通过 ARGV 传入,不拼成脚本源码。Redis 的 Lua 执行说明介绍了原子执行与键参数要求。脚本执行过程中其他命令不会插入这段判断与写入,但运行时错误也不会自动撤销此前已经完成的命令,键类型和输入格式必须受控。
Redis Cluster 中,多键脚本还要求键落在适用的同一槽位。示例以同一个 {42} hash tag 放置该对象的 floor 与 value;它并没有在实验中部署 Cluster,规则入口见 Cluster specification。
水位本身也有保存要求
floor 丢失以后,脚本只能把缺失值当作初始状态。若缓存内容也不存在,旧版本又可能通过比较。
floor=2,value 不存在
→ 回填 revision=1:拒绝
删除 floor
→ 回填 revision=1:接受所以水位不能按普通缓存的方式随意驱逐。可以从可靠数据库或日志恢复水位,在恢复期间拒绝不确定的回填,或者采用能隔离旧请求的新一代命名空间。若给水位设置 TTL,需要证明所有可能携带旧版本的请求、队列与重试都已结束;没有这个上限,就不能承诺到期后安全。
实验水位不设过期,仅用于演示,并由测试结束时清理。生产长期维护每个对象的水位有容量成本,需要定义回收、恢复和业务删除后的墓碑策略。把商品删除后又用同一个 ID 创建,也要决定版本是否连续、旧事件怎样被识别。
水位只能拒绝低于“已经知道的版本”的回填。数据库提交了版本 3,但失效事件仍未到达 Redis,floor 还为 2 时,版本 2 仍可能被接受。因此,对更新后立刻读取的要求,仍需最低版本、直接返回提交结果或指定读取来源等补充路径。
让失效记录能够在进程重启后继续处理
同一个数据库事务
├─ 更新 product,推进 revision
└─ 插入 outbox:对象 ID、revision、事件 ID
提交后
→ 发布器或 CDC 读取已提交 outbox
→ 消费者推进 floor 并删除旧内容
→ 记录处理位置
→ 失败可重新读取、重试和核对Outbox 把“还有一件需要处理的事”保存到数据库。发布可能重复、事件可能乱序,消费者仍需按事件 ID 或对象版本安全重放。事件积压期间读者可能继续看到旧缓存,监控需要包含最老未处理记录的年龄和当前处理位置。
Debezium Outbox Event Router介绍了相应记录与路由方式;完整数据库日志接入与消息消费操作见 Outbox 与 CDC。缓存实验测试失效消费者需要的版本规则,CDC 发布链需要独立部署和验证。
普通 Redis Pub/Sub 适合在线通知,断线期间消息不会替订阅者保存重放。把它用于本地缓存失效时,重连后应清空不可信的本地条目或重新同步;投递语义见 Redis Pub/sub。
失败后的读取与修复
收到删除失败时,至少保留受控的对象标识、提交版本、失败类别和可重试任务。对 NOPERM,应修正目标用户的权限;无限重试相同权限错误只会增加压力。对连接超时,先恢复连接并判断目标状态,再进行幂等失效。
重放旧失效事件时,只推进版本,不能用旧事件内的内容覆盖新缓存。消费者处理到 floor=3 后再收到 revision=2,应保留当前状态。纯 DEL 重放一般不会创建旧值,但会造成多余 miss;版本判断还能减少这种重复工作。
列表和聚合查询与单对象缓存不同。一笔订单改变状态,可能影响多个筛选列表和计数;只删除 order:42 不会自动更新它们。可选择有版本的查询命名空间、按依赖维护索引,或允许短期 TTL 重建。应把额外写放大与查询复杂度计算在内。
运行五个可重复的竞态与失败实验
环境和命令
下载 缓存实验工程。使用普通 Linux 用户、Bash、Docker、unzip,按 工程准备取得 PROJECT_DIR、LAB_DIR 并下载依赖,再按 Redis 网络配置启动隔离的 ca11-redis。
工程使用 Boot 4.1.1 管理的 JDBC、H2 与 Lettuce,Redis 为 8.10.1。H2 数据库在测试进程内创建;Redis 只在 ca11-lab internal 网络内使用,不发布宿主端口。无认证配置只适用于这个独立实验。
test "$(id -u)" -ne 0 || exit 1
test -f "$PROJECT_DIR/pom.xml" || exit 1
docker exec ca11-redis redis-cli PING
docker run --rm --name ca11-consistency-build \
--network ca11-lab --user "$(id -u):$(id -g)" \
-e MAVEN_CONFIG=/tmp/maven \
-e REDIS_URI=redis://ca11-redis:6379 \
-v "$PROJECT_DIR:/work" -v "$LAB_DIR/m2:/cache" -w /work \
maven:3.9.12-eclipse-temurin-25 \
mvn -B -ntp -o -Duser.home=/tmp -Dmaven.repo.local=/cache \
-Dtest=CacheConsistencyTest test预期 5 个测试全部通过。若 PING 失败,先检查容器与网络;离线依赖缺失则回到普通构建网络准备,不要把真实 Redis 测试跳过。
Java 17 对照使用 maven:3.9.12-eclipse-temurin-17,保持其他参数不变。测试操作的键都有随机 ca11:test 前缀;最后删除自己创建的键和临时 ACL 用户,不清空整个 Redis。
观察提交前删除与旧读晚到
deletedBeforeCommit=true readerSawRevision=1 finalDatabase=2 cache=old
committedRevision=2 deletedThenLateFill=old第一个结果通过两个实际 JDBC 连接复现。第二个测试用 CountDownLatch 固定顺序:读取线程先完成 SELECT,更新线程完成 SQL 提交和 Redis DEL,然后才允许读取线程 SET。
因此删除前后两个实验即使都成功执行 Redis DEL,也都能留下 old。排障时应查找删除之后还有谁写入,而不是一看到旧值就认定 DEL 没有执行。
检查版本拒绝与水位丢失
lateRevision1Accepted=false revision2Accepted=true olderInvalidationIgnored=true
watermarkLost=true oldRevisionAcceptedAgain=true第一行由 Redis 实际执行两段脚本:旧回填被拒绝,新回填成功,随后旧失效事件不降低水位。第二行删除测试水位,观察旧值再次被接受;这里仅改变测试元数据,未触发主从切换。
看到水位丢失后的结果,应优先处理水位恢复和请求隔离,不能继续把同一脚本称为绝对一致方案。
实际拒绝一次删除,再执行修复
deleteDenied=NOPERM committedDatabase=2 staleCache=old repaired=absent测试创建临时 ACL 用户,允许读取自己的前缀,却不授予 DEL。数据库先提交版本 2,再用该用户执行真实 DEL,Redis 返回 NOPERM;独立连接确认数据库为 2、缓存仍为 old。之后由有权的实验连接执行删除,验证条目为空。
这个反例把权限失败与业务提交分开观察。权限规则见 Redis ACL。生产中修复用户授权应走正常配置流程,不能把应用改用全权限账户作为默认处理。
根据观测决定下一步
| 现象 | 需要补的观测 | 处理方向 |
|---|---|---|
| 删除后又出现旧值 | 之后的 SET、源版本与读开始时间 | 比较版本,限制晚到回填 |
| 数据库新、缓存旧且没有后续 SET | DEL 的返回、异常与异步完成 | 恢复失效任务,检查客户端完成约定 |
| 更新接口新、下一次查询旧 | 是否读副本、最低版本是否传递 | 指定合适来源,校验最低版本 |
| 水位下降或消失 | 驱逐、过期、恢复和写入权限 | 恢复元数据,隔离旧一代请求 |
| 单对象已新、列表仍旧 | 列表键依赖与事件覆盖 | 更新相关查询版本或按约定重建 |
| 重试越多数据库越忙 | 失效积压、miss 与回源并发 | 限制回源预算,再逐步恢复 |
修复后,用相同对象版本再次验证并发读取、重复事件和乱序事件。缓存内容与数据库最终相同只是其中一个结果,还应确认过时读取在允许的时间和访问范围内得到处理。
