从一次入金到一次扣账:用本地消息构建可恢复的最终一致性

2 阅读23分钟

从一次入金到一次扣账:用本地消息构建可恢复的最终一致性

在资金类系统中,一笔“用户入金”往往会跨越三类边界:本服务要更新入金单状态;要调用第三方支付渠道;支付成功后还要通过 Kafka 通知账务服务扣账或入账。这三个动作分属不同的数据库、网络连接和故障域,不能也不应该试图塞进一个跨服务事务。

真正的问题并不是“调用失败了要不要重试”,而是:当超时或进程崩溃发生时,我们无法判断第三方究竟没有收到请求,还是已经完成了支付、只是响应丢了。如果直接重试,可能重复支付;如果不重试,可能漏单。随后,已经成功的支付状态与 Kafka 事件之间也有同样的空窗:数据库提交成功但消息还没发出,账务服务就永远不会收到扣账请求。

本文介绍一种可复用的本地消息(Local Message)方案。它以 Outbox 为可靠落点,以幂等为最终保险,以租约和乐观锁支持水平扩展。它不追求虚假的“端到端 exactly once”,而是在任意节点故障后都能恢复,并让重复投递不重复生效。

1. 先定义问题边界

假设入金流程如下:

  1. 用户提交入金,入金服务创建或更新入金单。
  2. 入金服务调用第三方支付渠道发起支付。
  3. 支付明确成功后,入金服务发布 deposit.paidledger.debit.requested 事件到 Kafka;账务服务消费事件并完成扣账/记账。

这不是一个可以由两阶段提交解决的问题。第三方支付通常不参与我们的数据库事务,Kafka 事务也不能把第三方 API 包进去。即使把数据库和 Kafka 放进同一个事务,支付请求本身仍有“请求已到达但响应未知”的窗口。

因此,系统的目标应调整为:

  • 本地业务状态和“后续必须执行的动作”原子落库;
  • 对外调用和消息投递可以至少一次发生;
  • 每个副作用的接收方都依据稳定幂等键保证至多生效一次;
  • 失败可见、可重试、可人工处理,并能通过对账发现极端遗漏。

这就是最终一致性:不是所有系统在同一时刻一致,而是每个中间状态都可解释、可恢复,并最终收敛。

2. 推荐的入金状态机

不要把“支付已调用”和“支付已成功”混为一个状态。一个简化但足够实用的入金单状态机如下:

CREATED
  │  同一数据库事务:写入入金单 + 本地消息 initiate-payment
  ▼
PAYMENT_PENDING
  │  支付渠道明确成功,或查询确认成功
  │  同一数据库事务:更新入金单 + 写入 Kafka 出站消息
  ▼
PAYMENT_SUCCEEDED
  │  账务服务幂等消费 Kafka 事件
  ▼
LEDGER_APPLIED

PAYMENT_PENDING ── 明确业务拒绝 ──> PAYMENT_FAILED
PAYMENT_PENDING ── 多次未知/基础设施失败 ──> PAYMENT_MANUAL_REVIEW

其中 PAYMENT_PENDING 很重要:它表示“本地已经承诺要完成支付,但支付结果尚未被确认”,而不是“支付失败”。遇到网络超时,不能贸然把它置为失败;应以同一个渠道订单号查询支付状态,或使用同一个幂等键安全地重放请求。

sequenceDiagram
    participant U as 用户
    participant D as 入金服务
    participant DB as 入金数据库/Outbox
    participant P as 第三方支付
    participant K as Kafka
    participant L as 账务服务

    U->>D: 提交入金
    D->>DB: Tx: 入金单 PAYMENT_PENDING + initiate-payment 消息
    D->>P: 发起支付(渠道订单号/幂等键)
    alt 成功或查询确认成功
        D->>DB: Tx: 入金单 PAYMENT_SUCCEEDED + deposit.paid 出站消息
        D->>K: Relay 发布出站消息
        K->>L: deposit.paid
        L->>L: 幂等记账
    else 超时或结果未知
        D->>DB: 保留本地消息,后台查询/重试
end

2.1 端到端执行拓扑

下面的图把“本地事务保证意图不丢”和“跨边界至少一次、下游幂等”的分工放在同一条链路中。实线代表同步执行或投递,虚线代表状态回写、查询或人工干预。

flowchart LR
    U["用户提交入金"] --> A["入金服务"]

    subgraph D["入金服务数据库(同一事务)"]
        O["入金单:PAYMENT_PENDING"]
        LM1["Local Message:payment.initiate"]
        O --> LM1
    end

    A -->|Tx: 写入入金单 + SaveTx| O
    LM1 -->|ClaimDue: 租约领取| W["Local Message Worker"]
    W -->|同一渠道订单号 / 幂等键| P["第三方支付"]
    P --> Q["超时或结果未知:查单"]
    Q --> W
    W -->|明确成功| S["支付确认处理器"]

    subgraph D2["入金服务数据库(同一事务)"]
        O2["入金单:PAYMENT_SUCCEEDED"]
        OB["Outbox Event:deposit.paid"]
        O2 --> OB
    end

    S -->|Tx: 更新状态 + SaveTx| O2
    OB -->|Relay 至少一次发布| K[("Kafka")]
    K -->|可能重复投递| L["账务服务 Consumer"]

    subgraph LDB["账务服务数据库(本地事务)"]
        I["Inbox:event_id 去重"]
        G["账务分录 / 扣账"]
        I --> G
    end

    L -->|Tx: 去重 + 记账| I
    W -->|永久错误或超过重试上限| DLQ["Dead Letter"]
    DLQ -->|人工修复后 RequeueDead| LM1

3. Local Message、Outbox 与“eBay 方案”到底是什么

中文技术语境中的“本地消息表”通常指一种由 eBay 大规模实践而广泛传播的最终一致性思路:每个服务先在自己的本地事务中记录业务状态和待发送/待执行消息,再以异步、可重试的方式推进下游。它经常与 BASE、可靠消息最终一致性放在一起讨论。

从可公开核验的模式名称看,今天更精确的叫法是 Transactional Outbox(事务性 Outbox):将业务变更和待发布消息写入同一笔本地事务,再由独立 Relay 投递到消息中间件。它保证的是“业务提交”和“待投递意图”不分离;Relay 对外提供的是至少一次投递,而不是“恰好一次发送”。Relay 可能在发布后、记录发布结果前崩溃,重启后会再次发布,因此消费者必须幂等。Transactional Outbox pattern

本文使用的 Local Message 是在 Outbox 之上更宽的一层抽象:一条持久化票据不仅可以代表“待发布 Kafka 事件”,也可以代表“待调用支付渠道”“待查单”“待执行补偿”。两者的关系如下:

概念要解决的问题执行者典型例子
Outbox本地提交后,待投递事件意图不能丢Message Relay / CDC Connectordeposit.paid 至少一次发布到 Kafka
Local Message一个外部副作用失败或结果未知后,不能丢失后续动作Retry worker / Handler调支付、查支付结果、调用下游 RPC
Inbox / 消费去重重复事件不能重复生效消费服务本地事务同一 event_id 只能生成一笔账务分录

所以不能把 Local Message 误解为“数据库当 MQ”。它的职责是持久化少量、关键、可恢复的业务意图;高吞吐事件流仍应由 Kafka 承担。它也不是分布式事务的替代品:跨边界的不确定性必须显式留在状态机里。Pat Helland 在 2007 年对无分布式事务系统的讨论也强调,这种不确定性最终由业务工作流承接,而不是由全局锁承接。Life beyond Distributed Transactions

3.1 2PC、TCC 与 Local Message:不是同一层面的方案

它们都处理跨边界一致性,但实现假设和业务代价完全不同。把它们当作互相替代的“分布式事务方案”,往往会选错。

方案核心机制对参与者的要求一致性与代价适合什么场景
2PC / XA协调器驱动 Prepare、Commit/Rollback;参与资源持锁等待决议所有资源管理器都支持 XA/2PC,且网络和协调器可用接近强原子性,但锁持有、阻塞、可用性和吞吐成本高少量内部、同质、确实支持 XA 的数据库资源;通常不用于微服务主链路
TCCTry 预留资源,Confirm 确认,Cancel 释放/补偿每个参与服务都必须实现三套业务接口和状态机业务级两阶段协调;仍需幂等、防悬挂、空回滚与超时补偿需要先锁定额度、库存、席位等资源,且参与方由团队可控
Local Message / Outbox本地事务记录状态和待执行意图;后台至少一次投递/重试只要求本地库事务;下游提供幂等、最好可查询状态最终一致;不持有跨服务锁,吞吐和可用性更好MQ 事件、HTTP/RPC、第三方支付、跨团队/跨组织依赖

2PC 的原子性边界只能覆盖真正加入协调协议的资源。第三方支付 HTTP API、普通 Kafka 消费业务和绝大多数 SaaS 都不属于这个边界;把它们硬塞进 2PC 并不能解决“请求已到但响应丢失”。

TCC 也不是自动化的事务魔法。以扣款为例,Try 可能是“冻结可用余额”,Confirm 是“确认扣款”,Cancel 是“解冻”。每一步都会因超时被重复调用,必须具备幂等;Cancel 还可能先于 Try 到达(空回滚),Try 也必须识别已经 Cancel 的业务(防悬挂)。对一个可控的账务/库存系统,TCC 很合适;但第三方支付渠道若不提供冻结、确认、撤销这一组语义,就无法凭空接入 TCC。

Local Message 接受中间状态存在:入金服务处于 PAYMENT_PENDING 时,不锁住账务服务,也不等待全局协调器;它持续查单或以同一幂等键重试,确认后再发布账务事件。它牺牲同步原子性,换取跨异构系统的可恢复性。

可以用下面的顺序选择:

  1. 动作是否只涉及同一个数据库?若是,直接本地事务,不引入分布式方案。
  2. 所有参与方是否可控,并且业务确实需要先预留资源、再统一确认/取消?若是,评估 TCC。
  3. 是否有少量同质 XA 资源、且强原子性收益显著大于可用性和锁成本?极少数情况下评估 2PC。
  4. 只要任一方是 Kafka、HTTP/RPC、第三方支付或跨组织系统,就默认采用 Local Message / Outbox,加上下游幂等、状态查询、补偿和对账。

在本文的入金链路中,第三方支付不在我们控制的事务域内,Kafka 与账务服务也不应被入金服务持锁等待。因此,核心选择是 Local Message / Outbox;账务服务内部若另有“冻结—确认—解冻”的需求,可以在其自身领域内使用 TCC,两者可以组合,但不应混为一层。

4. 本地消息不是普通的“失败重试”

本地消息是一张持久化的执行票据。它至少包含:业务类型、稳定幂等键、重试所需的 payload、状态、重试次数、下次执行时间、租约版本以及最后错误。业务代码在需要保证“此动作最终必须发生”时,写入这张票据;后台执行器领取到票据后调用对应处理器,并依据结果回写状态。

这里有两种落库模式:

模式票据如何写入适用场景主要风险
独立重试库业务事务提交后,另行写入票据通知、缓存刷新、可对账补偿的低风险动作业务已提交但进程在写票据前崩溃,会丢失重试意图
Outbox 同库模式业务状态和票据在同一数据库事务提交入金、账务、订单等不可丢的核心流程票据表和业务库共享故障域,需要做好库容量规划

入金场景应使用 Outbox。同一个本地事务中,创建/更新入金单并写入 initiate-payment 票据;事务提交成功,二者必定同时存在。支付成功后,再用同样的方式将状态变更和 Kafka 出站事件放进同一事务。这样,数据库提交与“必须尝试投递 Kafka”的意图之间没有空窗;Relay 最终会至少一次投递它,但可能重复投递。

需要强调:Outbox 只保证“本地状态 + 待执行意图”原子化,不保证第三方支付本身原子化。第三方副作用的正确性仍依赖渠道侧幂等和状态查询。

5. Outbox 表如何设计

表设计要同时服务于三件事:可靠保存意图、供多副本安全领取、保留足够的排障和审计信息。若一个服务同时需要重试 RPC/支付和投递领域事件,可以使用一张服务私有表 local_message_<service_name>,通过 kindbiz_type 区分用途;也可以将高频事件 Outbox 与低频动作重试表物理拆分。后者在流量差异非常大时更容易独立扩容。

下面是一份 MySQL 8 的单表参考 DDL。时间统一使用 UTC,payload 中不得放完整银行卡号、密钥或其他敏感原文。

CREATE TABLE local_message_deposit (
  id              BIGINT UNSIGNED NOT NULL AUTO_INCREMENT,
  kind            TINYINT NOT NULL,       -- 1=ACTION,2=EVENT
  biz_type        VARCHAR(64) NOT NULL,   -- 如 PAYMENT_INITIATE / DEPOSIT_PAID
  idem_key        VARCHAR(128) NOT NULL,  -- deposit_id、渠道订单号或 event_id
  aggregate_id    VARCHAR(128) NOT NULL,  -- 保序键,例如 deposit_id
  destination     VARCHAR(128) NOT NULL,  -- handler 名、topic 或下游标识
  payload         BLOB NOT NULL,
  headers         JSON NULL,
  status          TINYINT NOT NULL DEFAULT 0, -- 0=PENDING,1=SUCCEEDED,2=DEAD,3=CANCELED
  retry_count     INT UNSIGNED NOT NULL DEFAULT 0,
  max_retry       INT UNSIGNED NOT NULL DEFAULT 8,
  next_retry_at   DATETIME(3) NOT NULL,
  lease_until     DATETIME(3) NULL,
  version         BIGINT UNSIGNED NOT NULL DEFAULT 0,
  last_error      VARCHAR(1024) NOT NULL DEFAULT '',
  created_at      DATETIME(3) NOT NULL,
  updated_at      DATETIME(3) NOT NULL,
  PRIMARY KEY (id),
  UNIQUE KEY uk_biz_idem (biz_type, idem_key),
  KEY idx_claim (biz_type, status, next_retry_at),
  KEY idx_aggregate (aggregate_id, id)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;

字段中最容易被遗漏的是以下几项:

  • idem_key:幂等键,而不是随机的消息 UUID。它必须能表达“同一个业务副作用”的身份;可另有 event_id 作为 Kafka 消费去重键。
  • aggregate_id:若同一入金单的事件必须有序,应将其作为 Kafka key,并在应用层约束同一聚合的状态推进顺序。跨聚合的全局顺序既昂贵也没有业务意义。
  • versionlease_untilnext_retry_at:前者防止旧 worker 覆盖新状态,后两者让崩溃 worker 的任务自动重新可见。
  • last_error:只保留截断、脱敏后的错误摘要;完整堆栈应去日志平台,以免表膨胀和敏感信息泄漏。

一个常用的状态流转是 PENDING → SUCCEEDEDPENDING → DEADPENDING → CANCELED。领取不必新增 PROCESSING 状态:保留 PENDING,只将 lease_untilnext_retry_at 推到未来即可。这样 worker 崩溃后无需额外恢复脚本;租约到期的记录自然再次满足扫描条件。

uk_biz_idem 使同一动作的重复保存变成无害操作。idx_claim 是热索引,应以 biz_type, status, next_retry_at 的顺序支持按业务类型领取到期任务。死信后台若需要大量翻页,可额外按需加 (biz_type, status, id),不建议一开始就堆很多索引。

6. 幂等:最终正确性的最后一道防线

任何可靠投递机制都会产生重复。比如执行节点在支付成功后、回写本地消息成功前宕机;租约到期后其他节点会重新领取这张票据。系统必须将重复视为正常路径,而非异常。

建议为每个边界定义稳定、业务可追溯的幂等键:

  • 调第三方支付:deposit_id 或渠道订单号。每次重试使用同一个值,并透传给支付渠道。
  • 发布 Kafka:event_id,通常直接使用出站消息主键或业务事件 ID。
  • 账务消费:event_iddeposit_id。账务服务在自己的事务中写入消费去重记录和账务分录。

处理器必须先加载当前业务状态,确认动作是否已经完成。例如重试支付前先查询入金单:若它已是 PAYMENT_SUCCEEDED,直接返回成功;若支付渠道提供查询接口,优先按渠道订单号查询。只有确认未支付时,才以相同幂等键再次发起请求。

这是一条非常实用的原则:本地消息解决“我还要不要再尝试”,幂等和状态查询解决“再尝试会不会重复生效”。

7. 一个通用的执行模型

本地消息组件可以以 SDK 形式嵌入业务服务,暴露四类能力:

  • SaveTx:在业务事务内保存票据,利用 (biz_type, idem_key) 唯一键去重;
  • Register:为某个业务类型注册处理器及重试参数;
  • 后台扫描器:领取到期票据并分发执行;
  • 运维接口:暂停、恢复、取消、重投死信、查询积压。

典型处理器不应该只做一次 RPC 调用,而要把业务判断写完整:

func retryInitiatePayment(ctx context.Context, msg *LocalMessage) error {
    input := decodeOrPermanent(msg.Payload)

    deposit := repository.Get(input.DepositID)
    if deposit.Status == PaymentSucceeded {
        return nil // 之前已完成,幂等成功
    }

    result, err := paymentGateway.QueryOrPay(ctx, input.ChannelOrderID, input.Request)
    if err != nil {
        return err // 网络/未知错误,由策略退避重试
    }
    if result.Rejected {
        repository.MarkPaymentFailed(input.DepositID)
        return Permanent(result.Err) // 明确业务失败,不再盲目重试
    }

    return transaction(func(tx Tx) error {
        repository.MarkPaymentSucceeded(tx, input.DepositID, result.Reference)
        return outbox.SaveTx(tx, "deposit-paid", input.DepositID, eventPayload)
    })
}

上例中的 QueryOrPay 是刻意保守的抽象:对于结果未知的支付请求,优先查询;只有渠道明确支持幂等重放时才重放。不能把所有异常都当成“再调一次就好”。

8. 多副本下如何安全领取任务

扫描器在每个服务副本中运行。若所有副本都直接查询“到期消息”并执行,同一票据就会被多个节点同时处理。一个通用做法是把领取设计成短事务:

BEGIN;
SELECT id
 FROM local_message
 WHERE biz_type = ?
   AND status = 0                 -- PENDING
   AND next_retry_at <= NOW(3)
 ORDER BY next_retry_at
 LIMIT ?
 FOR UPDATE SKIP LOCKED;

UPDATE local_message
   SET lease_until = NOW(3) + INTERVAL ? MICROSECOND,
       next_retry_at = NOW(3) + INTERVAL ? MICROSECOND,
       version = version + 1
 WHERE id IN (...);
COMMIT;

SKIP LOCKED 让多个副本跳过彼此正在领取的行,避免集中式分布式锁。更新后的 next_retry_at 同时充当租约截止时间:节点崩溃不需要主动释放,租约自然过期后其他副本可以接手。

处理结束时必须带着领取时的 version 做 CAS 回写:

UPDATE local_message
   SET status = 1, version = version + 1 -- SUCCEEDED
 WHERE id = ? AND version = ? AND status = 0;

如果影响行数为零,说明票据已被取消或被其他节点重新领取;旧执行者不得覆盖新状态。即便租约、锁和 CAS 都具备,极端慢调用仍可能导致重复执行,所以不能删除下游幂等这一层。

领取数量也要和可立即执行的并发槽位绑定。一次领取一千条、再让它们在进程队列中等待,会使后面的票据在真正执行前就租约过期。正确做法是:只领取当前空闲 worker 数量以内的消息,领取后立即执行;租约长度至少覆盖“处理器超时上限 + 状态回写余量”。

9. Kafka 在方案中的位置

Kafka 是跨服务传播已确认业务事实的通道,不应承担入金服务内部的重试调度职责。

支付确认后,在同一数据库事务里执行两件事:将入金单改为 PAYMENT_SUCCEEDED,并插入一条 deposit.paid 出站消息。Outbox Relay 负责把这条消息发布到 Kafka。Relay 发布成功但未及标记已发送时,Kafka 会收到重复消息;因此账务消费者必须幂等。

账务服务的正确消费模式是:在自己的本地事务中,按 event_id 去重,写入账务分录,并记录消费完成。若账务服务还要继续发布事件,也在该事务中写自己的 Outbox。这样,一条事件在服务之间逐跳传播,每一跳都只要求本地事务一致,而不是试图让所有服务共享一笔全局事务。

Kafka 的 exactly-once 语义可以减少特定拓扑中的重复,但不能覆盖第三方 HTTP 调用、数据库外部副作用和消费者业务逻辑。对资金链路而言,真正可信的承诺仍是“至少一次投递 + 业务幂等 + 可对账”。

10. 重试、死信与人工介入

重试策略应区分错误性质:

  • 网络超时、限流、依赖不可用:指数退避并加入随机抖动,避免故障恢复时的重试洪峰;
  • 参数非法、payload 无法解析、渠道明确拒绝:标记为永久失败,不再重试;
  • 支付结果未知:优先查单;超过业务约定时限后转人工复核,而不是无限重放支付;
  • 重试耗尽:进入死信,保留请求摘要、最后错误、渠道订单号和操作轨迹。

取消本地消息只能阻止未来重试,不能撤回已发出的第三方请求,也不能回滚已生效的支付或账务分录。对于不可逆动作,止损手段是下游幂等、冻结后续流程,以及清晰的补偿/冲正流程,而不是期待“取消”具备事务回滚能力。

11. 可观测性和对账不是附属品

本地消息系统的健康度不应只看接口错误率。至少需要监控:

  • 待处理数量、最老消息年龄、按业务类型的积压;
  • 保存票据失败率,尤其是 Outbox 写入失败;
  • 执行成功率、重试次数分布、死信数量与死信年龄;
  • 租约过期重领次数、CAS 回写失败次数;
  • Kafka 出站延迟和消费者去重命中率;
  • 支付渠道订单与入金单、入金单与账务分录之间的日常对账差异。

对账是最后的安全网。它能发现代码缺陷、错误配置、渠道异步回调遗漏,以及超出系统假设的极端故障。资金正确性不应只建立在“组件理论上不会丢”之上。

12. 从入金扩展为通用组件

Local Message 的通用性不在于复用某个“支付重试函数”,而在于复用一套固定的可靠性骨架:本地事务落票 → 多副本领取 → 有界执行 → 分类回写 → 幂等下游 → 死信和运维闭环。每个业务只需要提供自己的 biz_type、payload、稳定幂等键和 Handler。

下表给出常见场景的接入方式。判断标准不是“是否异步”,而是“本地成功后是否存在一个不能默默丢失的外部副作用”。

场景票据 / 事件幂等键推荐模式业务要点
支付、退款、代扣payment.initiate / payment.query渠道订单号Outbox + Local Message结果未知先查单;明确失败才终态;超时转人工复核
订单履约、仓储出库fulfillment.createorder_id 或履约单号Outbox + Local Message第三方 WMS 必须按履约单号去重;库存预留若需同步确认,评估 TCC
领域事件到 Kafkaorder.paid / deposit.paidevent_idOutbox Relay用聚合 ID 作为 Kafka key;消费者在本地事务中去重
短信、邮件、Push、Webhooknotification.send通知业务 ID独立重试库或 Outbox营销/弱一致通知可接受独立库;合规通知、支付回调应采用 Outbox
CRM、风控、搜索索引同步customer.sync / index.upsert实体 ID + 版本Local Message下游支持 upsert 或版本比较;旧版本消息不能覆盖新数据
文件转码、OCR、外部批处理media.submit / media.poll任务 IDOutbox + Local Message提交和轮询分为不同票据;长任务不要长期占住 worker 租约
数据修复、补偿、回调补发reconcile.repair修复任务 IDLocal Message处理器先检查当前事实;重复修复必须无害,保留人工审批与审计

12.1 将业务差异收敛为 Handler

一个通用组件不应该认识“入金”“订单”或“短信”。组件只管理票据生命周期;Handler 负责解释 payload、读取业务现状、调用下游,并把错误分类为成功、可重试或永久失败。这样新增一个场景通常不需要改组件核心:

业务事务:写业务数据 + SaveTx(biz_type, idem_key, payload)
                      ↓
组件:ClaimDue → 调度对应 Handler → 成功 / 退避重试 / 死信
                      ↓
业务 Handler:查当前状态 → 幂等调用下游 → 需要时写下一跳 Outbox

以已开源的 Go 组件 LebronCroft/localmessage 为例,它提供 SaveTx、租约领取、FOR UPDATE SKIP LOCKED 多副本并发、乐观版本回写、退避抖动、死信、取消与人工重投等通用能力。业务侧的最小接入面是:

store, _ := localmessage.NewSQLStore(db, "local_message")
lm, _ := localmessage.New(localmessage.Options{
    Store: store, Concurrency: 8,
    Lease: time.Minute, HandlerTimeout: 20 * time.Second,
})

lm.Register("warehouse.create", retryCreateShipment,
    localmessage.Config{MaxAttempts: 8, Concurrency: 4})
lm.Start()

// 与订单状态变更在同一事务中调用:
lm.SaveTx(ctx, tx, "warehouse.create", shipmentID, payload)

组件不替业务生成幂等键,也不会自动判断“支付是否已经发生”或“库存能否扣减”;这些判断只能由拥有领域事实的服务完成。当前组件成功后会清理票据,死信可人工 RequeueDead,因此审计记录应保留在业务表、领域事件或日志平台,而不是依赖成功票据长期留存。

12.2 哪些场景不该使用它

通用组件也应有清晰边界:

  • 用户必须在当前请求得到最终结果的同步校验,例如登录鉴权、余额实时查询,不应转成后台重试;
  • 数百万条的常规流式消费不应压到业务数据库表,优先使用 Kafka、专业任务队列或 CDC;
  • 需要数小时运行的任务应拆成提交、查询、回调三个短步骤,或使用专门的工作流引擎;
  • 需要原子预留多个可控资源时,优先评估 TCC;Local Message 不能提供跨服务隔离性;
  • 对不可重复、又没有幂等键、查单或补偿能力的第三方接口,先补齐渠道契约,不能靠重试组件冒险调用。

13. 落地检查清单

在接入一条新的最终一致性流程前,逐项回答以下问题:

  1. 本地状态和待执行意图是否在同一数据库事务中写入?核心资金链路应为“是”。
  2. 对第三方调用、Kafka 事件和消费者写入,稳定幂等键分别是什么?
  3. 结果未知时是查单、重放还是转人工?渠道是否明确支持幂等?
  4. 哪些错误可重试,哪些错误必须永久失败?最大重试窗口是否符合资金时效?
  5. 租约、处理超时和执行并发是否匹配,避免租约内排队?
  6. 消费者是否把去重记录与业务变更放在同一事务中?
  7. 死信、积压、对账差异由谁处理,处理时限是什么?

结语

本地消息方案的价值不在于添加一个“自动重试”库,而在于把分布式失败从不可判断的偶发事故,变成有状态、有边界、可观察的正常流程。它允许支付、Kafka 和账务服务在各自的事务边界内独立演进,同时通过 Outbox 保证意图不丢、通过幂等抵御重复、通过状态查询与对账处理不确定性。

对于“入金状态变更 → 第三方支付 → Kafka 驱动账务处理”这类链路,最稳妥的组合是:核心动作采用同库 Outbox;第三方调用使用渠道订单号幂等并支持查单;Kafka 事件采用至少一次投递;账务服务本地事务去重记账;死信和对账承担最终运营闭环。这样得到的不是一笔脆弱的跨服务事务,而是一条能够经受网络抖动、重复投递和进程故障的可恢复业务链路。

如果你在 Go 服务中需要将这套模式落地,可以直接使用我的开源项目 LebronCroft/localmessage:它专注于可嵌入的事务 Outbox 与可靠重试 worker,保留业务 Handler 的幂等和状态判断职责。