从一次入金到一次扣账:用本地消息构建可恢复的最终一致性
在资金类系统中,一笔“用户入金”往往会跨越三类边界:本服务要更新入金单状态;要调用第三方支付渠道;支付成功后还要通过 Kafka 通知账务服务扣账或入账。这三个动作分属不同的数据库、网络连接和故障域,不能也不应该试图塞进一个跨服务事务。
真正的问题并不是“调用失败了要不要重试”,而是:当超时或进程崩溃发生时,我们无法判断第三方究竟没有收到请求,还是已经完成了支付、只是响应丢了。如果直接重试,可能重复支付;如果不重试,可能漏单。随后,已经成功的支付状态与 Kafka 事件之间也有同样的空窗:数据库提交成功但消息还没发出,账务服务就永远不会收到扣账请求。
本文介绍一种可复用的本地消息(Local Message)方案。它以 Outbox 为可靠落点,以幂等为最终保险,以租约和乐观锁支持水平扩展。它不追求虚假的“端到端 exactly once”,而是在任意节点故障后都能恢复,并让重复投递不重复生效。
1. 先定义问题边界
假设入金流程如下:
- 用户提交入金,入金服务创建或更新入金单。
- 入金服务调用第三方支付渠道发起支付。
- 支付明确成功后,入金服务发布
deposit.paid或ledger.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 Connector | deposit.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 的数据库资源;通常不用于微服务主链路 |
| TCC | Try 预留资源,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 时,不锁住账务服务,也不等待全局协调器;它持续查单或以同一幂等键重试,确认后再发布账务事件。它牺牲同步原子性,换取跨异构系统的可恢复性。
可以用下面的顺序选择:
- 动作是否只涉及同一个数据库?若是,直接本地事务,不引入分布式方案。
- 所有参与方是否可控,并且业务确实需要先预留资源、再统一确认/取消?若是,评估 TCC。
- 是否有少量同质 XA 资源、且强原子性收益显著大于可用性和锁成本?极少数情况下评估 2PC。
- 只要任一方是 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>,通过 kind 与 biz_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,并在应用层约束同一聚合的状态推进顺序。跨聚合的全局顺序既昂贵也没有业务意义。version、lease_until与next_retry_at:前者防止旧 worker 覆盖新状态,后两者让崩溃 worker 的任务自动重新可见。last_error:只保留截断、脱敏后的错误摘要;完整堆栈应去日志平台,以免表膨胀和敏感信息泄漏。
一个常用的状态流转是 PENDING → SUCCEEDED、PENDING → DEAD、PENDING → CANCELED。领取不必新增 PROCESSING 状态:保留 PENDING,只将 lease_until 或 next_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_id或deposit_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.create | order_id 或履约单号 | Outbox + Local Message | 第三方 WMS 必须按履约单号去重;库存预留若需同步确认,评估 TCC |
| 领域事件到 Kafka | order.paid / deposit.paid | event_id | Outbox Relay | 用聚合 ID 作为 Kafka key;消费者在本地事务中去重 |
| 短信、邮件、Push、Webhook | notification.send | 通知业务 ID | 独立重试库或 Outbox | 营销/弱一致通知可接受独立库;合规通知、支付回调应采用 Outbox |
| CRM、风控、搜索索引同步 | customer.sync / index.upsert | 实体 ID + 版本 | Local Message | 下游支持 upsert 或版本比较;旧版本消息不能覆盖新数据 |
| 文件转码、OCR、外部批处理 | media.submit / media.poll | 任务 ID | Outbox + Local Message | 提交和轮询分为不同票据;长任务不要长期占住 worker 租约 |
| 数据修复、补偿、回调补发 | reconcile.repair | 修复任务 ID | Local 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. 落地检查清单
在接入一条新的最终一致性流程前,逐项回答以下问题:
- 本地状态和待执行意图是否在同一数据库事务中写入?核心资金链路应为“是”。
- 对第三方调用、Kafka 事件和消费者写入,稳定幂等键分别是什么?
- 结果未知时是查单、重放还是转人工?渠道是否明确支持幂等?
- 哪些错误可重试,哪些错误必须永久失败?最大重试窗口是否符合资金时效?
- 租约、处理超时和执行并发是否匹配,避免租约内排队?
- 消费者是否把去重记录与业务变更放在同一事务中?
- 死信、积压、对账差异由谁处理,处理时限是什么?
结语
本地消息方案的价值不在于添加一个“自动重试”库,而在于把分布式失败从不可判断的偶发事故,变成有状态、有边界、可观察的正常流程。它允许支付、Kafka 和账务服务在各自的事务边界内独立演进,同时通过 Outbox 保证意图不丢、通过幂等抵御重复、通过状态查询与对账处理不确定性。
对于“入金状态变更 → 第三方支付 → Kafka 驱动账务处理”这类链路,最稳妥的组合是:核心动作采用同库 Outbox;第三方调用使用渠道订单号幂等并支持查单;Kafka 事件采用至少一次投递;账务服务本地事务去重记账;死信和对账承担最终运营闭环。这样得到的不是一笔脆弱的跨服务事务,而是一条能够经受网络抖动、重复投递和进程故障的可恢复业务链路。
如果你在 Go 服务中需要将这套模式落地,可以直接使用我的开源项目 LebronCroft/localmessage:它专注于可嵌入的事务 Outbox 与可靠重试 worker,保留业务 Handler 的幂等和状态判断职责。