技术思考问题36:分布式事务成功了,失败谁兜底?
分布式事务正常跑得飞快,凌晨出错谁来还债?
正常链路只证明快乐路径,异常链路才是账单。
[读者] 写过订单、支付、通知串行链路的后端与架构同学
[痛点] 正常路径跑得顺,异常路径一上来就靠人肉对账
[现在读] 一次超时或一次重复回调,就能把账和信任同时打穿
[读完] 能画出失败矩阵、配好幂等键与补偿状态机,按终态验收
[旧方案] 三步调用都返回成功,就当事务已经完成
|
v
[新需求] 超时、重复、部分成功、补偿失败都要有出口
|
v
[冲突] 跨服务没有全局原子性,任何一步都可能停在中间态
|
v
[后果] 支付成功订单未知,凌晨靠人肉对账还债
我是老李,这些年主要在做交易链路和中间件。见过最多的不是写不出代码,而是写完了却说不清“这笔单到底算不算完成”。
分布式事务的复杂度,主要不在正常路径上。正常路径是产品经理画的流程图,异常路径才是运维半夜接到的电话。
一个系统如果只被验证过“全部成功”,那它其实没有被验证过。上线验收必须覆盖每一条失败出口,否则故障时间会替你把剩下的九成补上。
串行调用每一步都返回成功,事务就算完成了吗?
跨服务没有全局原子性,验收到底该盯哪一步?
补偿代码写了几百行,为什么凌晨还是要人肉救火?
01、故事
一条典型链路:订单服务减库存、支付服务接第三方回调、通知服务发短信,三个服务各管各的本地事务,中间靠消息串起来。平时跑得很快,压测报告也很漂亮。任务是让“用户付款后订单一定变成已支付、库存一定扣对、通知一定发出”。
变化来得没有预告。促销开始后,第三方支付的回调开始抖动:有时慢到十几秒,有时干脆重复推两次。用户等不及点了刷新,链路里就多出一条订单。
这时候团队才发现,过去所有的验证都发生在这条链路的快乐路径上。超时、重复、部分成功、补偿失败这四件事,一件都没有被设计过。
代码没写错,验收标准写错了。
链路跑得快
异常无人管
顺境不算数
逆境见真功
02、问题
旧方案失效得很安静:把“每一步都返回成功”当成事务完成。业务影响是用户投诉“钱扣了订单没了”,客服靠导出表格手工核账。技术表现是订单状态卡在初始态、事件表里没有对应记录、同一个业务键出现两条处理记录、补偿动作被执行了两次。
可验证的完成标准要换掉:步骤级失败矩阵的每一格都有对应出口;全链路业务幂等键有唯一约束且真实生效;补偿状态机有明确终态且不可逆;对账差值可被解释、可被归零。
成功返回只是其中一格,不是全部。
会成功不算赢
会失败才算稳
标准若模糊
上线必翻车
03、原理
反直觉判断:分布式事务的“完成”不是一个瞬间,而是一组可收敛的状态集合。只要每种中间态都有归宿,它就算设计对了。
Saga 把长事务拆成若干本地事务,每一步配一个补偿动作,协调方式分事件编排与编排器两种。它换来原子性,但缺隔离性,中间态对外可见,需要语义锁一类手段去挡住误读(参见 Saga 模式)。
Transactional Outbox 把业务变更和事件写在同一个本地事务里,再由中继发布出去,解决“改了库但消息没发”的裂缝。代价是投递通常至少一次,消费端必须幂等(参见 Transactional Outbox)。
补偿事务撤销的是已发生步骤的净效果,不是数据库回滚。有些步骤天生不可补偿,比如已发货、已发短信,所以顺序本身要设计(参见 Compensating Transaction)。
完成非一瞬
收敛才算数
补偿非回滚
幂等是地基
04、架构
[输入] 支付回调 / 下单请求(可能超时、可能重复)
|
v
[模块] 幂等层 + 业务服务 + Outbox 中继 + Saga 编排器 + 死信队列
|
v
[数据/状态] 幂等键表、outbox 表、补偿状态机表、三方回执
|
v
[处理] 本地事务推进状态 → 同事务写事件 → 中继发布 → 失败进补偿或死信
|
v
[输出] 明确终态 + 对账结果 + 人工工单
边界:这套结构只解决最终一致性,不提供跨服务的强一致读;补偿要求步骤可逆或可前置排序。收益是每条失败出口都有归宿,代价是多一张表、多一个终态、多一套值守流程。适用条件是业务能接受短暂不一致,且每一步的失败都可描述。
有图才有界
有界才敢放
状态先想全
代码才不慌
05、实战一次
环境:JDK 21 与 PostgreSQL 14 以上,Python 侧使用 psycopg 3(版本以官方文档为准)。依赖:一个消息客户端对象 mq。配置:数据源指向业务库,中继独立进程运行。
create table orders (
order_id varchar(64) primary key,
status varchar(16) not null default 'INIT'
);
create table pay_idempotent (
biz_key varchar(128) primary key,
order_id varchar(64) not null
);
create table outbox (
id bigserial primary key,
aggregate_id varchar(64) not null,
event_type varchar(64) not null,
payload jsonb not null,
status varchar(16) not null default 'NEW',
retry_count int not null default 0
);
import json
def pay_success(conn, biz_key, order_id, amount):
with conn.transaction():
cur = conn.execute(
"insert into pay_idempotent(biz_key, order_id) values (%s, %s) "
"on conflict do nothing returning biz_key", (biz_key, order_id))
if cur.fetchone() is None:
return "DUPLICATE"
n = conn.execute(
"update orders set status = 'PAID' where order_id = %s and status = 'INIT'",
(order_id,)).rowcount
if n == 0:
raise RuntimeError("状态不允许推进: " + order_id)
payload = json.dumps({"orderId": order_id, "amount": str(amount)})
conn.execute(
"insert into outbox(aggregate_id, event_type, payload) values (%s, %s, %s::jsonb)",
(order_id, "OrderPaid", payload))
return "OK"
def relay_once(conn, max_retry=5):
rows = conn.execute(
"select id, payload from outbox where status = 'NEW' order by id limit 100").fetchall()
for out_id, payload in rows:
try:
mq.publish("order-paid", payload)
conn.execute("update outbox set status = 'SENT' where id = %s", (out_id,))
except Exception:
conn.execute(
"update outbox set retry_count = retry_count + 1, "
"status = case when retry_count + 1 >= %s then 'DEAD' else 'NEW' end where id = %s",
(max_retry, out_id))
conn.commit()
启动:先起业务服务,再单独起中继进程。请求:curl -X POST /pay/callback -d '{"bizKey":"pay-1001","orderId":"o-1","amount":"9.90"}'。验证:同一个 bizKey 连发两次,第一次返回 OK,第二次返回 DUPLICATE;再查 outbox 状态与订单状态。
未在当前环境实测,以下为预期结果。示例输出:
第一次回调:pay_idempotent 新增 1 行,orders.status 从 INIT 变为 PAID,outbox 新增 1 行 NEW。重复回调:pay_idempotent 不变,orders.status 仍为 PAID,outbox 不再新增。
V1 到这里只跑通了快乐路径,超时与死信还没处理,问题留给后面。
先跑通一条
再留一个坑
小步能验证
大步不失控
06、排查
现象一:支付回调成功后订单仍停在待支付,用户重复下单。怀疑是消息丢了。检查 outbox 表与中继日志,证据是 outbox 里有 NEW 记录长期不变 SENT,消费端日志查不到该 orderId。根因是早期版本先发消息后提交本地事务,事务回滚后消息已经飞出去;或者中继根本没启动。修复是改成同库同事务写 outbox,中继独立轮询,投递至少一次,消费端用业务键去重。
现象二:同一订单出现两笔退款。怀疑退款接口不稳。检查补偿日志与补偿幂等键,证据是同一条补偿动作有两条记录且 biz_key 为空。根因是补偿动作没配幂等键,重试一次就多一次副作用。修复是补偿同样带唯一业务键,重复执行直接返回首次结果。
错误尝试: 把超时从 3 秒调到 30 秒、重试从 3 次调到 10 次。为什么错:超时只是把失败从“早失败”推迟成“晚失败”,而重复次数被放大,副作用成倍增长;重试只对临时性失败有效,对“状态不允许推进”这类确定性失败完全无效,反而拖长故障窗口。
现象会骗人
证据不会
猜一次错一次
查一次准一次
07、优化
按证据形成 V2。第一处:业务与事件同事务写 outbox,中继独立发布。原因是消除“改了库没发消息”,新行为是事件不丢但可能重复,验证方式是杀掉中继再启动,观察 outbox 从 NEW 收敛到 SENT。
第二处:全链路统一业务幂等键,覆盖支付回调、消息消费与补偿动作。原因是重复不可避免,新行为是重复执行收敛到首次结果,验证方式是对同一 bizKey 连续投递并检查唯一约束冲突次数。
第三处:Saga 状态机显式终态,成功、已补偿、待人工三态互斥且不可逆,重试限次并配指数退避,超限进死信队列。原因是让“停不下来”的流程有一个确定的归宿。
第四处:每日对账任务比对三方回执与本地状态,差值生成人工工单。原因是不可补偿的步骤必须有人接住。
根因定方向
改动有依据
验证不虚构
优化才站得住
08、演进
[同一输入] 支付回调超时且被重复投递
|
+--[V1] 以返回码判定完成 / 代价:重复入账、订单未知、人肉对账
|
+--[V2] 幂等键 + Outbox + 补偿状态机 + 死信对账
| / 代价:多一张表、多一个终态、多一套值守流程
|
[Trade-off]
得到:异常路径有出口,验收有终态
失去:单次调用时延与实现复杂度
适用边界:最终一致性可接受、步骤可补偿或可前置排序的业务
比较维度:正确性上 V2 覆盖了中间态;稳定性上 V2 不再依赖“不超时”这个假设;复杂度上 V2 明显更高,需要中继与对账两个常驻组件;成本上是存储与运维投入;适用范围上 V2 适合交易类链路,不适合要求强一致读的场景。遗留问题是中间态对外可见,需要语义锁或状态前置校验继续补。
旧路快而脆
新路稳而重
取舍写明白
演进才有底
09、洞见
9.1 完成标准要从返回码换成失败矩阵
系统能不能上,不看成功那一格,看剩下每一格有没有名字。步骤级失败矩阵画不出来,说明边界还没想清楚,代码写得再漂亮也只是在赌。
9.2 幂等不是优化项,是重试的前提
反直觉判断:幂等键看起来是防止重复的补丁,实际上它是让“重试”这件事变得可用的地基。没有幂等,任何一次超时重试都是一次风险放大器;有了幂等,重试才从赌博变成工程手段。
9.3 补偿不是无条件回滚
反直觉判断:补偿写得越多不代表越安全,不可补偿的步骤才是设计核心。已发货、已发短信这些动作撤不回来,正确的做法是把它们排到最后,而不是多写几行回滚代码。
9.4 人工不是兜底,是系统的一部分
死信队列、对账任务、人工工单,如果只在事故时临时拉群,那它就不是设计。把它们当成正式组件,配上状态与SLA,才能让“凌晨谁来还债”这个问题有确定答案。
洞见不靠猜
全靠证据撑
反直觉之处
常藏真功夫
10、系统落地
原来有什么:一条能跑通的成功链路,和一堆事后补的 if 判断。本篇新增什么:失败矩阵、幂等键表、outbox 表、显式补偿状态机、死信与对账。现在能做什么:任何一次超时或重复投递,都能被识别并收敛到确定终态,不再依赖人肉记忆。还缺什么:跨服务的中间态对外可见问题、对账差异的自动归因、演练常态化。下一步演进:把故障演练做成例行任务,定期杀掉中继、模拟重复投递与三方超时,把每一条失败出口都真跑一遍。
原来只有路
现在多了闸
闸门认状态
落地才算数
11、小结
Q1 → 不是。串行成功只证明快乐路径可跑,完成标准是每条失败出口都有归宿
Q2 → 盯停在中间态的那一步:支付成功但订单未知,才是验收重点
Q3 → 因为补偿没做成状态机、缺幂等键、缺死信与对账,人工成了唯一出口
状态 → 失败矩阵 + 幂等键 + Outbox/Saga + 死信对账,交付一条能收敛的链路
三问皆有答
一链可验收
不怕夜里有事
只怕没人兜底
12、作业
12.1 理解题:为什么“支付成功但订单未知”比“全部报错”更危险?
参考答案:全部报错是显性失败,用户与系统都能立刻感知并重试;部分成功是隐性失败,账面上看起来一切正常,实际状态停在中间态,用户可能已付款而订单不存在,只能靠对账事后发现。
12.2 实战题:给补偿动作加幂等,最小改动是什么?
参考答案:为每条补偿动作生成唯一业务键,落库加唯一约束,执行前先插入该键,冲突即代表已执行过,直接返回首次结果;同时给补偿状态加终态约束,禁止从已完成回退。
12.3 排障题:outbox 中大量记录长期为 NEW,怎么定位?
参考答案:先看中继进程是否存活与轮询间隔是否生效,再看是否有异常被吞掉导致状态未更新,最后检查发送端是否在事务提交前就发布或连接是否被错误关闭。逐层排除,不要先改重试次数。
12.4 架构判断题:一个步骤已发货、要求强一致读的业务,是否适合直接套这套方案?
参考答案:不适合。已发货属于不可补偿步骤,强一致读又超出最终一致性边界。应改为把发货前置到可取消阶段之后,或用预留与确认模型隔离不可逆动作,并把一致性要求拆到局部。
会做不算懂
会判才算通
四题练一遍
手上才有活
13、思考
分布式事务的复杂度几乎全部来自异常路径。设计时把时间花在正常流程上,上线后就得把时间花在异常流程上,区别只是前者在会议室,后者在凌晨。
真正的分水岭不是用不用 Saga 或 Outbox,而是你有没有把“完成”从一个返回码,改造成一组有终态、可对账、可收敛的状态。补偿有边界,重试有前提,人工有流程,这三件事说清楚了,链路才敢上线。
顺境不验收
逆境必还债
先画失败图
再谈高可用

浙公网安备 33010602011771号