首页 / 资讯中心 / 文章详情

分布式事务选型:Seata与RocketMQ在返利系统中的实战对比

分布式事务选型:Seata与RocketMQ在返利系统中的实战对比 ★ FEATURED ARTICLE
做返利系统最怕什么不是用户量小也不是佣金率配错而是大促之后对账那天发现用户账户里的返利余额跟后台财务记录对不上。差几块钱还好说差几万块那基本就要通宵加班翻流水了。我接手过一套联盟返利系统订单从确认收货到返利入账要跨订单中心、返利计算服务、用户账户中心三个微服务落数据任何一个节点失败账就平不上。这个场景就是典型的分布式事务难题而Java生态里解决它绕不开两条主流路线Seata框架的全局事务方案以及RocketMQ的事务消息方案。这篇文章不打算给你堆概念就把我实际做返利结算时用这两种方案的完整心得拆开说清楚——核心机制、落地步骤、踩过的坑以及到底什么场景该选哪条路一次讲透。1. 返利结算的分布式事务困境问题到底出在哪1.1 一笔返利订单背后到底动了几张表很多刚接触分布式事务的同学第一反应是“不就是改几个表吗用本地事务包起来不就行了”。等真正把返利链路拆开看就知道事情没那么简单。一条典型的返利订单从确认收货到用户看到返利到账大致经过这四步联盟平台推送订单确认收货的消息返利系统把订单状态落库然后根据商品佣金率计算返利金额再调用用户账户服务增加余额最后写一笔返利流水明细。光这一步就涉及订单表、佣金配置表、返利明细表、用户余额表、资金流水表至少五张表分布在三个服务里。麻烦的地方在于订单状态更新和返利明细写入是在返利服务里用户余额和流水是在账户服务里。如果账户服务在加钱时失败了返利服务这边订单已经标记为“已返利”用户就白白丢了一笔钱反过来如果返利明细没写好但余额加了用户确实多了钱平台却在对账时找不到这笔返利的来源月底盘账直接红字。单机时代这个问题根本不存在靠数据库事务一把梭就行。但微服务化之后每个服务用自己的数据库实例跨库的原子性已经超出了本地事务的能力范围这就是分布式事务问题产生的根源。1.2 为什么本地事务和普通补偿在这里都不够用有的团队会说那我不用分布式事务框架手动写补偿行不行比如账户服务失败时调用一个反向接口把订单状态改回去。理论上可行但实际操作中补偿逻辑的维护成本高得离谱。你得给每个业务步骤都写正反两套方法还要处理补偿本身失败的情况那就要有更底层的兜底一层套一层代码很快就成了意大利面。本地事务完全无解是因为它只能管自己库里的那几张表。而对账需求又要求返利明细和用户余额必须最终一致不能出现“明细有、余额没有”或者“余额有、明细没有”的情况。也就是说问题的本质是跨服务的数据原子性。既然本地事务管不了就需要专门的一致性方案来兜底。此时面前有两条路一是引入Seata这类全局事务框架在应用层模拟出一个跨服务的“大事务”二是改用RocketMQ的事务消息把“要保证原子性的那几步”拆解成“本地事务可靠消息”用异步重试来收敛最终结果。两条路线解决同一个问题但设计哲学完全不同接下来我分别展开。1.3 两条路线的本质区别强一致与最终一致一句话总结我的理解Seata走的是“把分布式操作重新装回一个事务框里”它让跨服务调用在业务上看起来像本地事务一样要么全成功要么全回滚RocketMQ事务消息走的是“拆弹专家路线”先把核心结果固化到本地再通过消息可靠投递把后续动作通知出去接受短暂的中间状态只要最终对上账就行。这个选择直接决定了后面的业务代码怎么写、性能上限在哪里、甚至故障排查的方式。所以我建议你先按下技术人员喜欢钻研细节的习惯把这两条路线的宏观差异想明白再去看具体实现。2. Seata AT模式让跨服务操作“看起来像本地事务”2.1 AT模式的核心机制与全局锁原理Seata里用得最多的AT模式从名字上看是“自动补偿事务”Automatic Transaction实际做的是用代理数据源的方式在执行业务SQL前后自动记录数据快照二阶段通过快照对比做回滚或者清理。具体拆一下流程。第一阶段事务发起方TM向Seata的Server端TC申请一个全局事务IDXID各个参与服务在收到这个XID后把自己的分支事务注册到TC上。每个分支事务执行时Seata会拦截SQL在业务数据更新前记录一次旧值镜像before image更新后再记录一次新值镜像after image两条镜像加当前操作的行主键一起写入undo_log表。此时业务数据已经被真的修改了但全局事务还没提交这些修改对其他全局事务是不可见的通过全局锁隔离。第二阶段如果所有分支都成功TM通知TC发起全局提交Seata会异步删除每个分支的undo_log记录完成提交如果有任何一个分支失败TC通知所有分支执行回滚Seata不是用SQL把数据改回去而是拿undo_log里的before image反算出一个反向UPDATE或DELETE语句补偿回去。整个过程对业务代码几乎无侵入这是AT模式最大的卖点。这里有全局锁机制值得单独说道。AT模式在更新数据前会先尝试获取该行记录的全局锁拿到锁才执行本地更新。这个锁由TC统一管理目的是防止两个不同全局事务并发修改同一行数据导致镜像错乱。代价就是高并发下同一行数据是串行写入的这对热点账户余额这种行来说性能瓶颈非常明显。2.2 落地步骤注册中心、数据源代理、undo_log表Seata的落地我梳理成三个必须做对的事情。第一件事是部署Seata Server端。它需要注册到一个配置中心常见的是Nacos、Consul也可以直连数据库承担TC的角色。Server本身要连接一个独立的数据库用于管理全局事务会话和全局锁信息。这一步要特别注意如果Server挂了所有依赖它的全局事务都会卡住所以生产环境至少要部署两个节点做高可用千万不能因为图省事就单节点跑。第二件事是给客户端接入依赖并配置数据源代理。在Spring Boot项目里引入seata-spring-boot-starter后关键配置是seata.tx-service-group和注册中心地址但最容易出错的其实是数据源代理。AT模式必须对数据源做代理才能拦截SQL生成undo_log我见过不少团队配了注解却忘了代理数据源结果Seata完全没有工作还找不出原因。如果项目里用了MyBatis扫描到的DataSource必须是SeataDataSourceProxy包装过的。多数据源场景更麻烦每个数据源都要单独包装。第三件事是建undo_log表。这张表的DDL里有几个关键字段branch_id分支事务ID、xid全局事务ID、rollback_infobefore/after镜像的序列化内容、log_status、log_created、log_modified。官方推荐的DDL可以直接用我这里补充一个容易忽略的点rollback_info字段务必用longtext或blob字符集尽量用utf8mb4。我遇到过因为字段长度不够业务SQL稍微复杂一点就把镜像撑爆导致二阶段无法回滚的情况。2.3 代码层面的使用姿势与边界控制代码使用其实很简单核心就一个注解GlobalTransactional。我贴一段返利结算的伪代码Service public class RebateSettlementService { GlobalTransactional(rollbackFor Exception.class, timeoutMills 60000) public void settleRebate(String orderId) { // 1. 更新订单状态为已结算返利服务本地库 orderDao.updateStatus(orderId, SETTLED); // 2. 计算返利金额写入返利明细返利服务本地库 RebateDetail detail rebateCalculator.calculate(orderId); rebateDetailDao.insert(detail); // 3. 调用账户服务增加用户余额跨服务调用 accountClient.increaseBalance(detail.getUserId(), detail.getAmount()); // 4. 账户服务内写入资金流水账户服务本地库 // 这一步在accountClient内部完成也在同一个XID下 } }是不是看着特别像本地事务在账户服务的方法上也要加上GlobalTransactional并且通过RootContext.getXID()拿到上游透传过来的XID注册成同一个全局事务下的分支。这里有个关键透传逻辑Feign调用时需要把XID塞到请求头里传递Seata的starter一般内置了拦截器但如果你用了一些自定义的RPC框架一定要自己实现XID的透传否则下游服务无法加入同一个全局事务。注解用起来简单边界控制才是真正的功力所在。我的经验是全局事务里绝对不要做远程网络调用以外的事情尤其不要发MQ消息。为什么因为发送消息本身是外部IO如果消息发出去之后、事务还没提交之前另一个分支失败回滚了消息却已经发出去了消费者就会拿到一个“本应被回滚”的数据。这种不一致比事务自己失败更可怕因为消息一旦发出就很难追回。另外事务超时时间要设置而且要根据实际链路耗时调。我一开始用的默认值结果高峰期账户服务处理得慢全局事务超时被TC标记为回滚但业务线程还在继续跑最后数据就乱了。后来统一设置成60秒并配合对账任务兜底才算稳下来。3. RocketMQ事务消息先发消息再谈交付3.1 半消息与事务状态回查机制RocketMQ的事务消息设计思路跟Seata完全不一样。它的核心思想是生产者本地事务和消息发送必须保证原子性但这里不是靠锁而是靠“延迟提交”来做的。流程是这样的生产者先把一条“半消息”half message发送到Broker这条消息在特殊的状态下对消费者不可见。然后生产者执行自己的本地事务比如更新订单状态、扣减库存。执行完之后生产者根据本地事务的执行结果向Broker发送Commit或Rollback请求Broker收到Commit后这条半消息才会对消费者可见Rollback则直接销毁半消息。那如果生产者执行完本地事务后还没来得及告诉Broker结果就宕机了呢这时候Broker会启动事务状态回查机制Broker定时向生产者发回查请求询问“你这笔本地事务到底成没成功”。生产者的回查接口需要去数据库查询本地事务的状态返回Commit或Rollback。这个机制解决了一个很微妙的问题消息的可靠性不靠网络保证靠Broker的回查兜底把“消息发送结果”和“本地事务结果”绑定在一起。这个思路在资金类业务里特别实用因为它不需要长时间的全局锁代价就是消费者侧必须容忍消息的延迟到达以及处理消息重复。3.2 返利场景的完整流程设计订单确认→返利计算→入账用事务消息做返利结算流程可以这么设计用户确认收货后订单中心发布一个“订单已确认”的半消息同时在自己的数据库里把订单状态更新为“待返利”然后Commit这条半消息。返利服务消费到消息后执行返利计算、写入返利明细再调账户服务加余额。这样整个链路虽然长但每一步之间都是消息解耦的任何一个环节失败了消息都会在重试队列里反复消费直到成功或触发人工介入。这里有一个跟Seata方案不同的地方返利明细的写入和余额的增加天然变成了两个独立步骤中间可能隔了几秒甚至更久。所以消费者的代码必须设计成幂等的否则消息重复消费一次用户余额就会翻倍加。我贴一个事务监听器的骨架代码public class RebateOrderTransactionListener implements TransactionListener { Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { try { // 本地事务更新订单状态为“待返利” OrderBiz.updateRebateWait(orderId); // 本地事务成功了返回COMMIT让半消息对消费者可见 return LocalTransactionState.COMMIT_MESSAGE; } catch (Exception e) { // 本地事务失败返回ROLLBACK销毁半消息 return LocalTransactionState.ROLLBACK_MESSAGE; } } Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { // 回查去数据库查订单状态判断到底该Commit还是Rollback String orderId msg.getKeys(); boolean success OrderBiz.checkRebateValid(orderId); return success ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }注意checkLocalTransaction里不能依赖本地缓存或者内存状态必须查数据库因为回查发生在进程重启之后内存数据早就没了。我见过一次线上事故回查方法里去查Redis正好Redis缓存过期了回查结果一直返回UNKNOW消息卡在中间状态十几个小时用户一直收不到返利客诉被打爆。3.3 幂等消费与消息堆积的兜底设计事务消息方案里消费者侧的幂等设计是绝对的核心。因为RocketMQ的分布式特性决定了消息可能重复投递消费端必须自己做到“重复消费同一条消息效果跟消费一次一样”。我的做法是在返利明细表上建唯一索引字段是order_id和user_id的组合键消费时先尝试插入明细如果插入报唯一键冲突说明已经处理过了直接返回消费成功跳过加余额的步骤。这个办法简单粗暴但非常可靠比查一次表再决定是否处理要稳妥得多因为查表和插入之间永远存在竞态窗口唯一索引把这个窗口彻底堵死了。消息堆积的问题也要提前设计。大促期间订单量暴增如果返利服务消费能力跟不上消息会堆积在Broker里用户返利到账会越来越慢。我给返利消费者设置了独立的消费线程池并且把返利计算这类CPU密集型操作和数据库写入分开计算可以并行做写入控制并发数避免数据库连接池被打满。另外消息里带orderId作为消息的keys字段排查问题时可以按keys快速检索消息轨迹。4. 硬核对比一致性、性能、复杂度三个维度的取舍4.1 一致性保障力度对比很多人觉得“最终一致”听起来就不如“强一致”高级其实在返利场景里最终一致完全够用关键看你怎么设计。Seata AT模式提供的是全局事务级别的隔离读已提交数据时不会读到未提交的中间态对于资金敏感、需要实时反馈的操作这是优势。但它的“强一致”也是有边界的如果全局事务执行期间另一个服务直接改了同一行数据而没有通过Seata那保障就不存在了。也就是说Seata要求所有参与者必须接入同一个事务框架有一方不接入整个链条的保障就断掉了。RocketMQ事务消息的最终一致靠的是“本地事务成功→消息可靠投递→消费端幂等处理”这条链。它不保证实时一致但通过回查和重试机制理论上消息最终会被处理掉数据最终会一致。问题在于这个“最终”的时间窗口是多长取决于回查频率、消费堆积情况、以及消费者是否一直挂掉。如果消费者宕机一周那一周内用户看到的返利状态就是缺失的。从一致性的“确定性”来说Seata更直观出了问题可以直接看全局事务的状态RocketMQ则需要通过消息轨迹和消费进度来推断状态。但如果你的业务能接受秒级甚至分钟级的延迟RocketMQ的最终一致性方案在业务侵入和性能上都要优于Seata。4.2 性能开销与集群资源对比性能是这两种方案最明显的分水岭。Seata AT模式的性能开销主要来自三块一是每条SQL执行前后的镜像记录和undo_log写入相当于原本一次数据库更新变成了“查询旧值更新新值写undo_log二阶段删undo_log”多次数据库操作写放大明显。二是全局锁机制高并发下对同一行热点数据的串行化写入会直接把吞吐拉下来。三是所有分支事务都要跟TC做网络通信注册、上报、提交/回滚链路多了之后网络开销不容忽视。我实测过一个账户加余额的接口在Seata保护下TPS比裸接口掉了40%左右这还是乐观情况。RocketMQ事务消息的额外开销主要是发送半消息和事务回查。半消息发送本身很快本地事务执行跟普通业务代码没区别回查是低频的由Broker定时触发不会成为瓶颈。整体来看事务消息方案对数据库和CPU的占用要低得多吞吐量的上限基本取决于你的消费并发设计。我那个返利系统用事务消息方案扛过单日百万级订单量的促销活动消费端延迟控制在几秒到几十秒级别完全在可接受范围内。所以如果业务对实时性要求极高比如用户点完立即要看到结果那Seata更合适如果只是需要把账最终算对RocketMQ明显更适合高并发场景。4.3 选型决策矩阵什么场景必须用谁我结合自己的实战经验给一个选型建议表方便你直接对照着判断判断维度倾向Seata倾向RocketMQ事务链路长度3个服务以内越短越好链路长参与服务多实时一致性要求高用户操作后需要立即生效可容忍秒级到分钟级延迟并发压力中低热点行写入不密集高并发大促洪峰场景团队对框架的掌握度能维护Seata Server高可用已有RocketMQ集群运维能力资金安全敏感度高需要实时知道事务结果可通过对账任务兜底跨语言服务数量全部是Java服务允许部分非Java服务消费消息一个比较典型的反面案例我之前遇到一套返利系统订单确认链路里有一环是调用外部快递查询接口这个接口经常超时。把整个链路用Seata包起来之后每次超时都会触发全局事务回滚订单状态被反复改来改去用户收到一堆重复的确认通知。后来改成RocketMQ事务消息主流程只保订单状态更新这个本地事务查询快递这种外部依赖完全异步化问题迎刃而解。凡是涉及不可控外部依赖的都别往Seata里塞这一点务必记住。5. 常见问题与排障攻略5.1 Seata最容易踩的坑和处理方式实际运维Seata时我列几个高频问题和对应的处理思路都是自己踩过的。第一个坑字段字符集导致回滚失败。之前说过undo_log的rollback_info字段如果用的不是utf8mb4当业务数据里有表情符号或者生僻字时镜像序列化后的内容可能存不进去二阶段回滚直接报错。这个问题的排查特征是全局事务明明标记回滚了但业务数据没有变回去错误日志里能找到Data truncation相关的提示。解决方式就是扩容字段和改字符集。第二个坑全局事务里嵌套发MQ。前面强调过Seata事务里发MQ要格外小心因为MQ消息的发送和事务提交不在同一个原子边界里。如果你非要发我建议改成事务消息方案或者把发MQ移到全局事务提交之后通过TransactionSynchronizationManager注册事务同步回调在afterCommit里发消息这样至少能保证消息只在事务提交后才发出去。第三个坑超时时间设置不合理。我见过不少团队用默认的全局事务超时时间结果遇到慢SQL或者GC停顿导致事务超时TC单方面回滚了但业务线程还在继续执行最后数据错乱。排查这类问题要看TC的日志里有没有“Global transaction rollbacked due to timeout”之类的记录然后结合业务日志看线程是否还在跑。解决思路是先优化链路耗时再根据实测值设一个合理的超时时间宁可多等几秒也不要轻易让事务超时。第四个坑数据源代理遗漏。补充一个检查方法启动项目后去看Seata的日志里是否输出“Dynamic DataSourceProxy is active”之类的初始化信息没有的话说明你的数据源没有代理成功。这种问题官方文档里写得没这么细但对新手来说是特别容易卡住的一步。5.2 RocketMQ事务消息最容易踩的雷事务消息方案的坑也不少我挑三个影响最严重的说。回查逻辑返回UNKNOW导致消息卡死。前面提到过checkLocalTransaction里如果因为接口异常或者返回了UNKNOW状态Broker会按固定间隔反复回查默认是1分钟一次。如果回查一直不返回明确结果消息会一直卡在半消息状态。后来我把回查方法做了简化直接查订单状态表如果查到了且状态是已确认返回COMMIT没查到说明事务还没提交返回UNKNOW让Broker继续等但要控制回查方法的异常不要让它抛出未捕获的异常否则回查可能被中断。消费端没有幂等导致余额翻倍。这个问题是事务消息方案里最致命的。RocketMQ默认消费模式是“至少一次”加上网络不确定性重复投递几乎必然发生。除了建唯一索引我还会在消费方法开头写一个分布式锁用Redis或者数据库行锁双重保险。特别提醒不要以为只有一次消费就能跳过幂等设计线上环境的重复消费概率比你想的高得多。生产者和消费者的消息轨迹查询困难。事务消息出问题时要快速定位消息到底卡在哪一步光靠日志是看不懂的。RocketMQ控制台提供消息轨迹查询功能但需要你在发送时设置好keys我建议把orderId作为消息的唯一key这样排查问题时一搜就能看到半消息发送、提交、消费的整个时间线。5.3 两种方案混用的架构实践什么场景该混合用最后分享一下我现在的做法两套方案并不是水火不容一个系统里完全可以混合用。我的返利系统中用户的“确认收货→返利计算→返利明细落库”这3步核心资金链路用的是Seata AT模式因为这里必须拿到全链路成功的结果才能下一步操作而且服务就两个链路短Seata的开销可以接受。等返利明细确认落库后后面的“通知用户、推送报表、同步财务”这些动作全部走RocketMQ事务消息异步发出去失败了靠消息重试就行不需要硬凑进全局事务。这样拆的好处是核心资金操作有强一致保障非核心动作充分异步化两边的优势都放在合适的位置。代价是系统里同时存在两个事务方案对团队的能力要求高一些需要分别维护两端。但如果你想做一个承载千万级用户返利业务的系统这种混合架构基本是必经之路。写在最后的小经验我在反复折腾这两种方案之后最大的体会是做技术选型别光盯着框架的能力列表多想想你的业务链路到底能接受多大的延迟、你的团队能维护几套基础设施。Seata和RocketMQ都是成熟方案没有谁绝对优于谁只有“适不适合你当前的场景”。如果让我给第一次做返利系统的人一个建议我会说优先考虑RocketMQ事务消息做好消费端幂等和消息轨迹监控它是成本最低、最不容易出大事故的方案等业务链路扩展到对实时一致性有硬性需求时再在短链路上引入Seata稳扎稳打才不容易翻车。
阅读完成 · 觉得有帮助?
咨询建站