4 最终一致性方案设计:消息队列异步刷新、定时补偿、版本号校验

缓存与数据库保持强一致代价高昂,多数业务场景接受秒级甚至分钟级最终一致。本章聚焦三种经典手段:消息队列异步刷新(MQ)、定时补偿任务(Scheduler)、版本号校验(CAS/Optimistic Lock)。三者组合可覆盖写入链路、修复链路、校验链路,形成完整的最终一致保障体系。

4.1 边界与分层

最终一致保障并非替换业务主流程,而是作为补偿层叠加在常规写-缓存旁路或写-缓存同步之上。边界定义如下:

维度包含不包含
数据范围允许秒级不精确的缓存数据(排行榜、计数、商品快照)账户余额、库存扣减等要求严格一致的数据
触发时机写库后异步发送消息;定时扫描不一致记录实时双写、分布式事务
安全等级容忍短暂不一致,但不可丢失更新不允许最终数据不一致

分层模型分为三层:

  • 接入层:业务代码发送 MQ 消息,或标记待补偿记录。
  • 异步处理层:消费 MQ 更新缓存;定时任务拉取对比并修复。
  • 校验层:通过版本号或时间戳在读取时校验并触发自修复。

4.2 组件职责

组件职责依赖
MQ Producer(业务侧)数据库写操作后发送增量变更消息(key+新值或增量操作)数据库中间件或业务拦截器
MQ Broker(RocketMQ / Kafka)高可靠存储、顺序投递、重试、死信消息队列集群
MQ Consumer(缓存刷新)消费消息,执行缓存更新(SET/DEL/INCR)Redis / Memcached
定时补偿 Job每隔 N 分钟扫描差异记录,批量修复缓存对比表(diff table)或日志
版本号存储(Redis / DB)保存数据版本号,用于 CAS 条件更新Redis (INCR/LUA) 或数据库乐观锁字段
监控告警检测 MQ 积压、补偿延迟、版本冲突率Prometheus / Grafana

4.3 数据流 / 控制流 / 信任链路

4.3.1 消息队列异步刷新流程

  1. 业务写入数据库(UserService.updateScore(userId, +10))。
  2. 业务拦截器(或 Canal)向 MQ 发送 {"key":"user:123:score","op":"incr","delta":10}。
  3. MQ Consumer 拉取消息,执行 INCR user:123:score 10。
  4. 消费成功则 ACK;失败则重试最多 3 次,超出进入死信队列告警。

4.3.2 定时补偿流程

  1. 定时任务每 5 分钟查询待补偿表(或抽样对比 DB 与缓存值)。
  2. 计算差异列表,按优先级批量回写缓存。
  3. 补偿完成后记录补偿日志,并更新补偿状态。

4.3.3 版本号校验流程(读取时自修复)

  1. 写入:DB 保存 version(int),缓存同时保存 version。
  2. 读取:先读缓存 {"value":"xxx","version":5};若版本号低于期望值(DB 最新),则直接回源 DB 并更新缓存。
  3. 更新:使用 CAS 命令(如 SET key value NX/XX + 版本条件)确保不会覆盖更新的版本。
信任链路决策: MQ 消息不依赖数据库事务 —— 允许写库成功但消息发送失败(do-after-commit 模式)。定时补偿作为兜底,消除因 MQ 丢失导致的永久不一致。版本号校验提供读取时的一致性自愈,三者形成闭环。

4.4 选型权衡

方案延迟可靠性复杂度适用场景
MQ 异步刷新秒级(取决于消费速率)高(消息持久化 + 重试)中(需 MQ 运维)高并发写、写后立即缓存刷新
定时补偿分钟级极高(全量/增量对比)低(定时任务 + 对比表)允许一定延迟,对最终一致要求极高
版本号校验读取时修复(数毫秒)中(依赖正确实现 CAS)高(缓存层需支持版本)读多写少、要求读取一致性
推荐组合: MQ 异步刷新为主链路,定时补偿为兜底链路,版本号校验用于敏感字段(如库存、价格)的读取时校验。避免所有缓存都加版本号,否则增加存储和读取开销。

4.5 风险点

  • 消息丢失: 数据库写成功但消息未发送(如系统崩溃)。
    应对:采用本地消息表 + 定时重推(或 Canal 监听 binlog)。
  • 重复消费: MQ 至少一次语义导致缓存重复更新。
    应对:消费幂等(如 key+时间戳去重)。
  • 版本号回退: 并发写入导致版本号被覆盖为旧值。
    应对:使用 Redis LUA 原子操作:if redis.call('GET', versionKey) == expected then ... end。
  • 补偿扫描性能: 全表对比消耗数据库 I/O。
    应对:只扫描标记字段(如 last_update_time > 上次扫描时间)或使用增量日志表。
注意: 版本号校验不能解决“写后立即读”的读旧问题,因为读取时版本号可能还未更新。此时需要结合 MQ 异步刷新 + 读时等待(如读取后快速二次校验)。

4.6 落地步骤

  1. 分析缓存失效模式: 识别哪些缓存允许最终一致,评估延迟容忍度(秒/分钟)。
  2. 引入 MQ 中间件: 部署 RocketMQ / Kafka,定义 topic 与 message 格式(key, op, delta/timestamp)。
  3. 实现 MQ 生产端: 在写库后通过 Spring @TransactionalEventListener(phase=AFTER_COMMIT)发送消息。
  4. 实现 MQ 消费端: 幂等消费,成功后更新缓存;失败重试并记录死信。
  5. 搭建定时补偿: 创建 diff 表(key, db_value, cache_value, status),定时 Job 对比并修复。
  6. 引入版本号校验: 在缓存 value 中嵌入 version 字段;读取时检查,发现落后则回源 DB 并更新。
  7. 添加监控: 监控 MQ 积压数量、补偿延迟、版本冲突次数,设置告警阈值。

4.7 验收标准

验收项通过条件
MQ 消息可靠性模拟写库成功后 MQ 发送失败,定时补偿在 5 分钟内修复不一致
补偿延迟95% 的不一致在 1 分钟内被修复(MQ 为主链路)
版本号校验准确率并发更新场景下,缓存中版本号永远 ≤ DB 版本号,且读取时若版本落后会自动回源
幂等性重复发送同一条消息,缓存值只变化一次
性能MQ 消费端单机 TPS ≥ 业务高峰期峰值的 1.5 倍;补偿 Job 扫描耗时 ≤ 1s/万条

4.8 核心架构图

最终一致性保障三层闭环架构 业务写入数据库 (INSERT/UPDATE) MQ 异步刷新 Consumer 更新 Redis 定时补偿 Job 对比 DB 与 Redis 修复 Redis 缓存 (value + version) 版本号校验 读取时 CAS 回源 闭环说明 MQ 异步刷新为主链路,定时补偿兜底,版本号校验在读取时自愈。三者互不阻塞,可独立部署。 监控点:MQ 积压、补偿延迟、版本冲突率。

4.9 关键配置示例

以下为 MQ 生产端与消费端的配置片段(非完整代码,仅展示关键参数):

# 生产端:Spring Boot + RocketMQ
rocketmq:
  producer:
    group: cache-sync-group
    sendMsgTimeout: 3000
    retryTimesWhenSendFailed: 3
  topic: cache_update_topic

# 消费端:消费幂等 key 生成规则
consumer:
  concurrency: 8
  maxReconsumeTimes: 3
  deadLetterTopic: %DLQ%cache-sync-group
# 定时补偿 Job 配置(Quartz)
compensation:
  cron: "0 */5 * * * ?"
  scanTable: cache_diff_record
  batchSize: 1000
  maxRetryPerRecord: 3
# Redis CAS 操作(LUA 脚本)
local current = redis.call('GET', KEYS[1])
if current and tonumber(current) >= ARGV[1] then
  return 0
end
redis.call('SET', KEYS[1], ARGV[2])
return 1
实践建议: 版本号不要使用时间戳(可能回退),推荐使用 Redis INCR 生成的单调递增序列;每张业务表或每个 key 单独维护版本号。