跳转至

深入专题:数据库、缓存与消息一致性

这篇解决一个经常被“用了 Redis、用了 MQ”掩盖的问题:

一份业务状态同时出现在数据库、缓存和消息里时,哪一份是真的?某一步失败后怎样收敛?

先建立三个角色

数据库:业务事实

例如:

  • 工单是否存在;
  • 命令当前状态;
  • 用户是否拥有设备;
  • Agent 某个副作用是否完成。

Redis:查询视图或短期协调

例如:

  • 当前在线状态;
  • 一页设备的热数据;
  • 限流计数;
  • 短期锁;
  • 运行中任务视图。

Redis 也可以持久化,但在架构判断中仍要明确它是否是事实源、可重建视图,还是两者之一。

MQ:变化的传递

消息表达:

某个事实已经发生,其他组件可以据此更新自己的状态。

消息本身不一定是最终业务事实,消费者也可能晚到、重复或失败。

典型问题:更新数据库和缓存

用户修改设备名称:

数据库:name = "Pump A"
Redis:device:42:name = "Old Name"

怎样避免不一致?

方案一:先写缓存,再写数据库

写 Redis 成功
→ 写数据库失败

缓存展示了不存在的新事实,危险。

方案二:先写数据库,再删缓存

数据库提交
→ 删除缓存
→ 下次查询从数据库加载

这是常见 Cache-Aside。

仍有失败窗口:

数据库提交成功
→ 进程崩溃
→ 缓存没有删除

可以接受短期旧数据时,使用:

  • 缓存 TTL;
  • 删除重试;
  • CDC/事件修复;
  • 版本校验。

如果业务不能接受旧数据,就不应把关键权限完全依赖弱一致缓存。

并发下的旧值回填

两个请求:

请求 A:缓存未命中,读取数据库旧值 V1
请求 B:数据库更新为 V2,删除缓存
请求 A:把刚读到的 V1 写回缓存

最终缓存又变旧。

改进思路:

  • 较短 TTL;
  • 带版本写缓存;
  • 延迟双删;
  • 串行化关键更新;
  • 订阅数据库变更;
  • 对真正关键判断直接查事实源。

没有一个通用方案,先确定旧数据可容忍多久。

数据库和 MQ 的双写问题

创建工单后发布事件:

BEGIN
INSERT ticket
COMMIT
publish TicketCreated

失败窗口:

数据库提交成功
→ 进程崩溃
→ 消息未发布

如果先发消息:

消息发布成功
→ 数据库回滚

消费者看到了不存在的工单。

Transactional Outbox

在同一个数据库事务中:

BEGIN;

INSERT INTO tickets (...);

INSERT INTO outbox_events (
  event_id,
  aggregate_type,
  aggregate_id,
  event_type,
  payload,
  status
) VALUES (...);

COMMIT;

单独 Publisher:

扫描未发送 outbox
→ 发布 MQ
→ 标记已发送

数据库事务保证“工单和待发送事件同时存在或同时不存在”。

是否完全没有重复

没有。

Publisher 可能:

消息发布成功
→ 标记 sent 前崩溃
→ 重启后再次发布

因此消费者仍需根据 event_id 幂等。

Outbox 解决“不丢变化”,幂等消费者解决“重复变化”。

消费者事务

收到 TicketCreated:

检查 event_id
→ 写业务数据
→ 记录 event_id 已处理
→ 提交数据库事务
→ Ack

业务写入和 processed_event 最好在同一数据库事务中。

如果提交后 Ack 丢失,消息重投,event_id 告诉消费者已经处理过。

在线状态为什么又不同

设备在线状态是快速变化、允许短暂误差、可以从心跳重建的数据。

它可能采用:

  • Redis 查询视图;
  • Broker 连接事件;
  • last_seen;
  • 定时校准;
  • 数据库只保存迁移历史。

不需要每个心跳都使用强事务写数据库。设计一致性等级要匹配业务:

支付:强正确性,不能重复扣款
在线灯:允许秒级延迟,可以最终一致
设备控制:命令不能重复副作用,需要回执

一致性不是只有“强”和“弱”

需要明确:

  • 读到旧值最多允许多久?
  • 两个用户是否必须同时看到相同状态?
  • 重复是否可以容忍?
  • 丢失是否可以重建?
  • 顺序是否只需同一设备内保证?
  • 冲突由谁解决?

一个完整例子:设备离线后创建告警

1. 超时 Worker 判断设备离线
2. 数据库状态迁移 online → offline
3. 同一事务写 Outbox: DeviceOffline
4. Publisher 发布消息
5. 告警服务消费
6. 用 device_id + rule_id + offline_epoch 去重
7. 创建或更新告警
8. Ack
9. 通知服务异步发送

分别处理:

  • Worker 重复执行:状态条件和版本控制;
  • Outbox 重复发布:event_id;
  • 告警重复消费:业务去重键;
  • 通知失败:独立重试;
  • Redis 在线集合漂移:状态事实校准。

深度自测

  1. Cache-Aside 为什么通常先写数据库再删缓存?
  2. 这样做为什么仍可能短暂不一致?
  3. Outbox 解决什么,没有解决什么?
  4. 消费者为什么要把业务写入和 processed_event 放在同一事务?
  5. 在线状态与支付为什么不能使用同一种一致性要求?
  6. Redis 是不是一定不能成为事实源?
参考答案要点
  1. 避免缓存先出现尚未提交的业务事实。
  2. 删除缓存可能失败,并发读可能把旧值重新写入。
  3. Outbox 避免数据库提交后事件丢失,但发布仍可能重复。
  4. 防止业务成功但去重记录失败,导致下次重复执行。
  5. 在线状态可重建且允许短暂延迟;支付副作用不可随意重复。
  6. 不是。关键是明确持久化、恢复、复制和业务语义,而不是按产品名绝对判断。

完成标准

  • 能区分事实源、查询视图和变化事件
  • 能画出 Cache-Aside 的并发失败窗口
  • 能解释 Transactional Outbox
  • 能设计幂等消费者事务
  • 能为支付、在线状态和设备控制分别定义一致性要求