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()

三个必须做对的细节:

  1. 重试要有上限:retry_count 超过阈值必须停止并告警,否则一条坏消息会永久占用资源(毒丸消息);
  2. 退避策略:用指数退避而不是固定间隔,避免下游抖动时被打成雪崩;
  3. 接收方必须幂等:接收侧用第 05 章的业务幂等键去重。

三、幂等的实现优先级

方案 可靠性 说明
数据库唯一索引 最高 让数据库把关,应用层写错也拦得住(推荐作为底线)
Redis SET NX 去重 高 注意设置与业务窗口匹配的过期时间
应用层先查再写 低 存在检查与写入之间的并发窗口,仅在低并发场景凑合

经验:唯一索引兜底 + Redis 前置过滤。前者保证正确性,后者提升性能。

四、分布式对齐:不要让本地消息表变成隐藏的定时炸弹

落地时的几个现实问题:

五、与缓存一致性的关系

更新数据库后如何同步缓存,是同一类问题的简化版:二者无法在同一个事务里完成,所以必须接受短暂不一致,并用策略收敛。具体做法见《数据库与缓存调优》第 04 章。


动手:可观察结果

产出 判断标准
一张 LocalMessage 表 + 投递任务 停掉下游服务,订单仍可创建;恢复后自动补偿成功
幂等验证 同一条消息重复投递三次,业务只生效一次(建议用唯一索引验证)
毒丸消息处理 一条永远失败的消息在达到重试上限后停止并产生告警
监控面板 能看到待发、失败、重试超限的消息数

完成标志:关掉下游服务 10 分钟再启动,期间创建的订单全部被正确补偿,无丢失、无重复。

故障注入

注入方式 观察
把消息登记放在事务之外 订单创建成功但消息丢失,且无任何报错
去掉重试上限 毒丸消息是否永久反复重试,占满投递能力
收到消息先 ACK 再处理 处理失败后消息是否永久丢失
幂等只靠"先查再写" 并发下是否出现重复业务
不清历史消息 一周后该表扫描耗时变化

自测题

  1. 本地消息表为什么必须和业务数据在同一个事务里?
  2. 什么叫毒丸消息?没有重试上限会怎样?
  3. 为什么推荐的幂等实现是用数据库唯一索引而不是应用层查询?
  4. 最终一致方案里,哪个环节最需要一个可被观测的指标?为什么?
  5. 什么情况下你宁可用分布式事务框架而不用本地消息表?

进入 keel 阅读