支付系统的分布式事务实践——从业务需求到 Seata Saga 模式的落地路径
一、支付系统的分布式事务困境:一笔订单为何涉及 5 个服务
2025 年 Q3,团队接手了一个聚合支付系统的重构任务。这个系统连接了支付宝、微信支付、银联云闪付三条支付通道,每笔交易的核心流程涉及 5 个微服务:
- 订单服务:创建支付订单,记录交易流水
- 风控服务:对交易做实时风险评估
- 渠道服务:调用第三方支付 API 发起扣款
- 账务服务:更新商户余额和交易明细
- 通知服务:异步通知商户交易结果
在旧架构中,这 5 个步骤是通过一个单体服务内的@Transactional注解串行完成的。微服务化改造后,每个步骤变成了远程 RPC 调用,传统的事务管理机制失效。首先暴露的问题是数据不一致:风控服务扣减了风控额度,但渠道服务调用失败,退款逻辑没有正确回滚风控额度,导致用户额度被错误占用。
经过一周的线上数据对账,发现了 47 笔存在类似问题的订单。虽然每笔金额不大,但这个问题如果不从架构层面解决,随着交易量的增长会呈线性增加。
二、分布式事务方案选型:为什么最终选择了 Seata Saga 模式
面对分布式事务的经典"三选一"——TCC、可靠消息最终一致性、Saga——团队做了详细的方案对比:
| 方案 | 优点 | 缺点 |
|---|---|---|
| TCC(Try-Confirm-Cancel) | 强一致性,实时回滚 | 侵入性强,每个服务需实现三接口 |
| 可靠消息(RocketMQ 事务消息) | 性能好,解耦 | 无法处理同步回滚需求 |
| Saga(编排模式) | 长事务支持,补偿可定制 | 实现复杂度较高 |
支付场景有两个特殊约束:一是用户支付的超时窗口只有 30 秒(微信支付的要求),延迟必须可控;二是部分操作必须同步完成(如扣款结果),不能用异步消息替代。
最终选择Seata Saga 状态机模式,原因有三:
- 同步执行保证时效:Saga 的每一步由状态机串行/并行编排,不需要额外的消息队列中转
- 补偿机制灵活:可以为每个步骤单独定义补偿操作,粒度可控
- Seata 生态成熟:与 Spring Cloud Alibaba 集成良好,社区活跃,有生产案例
上图展示了支付交易的状态机流转。每个状态都由一个 Saga 参与者(Participant)实现,Saga 状态机负责编排执行和补偿回滚。
三、Seata Saga 模式的核心实现
Saga 模式的核心是状态机定义(SML JSON)和补偿逻辑。状态机定义描述了事务的每一步,包括正常执行路径和异常时的补偿路径。
{ "Name": "payment-transaction", "Comment": "聚合支付交易流程", "StartState": "CreateOrder", "Version": "1.0", "States": { "CreateOrder": { "Type": "ServiceTask", "ServiceName": "orderService", "ServiceMethod": "createOrder", "CompensateState": "CancelOrder", "Next": "RiskCheck", "Input": ["$.[orderRequest]"], "Output": {"orderId": "$.orderId"} }, "RiskCheck": { "Type": "ServiceTask", "ServiceName": "riskService", "ServiceMethod": "checkRisk", "CompensateState": "UnfreezeRiskLimit", "Next": "PayChannel", "Input": ["$.[orderId]"], "Output": {"riskPassed": "$.riskPassed"} }, "PayChannel": { "Type": "ServiceTask", "ServiceName": "channelService", "ServiceMethod": "payViaChannel", "CompensateState": "RefundPayment", "Next": "UpdateAccount", "Input": ["$.[orderId]", "$.[channelType]"], "Output": {"transactionNo": "$.transactionNo"}, "Retry": [ {"Exceptions": ["com.network.TimeoutException"], "Interval": ["5s"], "MaxAttempts": 3}, {"Exceptions": ["com.business.InsufficientBalanceException"], "Next": "InsufficientBalance"} ] } } }Java 侧的参与者实现需要遵循 Seata 约定:每个参与者方法要实现正操作 + 补偿操作。
/** * 渠道扣款服务的 Saga 参与者实现 * 实现正操作(payViaChannel)和补偿操作(refundViaChannel) */ @Service public class ChannelPaymentParticipant { private final PaymentChannelClient channelClient; private final TransactionLogRepository logRepository; public ChannelPaymentParticipant(PaymentChannelClient channelClient, TransactionLogRepository logRepository) { this.channelClient = channelClient; this.logRepository = logRepository; } /** * 正向操作:调用第三方支付渠道发起扣款 * 返回值会被 Saga 状态机写入上下文,供后续步骤使用 */ public ChannelPayResult payViaChannel(String orderId, String channelType) { try { // 记录事务日志,用于后续对账和补偿依据 TransactionLog log = TransactionLog.create(orderId, "PAY_CHANNEL"); logRepository.save(log); ChannelPayResult result = channelClient.pay( orderId, ChannelType.fromCode(channelType)); if (!result.isSuccess()) { throw new PaymentException("渠道扣款失败, orderId: " + orderId + ", channelCode: " + result.getErrorCode()); } // 更新日志状态 log.markSuccess(result.getTransactionNo()); logRepository.save(log); return result; } catch (PaymentException e) { // 业务异常直接抛出,Saga 状态机会触发补偿流程 throw e; } catch (Exception e) { // 网络/框架异常包装后抛出 throw new SagaException("渠道扣款异常, orderId: " + orderId, e); } } /** * 补偿操作:发起退款 * 必须支持幂等——同一笔订单多次调用退款不会重复执行 */ public ChannelRefundResult refundViaChannel(String orderId, String transactionNo) { try { // 幂等校验:检查是否已退款 TransactionLog existRefund = logRepository .findByOrderIdAndType(orderId, "REFUND_CHANNEL"); if (existRefund != null && existRefund.getStatus() == TransactionStatus.SUCCESS) { log.info("退款已处理,跳过重复补偿, orderId: {}", orderId); return new ChannelRefundResult(true, existRefund.getRefundNo()); } ChannelRefundResult result = channelClient.refund( orderId, transactionNo); if (!result.isSuccess()) { // 退款失败不阻塞补偿流程,记录到人工处理队列 logRepository.save(TransactionLog.createRetryTask( orderId, "REFUND_CHANNEL", transactionNo)); log.error("退款补偿失败,已加入人工处理队列, orderId: {}, error: {}", orderId, result.getErrorCode()); } return result; } catch (Exception e) { log.error("退款补偿异常, orderId: {}", orderId, e); // 补偿异常不向上抛出,由定时任务兜底 return new ChannelRefundResult(false, "SYSTEM_ERROR"); } } }以上代码体现了 Saga 模式的两个关键工程实践:
- 幂等性:补偿操作必须先检查是否已执行,防止重复执行导致重复退款
- 补偿失败不阻塞:补偿异常不能阻碍 Saga 状态机的流转。对于补偿失败的案例,通过定时任务扫描
TransactionLog中状态为RETRY_PENDING的记录,进行人工介入或自动重试
四、生产环境落地后的数据对比与监控体系
Seata Saga 上线后,我们在灰度环境做了为期两周的对照实验:
- 数据一致性:上线前每日对账差异笔数平均 12 笔,上线后降为 0 笔(对账差异原因变为第三方渠道超时导致的双边状态不一致,而非内部事务问题)
- 交易成功率:从 99.82% 提升到 99.95%(提升来自补偿机制自动修复了一部分因瞬时故障导致的失败)
- P99 延迟:增加了约 15ms(Saga 状态机编排带来的额外开销,在可接受范围内)
监控体系围绕分布式事务的"可观测性"建立:
- Saga 状态机执行轨迹追踪:通过 Seata 自带的
seata-saga-statemachine-designer可视化工具,可以实时查看每笔交易的状态机流转路径 - 补偿成功率监控:统计
TransactionLog中补偿操作的成功/失败比例,设定补偿失败率超过 1% 时触发告警 - 分布式事务超时监控:监控 Saga 全局事务从开始到结束的总耗时,超过 60 秒的交易做标记分析
五、Seata Saga 模式的适用边界与替代方案
Seata Saga 模式不是银弹。根据团队的使用经验,它的适用边界是:
适合的场景:长业务流程(5~10 步)、需要同步返回结果的交易、对数据一致性要求高的场景(支付、订单、库存扣减)
不适合的场景:超高并发(TPS > 5000 时状态机编排成为瓶颈)、纯异步通知(用事务消息更合适)、需要人工审批的长流程(Saga 不适合"挂起等待")
当 Saga 模式过于重量级时,两个轻量级替代方案值得考虑:
- 本地消息表 + 定时任务:在数据库事务中同时写入业务数据和待发送消息,用定时任务扫描未发送消息并重试。实现简单,不需要引入额外中间件
- RocketMQ 事务消息:利用 RocketMQ 的"半消息"机制实现生产者侧的事务保障。适合"发消息即事务成功"的异步场景
支付系统的分布式事务没有标准答案,关键在于先定义数据不一致的可容忍程度,再选择匹配的方案。对于金融级交易,可容忍度是零,那就必须投入足够的设计和工程资源来保证。