做过分布式事务的人都有种共识:凡是跟钱沾边的系统,一致性永远是一把悬在头上的刀。这把刀在境内单体团队里已经够磨人了,一旦业务搬到跨境场景——用户在国内App下单,货在海外仓,钱要经过收单、换汇、出金三道关口才能落到境外商户账上——问题的复杂度直接翻倍。这篇文章不打算从ACID讲起,也不准备罗列概念,而是以我实际参与过的一个跨境支付平台为例,把分布式事务在跨境交易中的真实难点、方案选型和落地细节摊开来讲。适合正在做或准备做跨境支付、跨境电商、海外结算系统的后端开发,也适合想了解这套技术选型背后权衡逻辑的同学。
1. 跨境交易的分布式事务到底难在哪
1.1 一条跨境订单要串起多少个系统
我先画一下业务轮廓。某跨境支付平台的典型交易是:境内用户在App上买了一件境外商家的商品,用本地支付方式(比如扫码或银行卡)付了人民币;平台在境外通过合作渠道把美元打给商户。注意,这中间不是一次数据库事务能搞定的,而是一条完整的服务链:
- 订单中心:创建订单,记录商品、金额、结算币种;
- 支付网关:对接境内收单渠道,完成用户扣款;
- 换汇服务:对接持牌换汇机构或银行,把人民币换成美元并锁定汇率;
- 出金服务:对接境外清分渠道,把美元打到商户账户;
- 记账结算中心:维护平台账本、费用与商户账单;
- 对账中心:每天拉取渠道结算单,和内部流水逐笔匹配。
每个环节都有自己的数据库,核心服务分部署在两个以上的地域。更关键的是,收单渠道、换汇机构、境外清分渠道都不是我们的系统,它们各有各的接口规范、幂等策略和结算周期。这就意味着:别人看不到你的本地事务,你也没法让别人跟你一起提交或回滚。
所以,一根简单订单背后其实是六七个独立系统的接力。任何一个环节超时、失败、重复通知,都会让同一笔交易在A系统显示成功、在B系统显示处理中、在C系统显示失败。分布式事务要解决的核心,就是让这种“不一致”被控制、被追踪、最终被收敛。
1.2 CAP约束下的“钱不能错”悖论
做分布式系统的都知道CAP,但做支付的人对CAP的理解会更痛:网络分区一定会发生,跨地域尤其如此。分区发生时你必须在可用性和一致性之间做选择。对支付系统来说,用户付款的高可用是刚需——你不能因为境外链路抖动就让用户付不了钱;但钱的账目又绝对不允许错——多扣一分、少记一毛都是事故。于是你被迫接受一个事实:短时间内系统之间就是不一致的,你要做的不是消灭不一致,而是保证不一致可观测、可补偿、最终一致。
用生活类比来说:单机数据库事务像在同一个房间里记账,来一单锁一次抽屉,账目清清楚楚;跨地域分布式事务就像两个城市同时记一本账,锁谁?锁多久?抽屉还不在一个房间。硬要把两个城市的数据拉到一起做同步强一致,每一笔都等上几百毫秒甚至几秒,用户体验和系统吞吐都会崩。
实践中对一致性的理解可以分成两层:数据库层面的一致性和资金层面的一致性。数据库层面靠本地事务和可靠消息保证;资金层面靠状态机、幂等和对账闭环保证。后者才是跨境支付真正要守的底线。
1.3 跨境场景额外要面对的“三座大山”
境内系统做分布式事务只需要考虑网络抖动、服务宕机这些通用问题;跨境场景还会叠加三个维度。
第一,网络质量。跨地域公网链路的时延和丢包率比机房内网高一个数量级,偶尔还会出现几十秒的恶性抖动。超时不是异常,是常态。设计时就要默认“调用可能超时、结果可能未知”,所以每个对外操作都要有查询接口来确认最终状态。
第二,时区和业务日。渠道方按它本地的自然日结算,凌晨零点跑批;我们这边是白天。一笔交易北京时间23点支付成功,按渠道的结算日可能算到第二天甚至下一批。如果对账时用同一时刻去比,永远比不平。这块我后面单独展开。
第三,监管与审计。不同市场对交易记录留存、反洗钱校验、数据本地化都有要求。这不是技术文档里会写的东西,但它直接决定你的日志保存在哪个地域、谁有权限发起结算、哪些操作必须留痕。把这些当成架构约束,而不是上线前的合规杂活。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 方案选型:2PC、TCC、SAGA、消息最终一致性怎么组队
2.1 四类主流方案的底层逻辑
先把四类方案的思路讲清楚,再讲选型。
2PC/XA的思路是分两阶段:先让所有参与者把资源准备好(Prepare),都准备好了再统一提交(Commit)。优点是强一致;缺点是同步阻塞,任何一个参与者不回应,整笔交易卡住,而且协调者是单点。就算没有故障,跨地域光来回两轮RTT就要几百毫秒,线上场景根本扛不住。这个方案我最终的结论是:只适合同地域、同数据库实例级别的强一致场景,跨境链路基本不用。
TCC把资源管理提升到业务层面。Try阶段冻结资源,Confirm阶段真正扣款,Cancel阶段释放资源。相比数据库锁,它不长时间占用行锁,但要求每个参与者实现三个接口,还要保证Confirm/Cancel的幂等。开发成本不低,却很适合“冻结—扣款”这类支付语义。
SAGA是另一种思路:把长事务拆成一组顺序执行的小事务,每个步骤配一个补偿步骤。正向走到第N步挂了,就反着执行补偿。它是最终一致的,适合分钟级、小时级甚至天级的长链路。SAGA又分编排式和 choreography 式两种,复杂的跨系统链路我推荐编排式,由中央协调者控制每一步,状态可查、出问题有明确负责人。
消息最终一致性更像基础设施:服务A在本地事务里写业务数据和一条待发送消息(Outbox),后台把消息发给MQ,服务B消费后继续处理。配合消费者幂等,能实现跨服务的最终一致。
2.2 跨境交易下我的选型结论:没有银弹,只有组合
我参与的平台最终采用的不是某一个方案,而是按资金流特征分成四层。
第一层,用户扣款用TCC语义。支付网关和收单渠道之间做预授权(Try)和确认扣款(Confirm),取消则走撤销(Cancel)。这样就算用户手滑点两次,也只有一个冻结单,不会重复扣钱。
第二层,整条交易链路用SAGA编排。订单、支付、换汇、出金、记账五个步骤由交易编排服务统一调度,每步都有正向操作和补偿操作。
第三层,服务间数据流转用可靠消息(本地消息表/Outbox)。比如支付成功后要通知换汇服务启动报价,不用同步RPC,而是发事件。
第四层,资金最终一致靠对账兜底。所有异步、重试、补偿都无法100%消除不确定性,T+1的对账是最后一道防线。
这里有个容易走弯路的点:很多团队一开始想用“一个框架解决所有问题”,要么全局TCC,要么全局SAGA。但跨境交易里的步骤性质差异太大——扣款是秒级、可撤销的,出金是分钟级、不可逆的,换汇中间还夹着有效期。硬套一种模式,要么补偿逻辑写得极其拧巴,要么把简单问题复杂化。按步骤性质选模式,比按“时髦程度”选模式靠谱得多。
2.3 一张表把方案比清楚
| 方案 | 一致性倾向 | 典型时延 | 开发成本 | 失败的典型表现 | 跨境场景适用性 |
|---|---|---|---|---|---|
| 2PC/XA | 强一致 | 高(两轮RTT+锁等待) | 中 | 参与者异常导致全局阻塞 | 基本不用 |
| TCC | 业务级较强一致 | 中 | 高 | Confirm/Cancel失败需重试补偿 | 适合支付预授权 |
| SAGA | 最终一致 | 低(可异步) | 中高 | 补偿步骤多,部分失败转人工 | 适合长链路编排 |
| 消息最终一致 | 最终一致 | 低 | 低中 | 消息重复/丢失/乱序 | 适合解耦,必须配幂等和对账 |
这张表不是让你挑一个最好的,而是提示你把每一类问题放进合适的框里。我见过最可惜的选型失败,不是技术不行,而是只用一套方案硬扛所有场景,最后要么线上事故不断,要么代码里全是特判和hack。
3. 一套可落地的跨境支付结算架构拆解
3.1 整体链路与核心服务划分
下面按一个具体业务场景拆解:用户在境内支付人民币,境外商户收到美元。业务对象是“交易单”,交易单号唯一标识一笔完整跨境交易。
- 订单中心:创建交易单,状态 CREATED;
- 支付网关:向收单渠道发起预授权、确认扣款、撤销/退款;
- 换汇服务:向换汇机构询价、成交、反向换汇;
- 出金服务:向境外清分渠道发起付款,查询付款状态;
- 记账结算中心:生成账务流水、商户账单,处理手续费;
- 交易编排服务:SAGA编排器加事件日志,驱动整条链路。
正向流程如下:
- 用户下单,订单中心生成交易单,状态 CREATED。
- 支付网关发起预授权,收到渠道“预授权成功”后进入 PAYMENT_PREAUTHED。
- 用户确认支付,支付网关发起确认扣款;扣款成功进入 PAYMENT_CONFIRMED。
- 交易编排服务触发换汇:锁定实时汇率,换汇完成进入 FX_DONE。
- 触发出金:向境外渠道发起付款,收到成功回执进入 PAYOUT_SUCCESS。
- 记账结算中心记账,交易单进入 SETTLED。
每一步都落库,都有事件记录。使用“交易单”为中心的好处是:无论中间有多少服务,最终判断一笔钱有没有问题,都只看这个单子的状态机。
3.2 SAGA编排:正向与补偿要分开设计
SAGA最重要的不是正向流程,而是补偿流程。因为补偿才是长事务和短事务最本质的区别。
举一个具体失败场景:换汇成功、出金失败。正向走到第5步挂了,补偿怎么走?
- 出金步骤:本地状态置为 FAILED,若渠道侧可能已受理(超时未回执),先查询渠道,确认无资金变动才能结束,不能直接当失败处理;
- 换汇步骤:调用换汇机构的撤销或反向成交接口,把美元换回人民币;如果渠道不支持撤销,只能持有头寸或走下一个交易日平仓;
- 支付步骤:向收单渠道发起退款,把用户的人民币原路退回。
注意这里有一个小细节点:支付步骤的补偿不是TCC里的Cancel,而是退款(Refund)。TCC的Cancel假设资源还在冻结中,可以直接释放;但在SAGA里,第3步付款已经真实发生,钱已经从用户账户扣走,你想回滚只能做一笔新的退款操作,这本身又是一次资金变动。因此退款也要幂等,也要入账,也要能被对账。
补偿操作本身也可能失败。所以状态机里必须允许“补偿部分完成”的中间态,比如 REFUNDING、REFUND_FAILED,最终落到 REFUNDED、MANUAL_REVIEW。硬要设计成“要么全成要么全不成”,遇到退款卡单就进退两难了。
编排器以事件日志为唯一真相来源。每发起一步,先写 STEP_STARTED,收到结果写 STEP_SUCCEEDED 或 STEP_FAILED。进程重启后从事件日志恢复现场,绝不依赖内存状态。
3.3 可靠消息:本地消息表和Outbox怎么落地
服务之间异步通知,最容易踩的坑是双写:先写业务数据再发消息,发消息失败,两边不一致;先发消息再写业务,消费者来了数据还没落库。标准解法是本地消息表(Outbox)。
具体做法:业务操作所在的数据库事务里,除了更新业务表,再插入一条待发送事件(事件表字段:event_id、交易单号、事件类型、载荷、状态=待发送)。这两个写在同一个本地事务里,要么都成功,要么都失败,双写问题就消失了。
后台一个分发任务每秒扫一次事件表,把待发送事件捞出来推给MQ;收到MQ发送成功的确认后,把事件状态更新为已发送。消费者收到事件后执行自己的步骤,并向编排服务回执。如果消息丢失(极端情况下MQ也不会100%可靠),订阅数据库变更日志的CDC组件或者定时扫描能兜底。
事件载荷我建议把消费者需要的完整业务数据带全,而不是只带个ID让消费者回查。回查一方面增加链路时延,另一方面源系统状态可能已经变化,拿到的数据就不是发送时的数据了。
再强调一次:消息队列的语义是at-least-once,也就是可能重复。消费者幂等是这套方案的硬前提,没有幂等,Outbox做得再漂亮也会出现重复扣款或重复通知。
4. 状态机、幂等、超时和对账:四个逃不掉的实现细节
很多人把分布式事务理解成选一个框架、配一堆注解就完事,其实真正的工程量全在这四个细节里。任何一个做不好,架构图画得再好看都是纸糊的。
4.1 用状态机把“最终一致”变成可观测
| 主状态 | 含义 | 可进入的操作 |
|---|---|---|
| CREATED | 交易单已创建 | 发起预授权 |
| PAYMENT_PREAUTHED | 用户资金已冻结 | 确认扣款 / 撤销 |
| PAYMENT_CONFIRMED | 用户资金已扣收 | 发起换汇 / 退款 |
| FX_DONE | 汇率已锁定,币种已转换 | 发起出金 / 反向换汇 |
| PAYOUT_IN_PROGRESS | 已向境外渠道发起付款 | 查询渠道状态 |
| PAYOUT_SUCCESS | 商户到账 | 记账结算 |
| SETTLED | 记账完成,交易闭环 | 只读 |
| REFUNDING | 退款处理中 | 查询退款状态 |
| REFUNDED | 退款成功 | 只读 |
| MANUAL_REVIEW | 需人工介入 | 人工处理 |
状态转换有严格的来源约束,禁止跨状态跳转。比如 PAYMENT_CONFIRMED 不能直接跳到 SETTLED,中间必须经过 FX_DONE 和 PAYOUT_SUCCESS。每一个状态变更都往事件表写一条记录,包含操作人(或系统模块)、时间、trace_id、载荷摘要。这样最终一致就不是一句口号,而是可以被查询、被统计、被监控的实打实的数据。
服务重启恢复的逻辑也要在这里说清楚:编排器扫描所有停留在 PAYOUT_IN_PROGRESS 等“进行中”状态超过阈值的时间,调用各渠道查询接口确认真实状态,再决定往前走、重试还是补偿。这部分是崩溃恢复能力的核心。
4.2 幂等:所有重试的前提
幂等表的核心设计就一张表:
sql复制CREATE TABLE dedup_record (
biz_id VARCHAR(64) NOT NULL COMMENT '业务单号',
op_code VARCHAR(32) NOT NULL COMMENT '操作码,如 CAPTURE/REFUND',
result_json TEXT NOT NULL COMMENT '第一次执行的结果快照',
created_at DATETIME NOT NULL,
PRIMARY KEY (biz_id, op_code)
) COMMENT '幂等记录表';
每个资金类操作执行前,先以 (biz_id, op_code) 查幂等表,存在就返回第一次的结果;不存在就INSERT抢锁,插入成功后执行真实操作,最后把结果写回。这里的关键不只是“不重复执行”,而是“重复请求返回第一次的结果”。如果你的实现是直接返回成功,那对账的时候就会发现A系统记了一笔、B系统记了两笔,又得靠人工补账。
对接外部渠道时,幂等键要让渠道方也能识别。我们给每笔对渠道的请求生成一个request_no,渠道侧即使重复收到,也会按request_no去重。但不要天真地以为对方一定做了幂等——超时之后不要盲目重试执行,而是先调用查询接口确认上一次请求的真实结果,再决定是继续推进还是重新发单。
4.3 超时与重试策略
| 步骤 | 同步超时 | 错误处理 |
|---|---|---|
| 预授权 | 15秒 | 失败直接结束,允许用户重试 |
| 确认扣款 | 15秒 | 超时后查渠道,禁止盲目重试扣款 |
| 换汇询价 | 5秒 | 可重试一次,重试时必须重新询价 |
| 出金请求 | 30秒 | 超时后置为查询态,由定时任务确认 |
| 退款 | 30秒 | 超时后查询退款单,确认是否入账 |
重试策略的原则可以归纳成一句话:能查询确认的,绝不靠重试猜。重试用指数退避加随机抖动,避免流量同时涌向渠道;重试次数设上限,超过上限进入人工任务队列并告警。人工队列要有人看,不然它就是事故的盲区。
4.4 对账:兜底所有不确定性的最后防线
再完备的分布式事务方案也防不住“对方悄悄改了结算规则”“对方少回了一张单”“汇率在中间被做了手脚”。对账是用外部系统的账单反向校准内部账本,这是资金系统的最后防线,没有之一。
对账的数据源有三个:内部账务流水、收单渠道的结算单、出金渠道的回单。匹配键用支付单号、出金请求号、渠道流水号。匹配前先把双方的时间对齐到渠道方的业务日,金额统一为最小货币单位。差异分成几类:我方有对方没有、对方有我方没有、金额差额、状态不一致。
差异不会都自动解决,我们的策略是:能明确原因的自动冲正并记录原因码,比如手续费调整;不能明确的进入人工处理,同时每天出差异报告。对账发现“我方以为成功、渠道实际未收到”这种情况,必须立刻冻结相关状态并告警,这是资金安全等级最高的事件。
5. 跨境场景特有的坑:时区、汇率、精度与合规
5.1 时区与结算日的对齐
境内系统对时区的感知很弱,跨境系统则几乎每天都要处理时区问题。我的第一原则是:系统内部统一存UTC时间戳,展示时再转换到用户当地时区。但更隐蔽的坑在“业务日”。
渠道的结算日是按它本地自然日划分的,比如某个渠道的跑批时间是当地时间凌晨0点,那它对“这笔交易算今天还是明天”的判断,取决于它那边的时钟。处理办法是为每个渠道建一张映射表,记录渠道所在地、时区、日切时间;对账和结算日期计算都走这张表,绝不直接用服务器本地日期做字符串比较。
我之前吃过的亏是:用服务器的零点做了结算日切,结果渠道按它的本地时间把一笔晚上11点的交易记到了第二天,两边对账差了整整一天的量。后来把所有日切逻辑统一收敛到渠道配置,这个问题就再没出现过。
5.2 汇率锁价的有效期与自动重报价
换汇服务有个容易忽视的细节:汇率报价是有有效期的。银行或换汇机构给你的价格可能只保持10分钟,过了有效期就必须重新询价。
锁价的关键是:出金之前必须核验报价是否过期。如果过期,你不能默默按新汇率出金,因为差额会静默产生,用户那边看到的金额和实际出金的金额对不上。我们的做法是:发现报价过期,把交易单置为 QUOTE_EXPIRED 状态,弹窗让用户确认新汇率;只有用户确认了,才用新的报价继续出金。
同时换汇方向的语义要小心:用户付人民币、商户收美元,我们这边接到的人民币和换出的美元必须对应同一笔交易单。报价单号、汇率、费用、有效时间全部存下来,出金时带上报价单号,渠道侧才能追溯。
5.3 金额精度和小数规则
钱在系统里必须用最小货币单位表示,存整数。人民币用分,美元用美分,日元没有小数位,所以字段精度要按币种配置,不能写死两位小数。如果你用浮点数算钱参与费用分摊,早晚会出0.01的差额,而且这种差额最难查。
手续费和舍入规则也要在配置里写清楚:是按订单总额收还是按每个子项收,两者差一分钱很正常;舍入方式用四舍五入还是银行家舍入,不同市场的要求也不一样。我们把这些规则配置化,并且在对账时按配置逐笔验证。别小看一分钱,跨境交易量大之后,一分钱的系统误差会被放大成整月的对账差异。
5.4 监管合规与审计链路
跨境支付必然涉及不同市场的监管要求。普遍的要求包括:付款前的反洗钱风险校验、交易记录和客户身份的保存期限、审计日志不可篡改、某些市场的交易数据需要本地留存。这些不是上线前补个合规文档就行的,它们会直接影响架构:日志存哪个地域、谁有权限改动账务、哪些敏感字段需要加密脱敏。
我们的处理原则是:账务数据一律增量不删除,更正走冲正分录,不做UPDATE/DELETE;所有状态机跳转和渠道交互都留审计日志;涉及资金的操作强制记录操作人、来源、trace_id。这套习惯做下来,后面接新市场的监管审计时,基本不会手忙脚乱。
6. 几次线上问题复盘:从故障中提炼的经验
6.1 重复支付:用户点了一下,钱扣了两次
一次线上事故是:用户在支付页等得太久,点了两次提交。前端和后端都做了防重复,但两个请求走了不同的验证路径,都到达了收单渠道,结果用户被扣了两次钱,订单却只生成了一笔。
根因是扣款接口的幂等键没有统一:前端防抖用的是页面随机数,后端幂等用的是订单号,两边对不上。修复方案是把幂等键收敛为支付系统生成的payment_request_id,在下单时生成、随请求透传,渠道侧也按这个ID去重;同时把扣款流程改成预授权加确认扣款,即使重复发起,也只有一个预授权单可以被确认。这个事故对我们的教训是:防重复不能只靠前端,也不能只靠后端,幂等键必须从源头贯穿到渠道。
6.2 消息重试导致补偿和正向操作竞争
另一个印象深刻的问题是:出金请求超时后,编排器判断为失败并触发补偿;但实际的出金请求在渠道侧已经成功了,只是回执丢失。补偿流程执行到“原路退款”时,发现商户那边已经到账,退款自然失败,交易单卡成了一个两头都说不清的中间态。
修复分两步。第一步,任何补偿动作执行前,先查询渠道确认目标步骤的真实状态,确认没有资金变动才允许补偿。第二步,补偿和正向操作之间要加锁和版本控制,只有交易单状态处于“可补偿”的节点才能发起补偿,防止正向任务还在跑、补偿任务已经开动。后来我们给编排器加了基于交易单ID的分布式锁,双写竞争的现场基本就没再出现过。
6.3 汇率过期导致静默亏损
换汇报价有效期的坑也真实踩过。当时某个定时任务挤兑,一笔交易的出金延迟了三个小时才执行,执行者拿的还是三小时前的报价。正好那个时段汇率波动大,出金按旧汇率结算,导致平台产生了实质性的汇率亏损。
说实话,这个亏损不是对账查出来的,是月底财务算账时发现的。它让我明白一个道理:在跨境交易里,最危险的不是报错,而是静默。系统默默按过期报价完成出金,用户不知道,财务也不知道,等知道的时候已经亏了钱。后来我们把报价校验下沉到出金服务内部,过期就必须进入人工确认流程,同时在日报里增加汇率损益统计。宁可让交易停在人工环节,也不能让它静默地走完。
6.4 对账不平时的完整排查思路
最后分享一套我常用的对账排查方法。
第一,先建内部事件时间线。对出问题的交易单,把订单、支付、换汇、出金、记账五个阶段的事件全部拉出来,按时间排序,确认每个步骤在内部系统的最终状态。第二,拉外部渠道数据。用支付单号或出金请求号去渠道结算单和回单里找对应记录,先对齐业务日,再用最小货币单位比较金额。第三,分类差异。是时间差、金额差、还是状态差,不同的差对应不同的根因路径。第四,针对根因修复数据和规则,并把这类差异沉淀成自动处理规则,避免下次还走一遍人工。
印象中最常见的对账不平,原因是渠道的日切时间跟我们不一样,其次是手续费规则微调和部分退款。把渠道配置表维护好,把差异规则积累起来,对账的自动化率会越来越高,剩下的才是真正值得人力介入的异常。
这些经验说到底只反映一个原则:跨境分布式事务的工程重点,不是让系统永不失败,而是让失败有迹可循、有路可走。状态机提供可观测,幂等提供可重试,补偿提供可回退,对账提供可兜底——四件事都做好,剩下的交给时间和运维。
