做返利系统最头疼的往往不是佣金算错,而是佣金算了却入不了账。淘宝客返利这类业务,天然就是分布式事务的重灾区:订单在电商侧生成,返利平台要同步订单、判断订单有效性、计算预估佣金、等确认收货后生成可结算返利,最后再把钱打到用户余额里。每一步都跨服务、跨数据库,任何一个环节出现“做了一半”的中间状态,用户端看到的就是返利丢失或重复到账。这篇文章我就围绕分布式事务这个核心,把 Seata 和 RocketMQ 两条最终一致性技术路线放在返利场景里做一次完整对比,讲清楚各自的原理、适用边界、落地细节,以及我在压测和线上排查中踩过的坑。如果你正在设计返利、分销、结算清分这类系统,或者正在纠结到底选 Seata 还是 RocketMQ 事务消息,这篇内容应该能帮你省不少时间。
1. 返利链路里的一致性难题到底出在哪
1.1 一条返利订单背后要经过多少道坎
先还原一下业务链路。用户通过返利渠道去电商平台下单,支付完成后电商侧会产生订单数据,返利平台的任务是把这张订单同步到自己的订单中心。这里面第一道坎就来了:订单同步完成不代表用户不会退货,所以系统要等订单状态流转到“确认收货”或“结算完成”,才能判断这笔返利是否有效。
接下来是佣金计算。返利规则往往不是固定比例,不同商品类目、不同用户等级、不同活动周期,对应的佣金比率都不一样。订单数据到了佣金服务之后,要先做规则匹配,然后把算出来的预估佣金写入返利明细表。再往后是入账阶段,返利平台要把这笔金额从“待结算”更新成“已入账”,同时给用户的账户余额做增加操作。
这里要注意,订单系统、佣金系统、账户系统通常是三个独立部署的服务,数据库也各自独立。订单同步成功是写在订单库,佣金计算成功是写在佣金库,账户入账成功是写在账户库。三个库之间没有本地事务可讲,任何一个服务的写操作成功,都不代表另外两个服务也能写成功。
最常见的问题就是:佣金明细写好了,状态也改成“待入账”了,但是账户服务调用超时,用户余额没加。或者反过来,账户余额加了,返利明细状态没更新,第二天对账一看,钱多发了一笔。这种问题在单体时代根本不存在,一旦拆成微服务,就变成每天都要处理的“薛定谔的返利”。
1.2 为什么2PC和本地消息表都不够理想
面对这种跨服务数据一致性问题,很多人的第一反应是上两阶段提交(2PC)。两阶段提交确实能保证多个参与者要么都提交、要么都回滚,但它的问题也很致命:准备阶段要把所有参与者的资源锁住,协调者要等所有参与者返回准备成功才发提交指令。在高并发场景下,只要有其中一个库响应慢,全局事务就要拖着所有库一起等,数据库连接池先被打满,然后是超时雪崩。
返利系统的特点是日订单量大、峰值流量集中,尤其大促期间,订单同步消息是每秒几千条往队列里灌。在这种流量下搞2PC,等于给每个跨库操作发了一张长期占用的锁票,系统吞吐量会直线往下掉。所以生产环境里我几乎不会用2PC来做返利链路的一致性。
还有本地消息表方案,思路是在本地事务里同时写业务数据和消息表,然后异步把消息发给MQ,下游消费成功后更新消息状态。这个方案实现简单,但它本质上要求业务表和消息表在同一个库,否则“本地事务”就名存实亡。返利系统的订单库和账户库根本不在一起,消息表无论放哪边,都没办法保证业务数据和消息数据同生共死。而且自己维护消息状态、轮询重试、失败补偿,代码量不小,后期维护成本很高。
1.3 最终一致性不是妥协,而是可接受的工程选择
分布式系统里有CAP理论,说的是网络分区时,一致性和可用性只能选一个。返利业务和银行扣款不一样,用户能接受“下单之后返利不是秒到”,甚至平台规则本身就写了返利要等确认收货后到账,天然给系统留出了一段异步处理时间。所以BASE模型里的最终一致性,才是更贴合返利业务的工程选择。
最终一致性不是说不处理异常,而是承认中间状态存在,但通过重试、补偿、对账等手段,把这个中间状态在可预期的时间内收敛成终态。Seata 和 RocketMQ 这两个方案,本质上都是在解决同一个问题:如何把“不确定”变成“确定”,只是手段完全不同。理解了这一点,后面所有设计决策都会清晰很多。
2. 两个方案的底层原理对比
2.1 Seata AT模式:面向短事务的自动回滚
Seata是Java生态里使用率比较高的开源分布式事务框架,AT模式是它最常用的事务模式。AT模式里有三个核心角色:TC是事务协调者,负责全局事务的开启和提交回滚;TM是事务管理器,业务代码里发起全局事务的一端;RM是资源管理器,负责管理各个分支事务。
AT模式的设计思路是:业务SQL正常执行,同时框架自动生成undo_log回滚日志。第一阶段,每个分支事务直接提交本地数据库事务,把数据库行锁释放掉,性能比传统2PC好不少。第二阶段,如果全局事务所有分支都成功了,就异步清理undo_log;如果有任何一个分支失败,TC会通知所有RM,RM根据undo_log里的前后镜像数据,执行反向SQL把数据恢复原状。
对业务代码来说,AT模式的侵入非常低,很多时候只需要在方法入口加一个@GlobalTransactional注解,原来的本地事务就能纳入全局事务管理。这个特性让Seata在Java技术栈里很受欢迎,但代价是全局锁竞争和undo_log的写入清理开销。事务分支越多、执行时间越长,全局锁被占用的时间就越久,并发冲突的概率也会线性上升。
2.2 RocketMQ事务消息:把不确定交给回查
RocketMQ事务消息的思路和Seata完全不同,它解决的是“本地数据库操作”和“发MQ消息”这两件事的一致性。核心机制分三步:第一步,生产者先发送一条半消息,这条消息对消费者不可见;第二步,生产者执行本地事务,比如在返利系统里更新订单同步状态;第三步,根据本地事务执行结果,对半消息提交Commit或Rollback。
这里最巧妙的是回查机制。如果生产者执行完本地事务之后进程崩溃了,或者Commit消息丢失了,Broker端会定时回调生产者的checkLocalTransaction接口,询问这条半消息对应的本地事务到底成没成功。只要本地事务结果没有确定,Broker就会一直查,直到得到明确结论。这个机制保证了“本地事务成功则消息必达,本地事务失败则消息不投递”。
从使用姿势上看,RocketMQ事务消息适合那些“先做本地操作,再通知下游”的场景。返利系统里典型的例子就是订单同步完成后要通知佣金服务开始计算,或者返利入账完成后要发消息给用户通知。本地事务和消息投递必须同时成立,否则下游就会基于错误状态继续干活。
2.3 关键差异对比表
| 对比项 | Seata AT模式 | RocketMQ事务消息 |
|---|---|---|
| 一致性模型 | 全局短事务,二阶段提交思想 | 本地事务与消息投递的一致性 |
| 事务范围 | 跨多个数据库的多个分支SQL | 单个服务本地事务 + 一条MQ消息 |
| 回滚方式 | 基于undo_log自动反向SQL回滚 | 本地事务失败则回滚半消息 |
| 业务侵入 | 低,注解即可接入;需加undo_log表 | 低,需实现TransactionListener |
| 性能特征 | 有全局锁竞争,事务耗时敏感 | 异步解耦,只有回查时有额外压力 |
| 适用场景 | 账户入账、库存扣减、状态强一致更新 | 订单同步通知、状态变更事件分发 |
| 主要风险 | 全局锁超时、undo_log膨胀 | 半消息堆积、回查接口压力大 |
表格里能看得很清楚,Seata管的是“多个库的多条SQL一起成功或一起失败”,RocketMQ管的是“本地库操作和发消息一起成功或一起失败”。放到返利系统里,账户入账这种多库强一致操作适合Seata,订单同步后发通知这种异步事件流适合RocketMQ。两者不是替代关系,更多是分工关系。
3. 在返利系统里怎么选、怎么落
3.1 返利入账:用Seata保证事务边界
返利入账是整个链路里对一致性要求最高的环节。用户确认收货后,系统要同时完成两件事:在返利服务里插入一条返利入账流水,把返利记录状态从“待入账”改成“已入账”;在账户服务里给用户余额增加对应金额。这两件事分属两个服务、两个数据库,必须保证要么都成功,要么都失败,不能出现流水写好了余额没加,或者余额加了流水还是待入账。
这种情况下用Seata AT模式最合适。业务代码只需要在聚合方法上标记全局事务,原有两个服务的本地事务会作为分支事务被自动管理:
@GlobalTransactional(name = "rebate-account-in", timeoutMills = 5000) public void settleRebate(Long userId, Long rebateId, BigDecimal amount) { // 分支一:返利服务更新返利流水状态 rebateService.markSettled(rebateId); // 分支二:账户服务增加用户余额 accountService.increaseBalance(userId, amount); }落地时有几个细节必须提前处理好。第一,涉及的两个库都要建undo_log表,建表脚本可以用Seata官方提供的模板,注意表结构和业务表必须在同一个库,否则分支事务无法自动回滚。第二,全局事务内不要塞远程调用,尤其不要调那种没有超时保护的外部HTTP接口,否则事务耗时会被无限拉长,全局锁把行锁住的期间,其他并发事务只能干等。第三,要给业务操作设计幂等键,Seata只能保证事务一致,不能防止调用方重试导致重复执行。
我给一个返利团队出过这类方案,上线初期他们把商城商品信息同步也放进了Seata事务,远程调用一慢,整个全局事务直接超时,线上告警响了一晚上。后来把不影响资金正确性的操作全部拆出去,Seata只保留账户入账和返利流水更新这两个强一致动作,问题立刻缓解。
3.2 订单同步与回执通知:用RocketMQ事务消息
返利系统里还有一类典型场景:订单同步完成之后,需要发一条消息给佣金服务触发计算。问题在于,如果先更新订单同步状态再发MQ,发送失败就会导致佣金服务永远收不到消息;如果先发MQ再更新状态,下游消费消息时看到的状态可能还是“未同步”,业务逻辑会判断错误。
RocketMQ事务消息正好能解决这个顺序问题。发送半消息先到Broker暂存,本地事务里更新订单同步状态,更新成功后Commit半消息,佣金服务立刻就能消费到有效数据。本地事务失败,半消息被Rollback,不会对下游产生脏消息。
核心实现是TransactionListener,示例代码长这样:
producer.setTransactionListener(new TransactionListener() { @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 本地事务:更新订单同步状态 orderSyncService.markSynced((Long) arg); return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { return LocalTransactionState.ROLLBACK_MESSAGE; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查:根据订单号查同步状态 Long orderId = Long.valueOf(msg.getUserProperty("orderId")); if (orderSyncService.isSynced(orderId)) { return LocalTransactionState.COMMIT_MESSAGE; } return LocalTransactionState.ROLLBACK_MESSAGE; } });这里有个重点:消费端必须做幂等。RocketMQ的普通消息是At Least Once语义,事务消息Commit之后同样可能被重复投递。佣金服务消费消息时如果先查一下订单是否已经计算过佣金,就能杜绝同一笔订单被重复计算。我的习惯是在消费逻辑入口用订单号+业务类型做唯一键插入,插入失败直接跳过。
3.3 混合方案与避坑原则
很多文章喜欢把Seata和RocketMQ事务消息写成二选一,但我实际做返利系统时是混合使用。账户资产变动、返利流水状态翻转这类强约束操作走Seata,订单同步、返利入账完成通知、佣金计算触发这类异步解耦操作走RocketMQ。这样设计,既保证了资金关键链路的一致,又避免了全链路Seata带来的锁竞争风险。
我给自己定过几条选型原则,可以给你参考。第一,先问数据错了会造成什么后果,账户余额错了要立即修复,这类就上Seata;通知漏一条可以靠对账弥补,这类就用RocketMQ。第二,能不改系统结构就不引入分布式事务,很多场景其实用本地事务加定时任务就能闭环。第三,无论选哪个方案,必须配置对账任务,最终一致性系统的底线永远是核对和补偿。
4. 压测与线上问题排查实录
4.1 全局锁等待:Seata在高并发下的瓶颈
Seata AT模式有个经典问题:全局锁等待超时。我做过一次返利入账的压测,单事务耗时只要超过200毫秒,并发到500线程时,数据库里就会冒出大量GlobalLockWaitTimeoutException。原因是同一个账户在短时间内连续入账几笔返利,多笔全局事务同时要更新同一行账户余额,后到的事务拿不到全局锁,一直在等待。
排查起来也比较直观,看Seata Server的监控面板,能发现事务平均耗时和锁等待时间都在飙升。这类问题的解决思路是缩短全局事务时间,减少分支事务数量,尽量让事务只包含必要的两个写操作。另外可以给@GlobalTransactional调大一点timeoutMills,但不要无脑调大,锁等待时间越长,后面的请求堆积越严重,系统整体吞吐反而下降。
还有个很容易忽略的坑是undo_log表膨胀。大促期间几个小时的入账量就能让undo_log涨到上千万行,如果不做定期清理,表越来越大,写入和回滚都会变慢。我后来写了定时任务按创建时间分区清理undo_log,线上稳定了很多。
4.2 事务回查风暴:RocketMQ消息堆积风险
RocketMQ事务消息最常见的问题是半消息堆积。正常情况下本地事务执行很快,Commit消息也很快,半消息不会积压。但一旦生产者服务出现IO异常或者数据库连接池耗尽,本地事务迟迟不返回结果,Broker就会不断触发回查,生产者数据库会被回查SQL打到高负载,形成恶性循环。
我有一次排查线上消息延迟,发现halfTopic的消费积压超过几万条,打开日志看到checkLocalTransaction大量超时。原因特别简单,回查接口里做了全表扫描判断订单状态,订单表数据量上来之后,这条SQL把数据库CPU打满了。解决办法是给回查SQL加索引,并且把回查结果做本地缓存,减少对数据库的重复访问。
另外要强调一点,回查逻辑必须幂等,同一个订单号查多少次,返回的Commit或Rollback结论都要一致。我见过因为缓存和数据库数据不一致,导致回查结果在Commit和Rollback之间反复横跳,最终消息被错误Rollback,下游丢了一整批订单通知。这种问题排查起来非常痛苦,建议在日志里把订单号、回查次数、状态结论全打出来,方便对比。
4.3 幂等与对账兜底
分布式事务能保证最终一致,但保证不了不丢消息、不重复执行。Seata的全局事务在极端情况下也有不确定窗口,比如TC宕机后重启,部分分支事务状态需要人工确认。所以幂等和对账不是可选项,是必须项。
我的经验是给每个关键业务表设置业务唯一键,比如返利流水表的order_id加rebate_type联合唯一索引,重复插入直接报错拦截。消费端做幂等判断时,尽量用数据库唯一约束而不是先查询再判断,因为先查再判断在多线程下还是会有竞态,唯一约束才是硬保证。
对账任务每天跑一次,核对三组数据:返利明细表的入账总额和账户余额变动总额是否一致;佣金计算表和订单状态表是否一致;RocketMQ消费进度和业务处理进度是否一致。任何一组对不上,就触发补偿流程,该重发的重发,该人工介入的生成工单。这套兜底机制比任何中间件都可靠。
4.4 问题排查速查表
| 异常现象 | 可能原因 | 排查思路 | 常用处理 |
|---|---|---|---|
| 大量全局锁等待超时 | 全局事务内包含远程调用或慢SQL | 查看Seata监控,定位锁冲突行 | 缩短事务范围,调大timeout,优化SQL |
| undo_log表持续膨胀 | 历史数据未清理 | 查看表空间和分区情况 | 定时归档清理,建分区表 |
| 半消息堆积 | 本地事务挂起或回查超时 | 查看回查日志和halfTopic积压 | 加索引、做缓存、监控半消息数量 |
| 回查结果不一致 | 缓存与数据库数据不一致 | 对比回查日志与业务表状态 | 去掉缓存或统一状态来源 |
| 消费重复执行 | 消息重复投递 | 查看消费日志中重复业务键 | 加唯一约束,幂等表兜底 |
| 账实不符 | 对账发现流水与余额不一致 | 用对账脚本定位差异明细 | 补偿脚本重放或人工修复 |
这张表是我在返利系统运维期间一点点攒出来的经验,每次遇到问题都先对着它猜一遍,基本能覆盖八成线上故障。
5. 最后分享点个人经验
做的分布式方案越多,我越倾向于把分布式事务当成最后手段,而不是默认手段。返利系统刚拆分微服务时,我一度想把所有跨服务操作都包上Seata,结果就是把系统的复杂度和故障半径一起变大了。后来我给自己定了一条规矩:只有资金资产这类强一致场景才上Seata,其他能异步的就异步,能用本地事务加消息兜底的就不要强行上全局事务。RocketMQ事务消息确实好用,但也要清醒认识到,它只是保证了消息和本地事务的一致性,下游消费仍然可能失败,消费端的幂等和重试机制一点都不能省。
还有一件事想单独嘱咐一下,监控一定要提前埋好。Seata的全局锁超时次数、RocketMQ的半消息数量、回查接口的耗时峰值,这些指标比业务日志更能提前暴露问题。我踩过几次坑之后,现在每接入一个分布式方案,第一件事就是先把监控面板建出来,再谈业务上线。毕竟最终一致性系统里,最怕的不是出错,而是出错了没人知道。