KEEL · 龙骨 · A CURRICULUM FOR THE AI ERA
06 · 数据一致性:本地消息表、重试与幂等 — keel 龙骨
这一章回答:跨服务、跨库的操作不能用单库事务时,怎么保证数据最终是对的。
这一章回答:跨服务、跨库的操作不能用单库事务时,怎么保证数据最终是对的。
一旦写操作跨越两个服务或两个存储,本地事务就失效了。此时要在强一致(付出性能与可用性代价)和最终一致(付出短暂不一致的代价)之间做明确选择。绝大多数业务场景应当选后者,并用工程手段把不一致窗口收敛到可控范围。
一、四种手段与适用场景
| 手段 | 一致性 | 适用场景 | 代价 |
|---|---|---|---|
| 本地事务 | 强一致 | 单库内的多表更新 | 无(首选) |
| 分布式事务(2PC/Seata 等) | 强一致 | 跨库且必须同时成功/失败(金融机构核心账务) | 吞吐低、耦合重、运维复杂 |
| 本地消息表 + 重试 | 最终一致 | 跨服务的业务协同(下单→支付→发货) | 需要幂等、有延迟 |
| 事务消息 / Outbox + CDC | 最终一致 | 对可靠性要求更高的跨服务事件 | 引入额外组件 |
工程上的默认建议:能本地事务就本地事务,跨服务用本地消息表,只有极少数场景才上分布式事务框架。
二、本地消息表:最实用的最终一致方案
核心思想很简单:把"发消息"这件事和业务数据放进同一个本地事务里,用一张表保证它至少被记录一次,再由后台任务保证它被投递出去。
async def create_order(session, payload):
async with session.begin(): # 一个本地事务
order = Order(**payload)
session.add(order)
session.add(LocalMessage( # 在同一个事务里登记待发消息
business_id=order.id,
message_type="order.created",
payload=json.dumps({"order_id": order.id}),
status="PENDING",
retry_count=0,
))
# 事务提交后,订单和"待发消息"要么都在,要么都不在
后台任务周期性扫描 PENDING 的消息并投递:
msgs = await session.execute(
select(LocalMessage).where(
LocalMessage.status == "PENDING",
LocalMessage.retry_count < 3, # 超过次数转人工/告警
).limit(100)
)
for msg in msgs.scalars():
try:
await broker.publish(msg.routing_key, msg.payload)
msg.status = "SENT"
except Exception:
msg.retry_count += 1 # 指数退避,下一轮再试
await session.commit()
三个必须做对的细节:
- 重试要有上限:
retry_count超过阈值必须停止并告警,否则一条坏消息会永久占用资源(毒丸消息); - 退避策略:用指数退避而不是固定间隔,避免下游抖动时被打成雪崩;
- 接收方必须幂等:接收侧用第 05 章的业务幂等键去重。
三、幂等的实现优先级
| 方案 | 可靠性 | 说明 |
|---|---|---|
| 数据库唯一索引 | 最高 | 让数据库把关,应用层写错也拦得住(推荐作为底线) |
Redis SET NX 去重 |
高 | 注意设置与业务窗口匹配的过期时间 |
| 应用层先查再写 | 低 | 存在检查与写入之间的并发窗口,仅在低并发场景凑合 |
经验:唯一索引兜底 + Redis 前置过滤。前者保证正确性,后者提升性能。
四、分布式对齐:不要让本地消息表变成隐藏的定时炸弹
落地时的几个现实问题:
- 表会长大:
SENT的消息要定期归档清理,否则扫描变慢; - 扫描要加锁或用抢占式领取,避免多个实例重复投递;
- 要有可视化:待发、失败、超过重试上限的消息数量应当进监控面板——这条最重要,因为最终一致的失败往往是静默的;
- 对账兜底:定时任务对长期未完成状态做校准,这是最后的保险。
五、与缓存一致性的关系
更新数据库后如何同步缓存,是同一类问题的简化版:二者无法在同一个事务里完成,所以必须接受短暂不一致,并用策略收敛。具体做法见《数据库与缓存调优》第 04 章。
动手:可观察结果
| 产出 | 判断标准 |
|---|---|
一张 LocalMessage 表 + 投递任务 |
停掉下游服务,订单仍可创建;恢复后自动补偿成功 |
| 幂等验证 | 同一条消息重复投递三次,业务只生效一次(建议用唯一索引验证) |
| 毒丸消息处理 | 一条永远失败的消息在达到重试上限后停止并产生告警 |
| 监控面板 | 能看到待发、失败、重试超限的消息数 |
完成标志:关掉下游服务 10 分钟再启动,期间创建的订单全部被正确补偿,无丢失、无重复。
故障注入
| 注入方式 | 观察 |
|---|---|
| 把消息登记放在事务之外 | 订单创建成功但消息丢失,且无任何报错 |
| 去掉重试上限 | 毒丸消息是否永久反复重试,占满投递能力 |
| 收到消息先 ACK 再处理 | 处理失败后消息是否永久丢失 |
| 幂等只靠"先查再写" | 并发下是否出现重复业务 |
| 不清历史消息 | 一周后该表扫描耗时变化 |
自测题
- 本地消息表为什么必须和业务数据在同一个事务里?
- 什么叫毒丸消息?没有重试上限会怎样?
- 为什么推荐的幂等实现是用数据库唯一索引而不是应用层查询?
- 最终一致方案里,哪个环节最需要一个可被观测的指标?为什么?
- 什么情况下你宁可用分布式事务框架而不用本地消息表?