做交易系统的同学,应该都有过这种体验:本地数据库已经把订单状态改成“已支付”,下游账户服务也返回了成功,可第二天对账却发现,订单表和账户流水对不上。不是代码逻辑写错了,也不是数据库坏了,而是多个服务在分布式环境里各自提交事务,天然就没有“全局同时成功”这回事。
这个专题,是某跨平台交易系统性能优化系列里的第9个改造点,核心是事务型消息。它解决的问题很具体:让一条消息的发送,和本地数据库事务绑定起来,保证要么一起成功、要么一起失败,并且通过消息回查机制兜住最终一致性。适合正在折腾订单、支付、库存、积分这类链路,或者刚接触消息中间件分布式事务的同学。
我先把结论放在前面:事务型消息不是让你写出一个分布式事务神器,它是用一种“最终会一致”的方式,把交易链路里的掉单、重复扣款、对不上账这些问题控制在一个可控范围内。想用好它,必须把生产者侧的本地事务、消息回查,以及消费者侧的幂等设计一起做扎实。下面按我的落地过程来拆。
1. 为什么交易系统绕不开事务型消息
1.1 交易链路里最头疼的三个硬伤
刚开始做交易系统的人,最容易犯的一个错,就是把整条链路都写成同步调用:下单请求进来,先写订单库,再调库存服务扣库存,然后调积分服务加积分,最后返回客户端成功。
单看每一步都没问题,但一旦放到高并发和故障场景里,三个硬伤会立刻暴露出来。
第一个是跨服务调用的超时不确定性。你调用库存服务,对方处理了但响应超时,你这边到底是重试还是回滚?如果重试,库存可能被重复扣;如果回滚,对方可能已经扣成功了。任何一种选择都可能导致数据不一致。
第二个是长事务占用的资源问题。同步链路里,只要后面任何一个依赖服务响应慢,数据库连接、线程池、内存就全被占着。交易流量稍微一上来,数据库连接池先被打满,紧接着整个服务跟着雪崩。
第三个是补偿逻辑的复杂度。要解决前面两个问题,常规做法是加一张“补偿记录表”,把每一步执行结果记录下来,失败了就异步重试。这个思路没错,可一旦步骤变多,比如下单、支付回调、库存预占、积分累加、短信通知,五个环节的补偿状态互相耦合,代码写到最后自己都绕不清楚。
所以交易系统演进到一定规模,必然会把同步链路拆成异步消息链路。这是事务型消息能发挥价值的前提。
1.2 引入消息队列后,又出现了新的数据不一致
把下游逻辑改成消息驱动之后,新问题就来了:本地事务和消息发送之间,怎么保证一致?
做个最简单的推演。先发消息,再执行本地事务。这时候消息发出去了,接着本地事务执行失败,消费者已经看到那条消息并处理了,于是出现“下游知道有个订单,上游却回滚了”的脏数据。反过来,先执行本地事务,再发消息,本地事务提交成功了,可消息发送时网络抖动,发送失败,下游永远不知道这单已经创建了。这就出现了“上游说成功,下游不知道”的丢消息问题。
很多团队在这个阶段会引入本地消息表:在同一事务里,把业务数据和待发送消息一起写入本地库,然后由另外一个后台任务扫表发消息。这个方案能解决大部分丢消息问题,但我实际用下来的感受是,它对开发人员的要求很高。你得自己维护一张消息表,自己写扫表任务,自己设计重试策略,还要处理扫表任务重复发送的问题。一旦消息量大,这张表会变成新的热点,扫表延迟还会拉高整个链路的处理时间。
1.3 为什么没有直接上2PC或TCC
在选型阶段,团队内部也讨论过要不要上两阶段提交协议,或者TCC。
两阶段提交协议要求所有参与者在第一阶段先预提交,第二阶段再集体提交。好处是强一致,坏处也明显:整个事务窗口周期内,数据库资源一直被占着。交易场景一天几百万单,资源锁等待时间根本扛不住,而且只要有一个参与者宕机,整个事务卡死,可用性大打折扣。
TCC看起来灵活一些,但要求每个业务动作都实现Try、Confirm、Cancel三套操作。订单服务、库存服务、积分服务每个接口都要写三份逻辑,业务改一个字段,三处都要跟着改,半年之后维护成本非常高。
事务型消息是另一种思路:不强求所有系统同时成功,而是通过“先发一个半消息,再确认提交”的方式,保证消息的投递结果和本地事务结果一致。只要本地事务成功,消息最终一定会被投递出去;本地事务失败,消息要么不投递,要么被删除。剩下的下游消费,只要做好幂等和最终补偿,交易数据在短暂延迟后一定会对齐。
这种“可靠消息 + 最终一致”的模式,才是交易链路里最划算的选择。
2. 事务型消息的核心机制,其实就是“半消息卡位”
2.1 半消息:消费者看不见的“预授权”
事务型消息和普通消息最大的区别,是它多了一个半消息状态。
你可以把半消息理解成酒店预订里的“预授权冻结”。你预订酒店时,平台会向银行发一个预授权请求,冻结一部分额度,但并没有真正扣款,等到你入住结账时,才做正式扣款。如果中途取消,冻结额度就被释放。
事务型消息也是这样。生产者先把一条消息发给消息中间件,但这个消息此时处于“半消息”状态,消费者看不到,也不会消费到。紧接着生产者去执行本地事务,比如在订单库里插入一条订单记录并提交。本地事务执行完,生产者再通知消息中间件:这条消息可以正式投递了,或者应该删掉了。
这看起来像两阶段,但关键点在于:生产者的本地事务和消息发送,并不需要在同一个数据库事务里。本地事务的提交和消息状态的确认,可以彼此独立,最终一致性靠回查机制来兜底。
2.2 本地事务执行完,生产者要明确回答三种状态
在具体实现里,生产者执行完本地事务后,需要向消息中间件上报一个结果状态,一般有三种:提交、回滚、未知。
提交状态,说明本地事务执行成功,消息可以被消费者看到了。回滚状态,说明本地事务执行失败,消息中间件会删除这条半消息。未知状态,说明生产者执行本地事务时遇到了网络抖动或异常,连自己都不确定本地事务到底提交成功没有,需要消息中间件稍后主动回查。
很多同学第一次接触这个流程时有个误区:把“未知”状态当成异常处理,直接返回回滚。其实恰恰相反,事务型消息的设计精髓,就是允许你暂时不确定。业务执行到一半,比如订单已经写进去了,但提交事务时网络超时,你没法判断最终结果到底成没成。这时候你盲目回滚可能导致订单丢失,盲目提交又可能导致消息已投递但本地事务没成。正确的做法是返回未知,让回查机制去数据库里查真实状态。
2.3 回查机制:最终一致性的兜底保证
消息中间件收到未知状态后,不会无限等待,而是会启动一轮回查流程。
所谓回查,就是消息中间件在等待一段时间后,主动调用生产者提供的一个查询接口,问它:你之前发的那条半消息,对应的本地事务到底提交了没有?生产者的查询接口怎么做?非常简单,去本地数据库查那条业务记录是否存在,或者查一张事务记录表里的状态字段。
回查接口返回提交或回滚之后,消息中间件再决定继续投递还是删除消息。只要生产者侧能正确回答回查,这条消息和本地事务最终一定是一致的。
需要注意一个细节:回查不是立即发生的。消息中间件通常有一个可配置的延迟时间,比如几秒到几十秒不等,之后才会发起第一次回查。如果回查接口因为临时故障没有响应,中间件会按配置的策略继续重试。设计回查逻辑时,一定要让查询接口足够轻,只查状态,不要在里面做复杂的业务计算,否则回查流量一多,业务库会先被拖垮。
这个“半消息 + 回查”的模型,本质上就是把人面对分布式事务时最拿不准的“到底成没成”这个问题,交给一次可重复的状态查询去解决。事情反而变得简单了。
3. 在某跨平台交易系统中的落地实现
3.1 模块划分和基础能力准备
我在某跨平台交易系统里落地事务型消息时,划分了三个核心模块:订单服务负责创建订单和执行本地事务,库存服务作为消费者负责扣减库存,积分服务作为消费者负责累加积分。
生产者侧要准备的东西,是一张事务记录表。这张表就是回查的依据,也是整个机制能闭环的关键。表结构不需要很复杂,但下面几个字段基本是必须的:事务ID、业务单据号、业务类型、事务状态、创建时间和更新时间。
为了让大家直观理解,我给出一个简化版的建表语句:
CREATE TABLE t_tx_record ( tx_id VARCHAR(64) PRIMARY KEY COMMENT '事务消息的全局唯一键', biz_type VARCHAR(32) NOT NULL COMMENT '业务类型,如订单/支付', biz_id VARCHAR(64) NOT NULL COMMENT '业务单据号,如订单号', tx_status TINYINT NOT NULL COMMENT '0=处理中 1=已提交 2=已回滚', create_time DATETIME NOT NULL, update_time DATETIME NOT NULL, UNIQUE KEY uk_biz (biz_type, biz_id) ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;这里做了一个唯一索引,目的是防止同一条业务单据被重复创建事务记录。事务ID可以直接用消息中间件生成的全局ID,也可以由业务方自己生成,只要保证全局唯一即可。
3.2 生产者侧:事务监听器与本地事务的编写
消息中间件的事务消息机制,一般需要实现一个事务监听器,里面包含两个方法:一个是执行本地事务,另一个是回查本地事务状态。
下面是一段可以参考的生产者侧核心代码。为了不绑定具体中间件品牌,我用通用接口名来表示:
@Component public class TradeTransactionListener implements TransactionListener { @Resource private TxRecordService txRecordService; @Resource private OrderService orderService; @Override public LocalTransactionState executeLocalTransaction(Message msg, Object arg) { String txId = msg.getKeys(); // 先写入一条事务记录,状态为处理中,这是回查的凭据 txRecordService.createProcessing(txId, "ORDER", arg.get("orderId")); try { // 执行真实业务:创建订单,并更新订单状态 boolean success = orderService.createOrder(arg); if (success) { txRecordService.updateStatus(txId, 1); return LocalTransactionState.COMMIT_MESSAGE; } txRecordService.updateStatus(txId, 2); return LocalTransactionState.ROLLBACK_MESSAGE; } catch (Exception e) { // 不能确定本地事务是否成功,交给回查去查真实状态 return LocalTransactionState.UNKNOW; } } @Override public LocalTransactionState checkLocalTransaction(MessageExt msg) { String txId = msg.getKeys(); // 根据事务记录表判断本地事务最终结果 return txRecordService.getStateByTxId(txId) == 1 ? LocalTransactionState.COMMIT_MESSAGE : LocalTransactionState.ROLLBACK_MESSAGE; } }这一段有几个细节值得重点说。
第一,事务记录表的新增操作,应该和业务操作放在同一个本地数据库事务里。一定要先在本地库里留下记录,再去执行业务。如果业务执行失败,事务记录被回滚,但消息又返回了未知,回查时查不到状态,系统会进到一个比较尴尬的境地。
第二,捕获异常后不要什么都返回回滚。如果你没法判断是因为网络超时还是业务失败,返回未知更安全。回查机制会带着事务ID去数据库确认,靠真实状态做决定。
第三,提交订单的业务逻辑里,一定要用到消息里携带的业务键。如果只用内存参数,回查的时候根本找不到状态。
3.3 消费者侧:幂等消费是最后一道防线
生产者侧已经保证了消息不会丢,但能保证“消息不丢”不等于能保证“消费不重”。消息中间件的投递语义大多是至少一次,消费者在极端情况下,比如消费成功后还没来得及确认,又被重新投递,这就发生了重复消费。
交易链路的重复消费,后果非常直接:订单创建两次,库存扣两次,积分加两次。所以消费者侧必须做幂等。
我在系统里最常用的一种幂等方案,是消费记录去重表。消费者收到消息后,先根据事务ID查去重表,如果已经存在说明处理过,直接返回确认。如果不存在,再插入一条消费记录,并执行业务更新。插入操作和业务更新必须放在同一个事务里,否则会出现插入了消费记录但业务没更新的情况。
可以参考下面的实现思路:
@Transactional(rollbackFor = Exception.class) public void consumeOrderMessage(OrderMessage message) { String txId = message.getTxId(); // 唯一约束保证并发场景下只有一个线程能插入成功 if (consumeRecordDao.exist(txId)) { return; } consumeRecordDao.insert(txId); stockService.deductStock(message.getGoodsId(), message.getQuantity()); }这段代码里最关键的地方,是去重表的唯一索引。到了高并发阶段,同一个事务ID的回执可能同时重试两次,如果只做查询再插入,两次查询都查到不存在,还是会重复执行。只有唯一索引能在数据库层面挡住第二次,配合事务回滚,就能保证整个消费动作的幂等。
3.4 回查接口如何设计得更稳
事务型消息中间件对生产者的回查压力其实不低。尤其是消息量大、回查比例高的时候,回查接口的稳定性会影响整个链路的延迟。
我给回查接口做了三件事。第一,回查只查事务状态表,不查线上订单主表。事务状态表很小,字段少,查询走主键,单次查询耗时很低。第二,给回查接口单独配置线程池,避免回查流量占满业务线程。第三,重点监控回查慢调用和未知状态比例,一旦未知状态比例超过阈值,说明业务侧出现了批量超时或事务异常,需要及时告警。
回查接口返回状态时,也不要每次都做很重的组装。能直接返回提交的,就不要再去查上下游状态。回查只看“本地事务是否成功”,不要额外检查消费者是否处理完成,那不是生产者该管的事。
4. 交易性能优化:事务型消息的性能调优实战
4.1 先搞清楚性能开销到底在哪
很多同学一听事务型消息,第一反应就是性能肯定不行,毕竟比普通消息多了一半事务记录写入,还可能回查。但实际做下来,事务型消息的额外开销远没有想象中那么大,关键在于你把它用在了正确的位置。
性能开销主要来自三块:事务记录表的写入、半消息状态确认的RTT、以及回查带来的额外数据库查询。
第一块开销难以避免,但可以把事务记录表和业务主表设计成同库不同表,利用数据库本地事务保证一致性,写入代价并不高。第二块开销取决于网络质量,只要业务服务和消息中间件在同一个内网,一次状态确认的耗时可以控制在一毫秒级。第三块回查开销是可控的,回查只发生在本地事务结果为未知的情况下,正常不超时的时候,回查比例应该非常低。如果发现回查比例特别高,先怀疑网络抖动,再怀疑代码里有没有过度返回未知状态。
4.2 实测下来最有效的四个优化点
第一,减少单条消息体大小。事务型消息要携带业务数据,但不要把所有关联数据都塞进去。我见过有同事把一份完整的订单快照放进去,结果一条消息几十KB,发送和消费吞吐都被拖低。正确做法是消息里只放业务ID、商品ID、数量、金额等必要字段,下游需要更多数据时再根据业务ID查询。
第二,把回查间隔调成指数退避。默认情况下,回查次数可能从十几秒开始,逐次翻倍,直到达到最大次数。这个策略能有效减少不必要的回查压力。回查间隔设得太长,会拉长最终一致的延迟;设得太短,又容易造成数据库压力飙升。一般事务型消息的回查间隔可以保守一些,从几十秒起步,最大重试次数控制在十几次以内。
第三,消费端采用批量消费 + 线程池隔离。交易场景中,库存和积分这类服务消费速度要跟得上生产者。如果消费端还是一条一条串行处理,同样性能会差很多。我实测把批量消费窗口调到256条以下,配合固定大小的消费线程池,吞吐量能稳定提升,同时还能控制内存占用。
第四,及时清理过期的事务记录表数据。事务状态一旦确定,这条记录唯一的作用就是防止重复写。确认提交或回滚之后,其实过了回查窗口,记录就没什么用了。可以每天定时清理几小时前的旧记录,避免事务表不断膨胀,影响后续写入性能。
4.3 压测数据参考
在一套四核八G、三节点的模拟压测环境里,我对订单创建链路做了对比。优化前,订单创建接口直接同步调用库存服务和积分服务,TPS大概在2200左右,接口P99延迟接近480毫秒。切换到事务型消息链路,并把调优点全部落地之后,TPS提升到了4200左右,P99延迟降到了260毫秒以内。
这个结果其实也好理解:同步链路里,一个慢下游会把整条链路拖住,而消息链路把最耗时的下游处理转移到了异步消费,上游只需要关注本地事务和消息确认,耗时自然大幅下降。回查比例在压测中控制在3%以内,并没有成为性能瓶颈。
要注意的是,这个数据只代表特定环境和特定业务模型下的趋势,不能直接抄到所有系统里。不同业务的消息大小、消费速度、下游延迟都不同,但调优方向是一致的:让上游链路更短,让回查更少,让消费更轻。
5. 事务型消息在实际使用中的常见“坑”和排查心得
5.1 回查风暴:回查次数设置过猛的教训
有一次压测,某个同事把回查间隔设置得特别短,结果业务量稍微上来,数据库的连接数就飙升了。排查后发现,某张订单表存在大量状态处理中的半消息,消息中间件每秒发起几百次回查,每次回查都扫到一张老查询上,数据库瞬间被打满。
这个问题的核心不是回查接口写得慢,而是回查的频率根本没有必要这么高。回查本身就是一种补偿手段,正常流程里它不应该成为主要流量。正确做法是把回查间隔拉大,次数设上限,同时给回查逻辑增加一个熔断开关。一旦发现回查比例异常升高,可以临时降低回查触发频率,优先保证业务主流程可用。
5.2 消费者幂等没做全,导致多扣库存
生产环境曾出现过一次库存多扣。问题根源不是事务型消息丢了消息,而是消费端重复消费。当时库存扣减的消费逻辑里,只有更新库存表的操作,觉得消息中间件自带重试就能避免问题。但实际场景中,消费者处理完还没来得及向中间件确认时进程重启,消息会被重新投递,库存被扣了两次。
排查时看到库存流水表里同一个事务ID出现了两条记录,才定位到问题。后来在消费入口加了去重表和唯一索引,把消费动作变成幂等操作,这个问题才彻底解决。这里必须强调:事务型消息只能保证消息本身不丢,不能保证消费时只执行一次。消费者侧幂等,永远是必选项,不是可选项。
5.3 消息顺序被打乱,下游处理出错
交易链路里,有些场景对顺序极度敏感。比如订单系统里,必须先收到“创建订单”消息,再收到“支付成功”消息。如果这两条消息被并发消费,支付成功消息先到,下游拿着不存在的订单号去处理,就会报错。
事务型消息本身不提供严格的顺序保证,它是靠按业务ID写入同一个消息队列来实现顺序的。我在下单链路里,把订单号的哈希值作为消息分区键,确保同一个订单的所有消息都进入同一个分区,再配合单线程消费,基本能保证相对顺序。如果业务允许一定程度的乱序,那就在消费端做状态机校验,顺序不对时先缓存或跳过,不能直接抛异常。
5.4 消息堆积导致回查滞后,最终一致时间被拉长
消费者处理能力跟不上生产者时,消息会堆积。事务型消息虽然不会丢消息,但消费者的积压会直接拉长“最终一致”的时间。比如生产者已经提交了订单消息,库存服务一直消费不完,用户看到订单创建成功,可库存迟迟没有扣减,再过一会可能还会遇到库存超卖。
这种问题光靠事务型消息解决不了,必须从两个方向处理。一个是提高消费者的吞吐能力,批量消费、多线程消费、增加分区数。另一个是给消费延迟添加监控,消息积压量达到阈值就要告警,避免最终一致变成“最终不一致”。
5.5 事务型消息问题排查速查表
| 现象 | 可能原因 | 处理手段 |
|---|---|---|
| 回查请求过多,数据库压力大 | 回查间隔过短、未知状态比例过高 | 调整回查间隔为指数退避,设置重试上限,排查网络抖动 |
| 下游重复扣款或重复加积分 | 消费端没有做幂等 | 增加消费去重表,基于事务ID加唯一索引 |
| 上游已提交,下游迟迟收不到 | 消费者堆积或消费失败 | 检查消费端TPS、DLQ堆积,优化消费逻辑 |
| 同一条业务消息顺序错乱 | 分区键设计不合理 | 使用业务ID作为分区键,保证同ID进同一队列 |
| 事务记录表越来越大 | 未及时清理历史数据 | 每天定时清理已结束且超过回查窗口的数据 |
| 本地事务执行成功但消息回查不到 | 事务记录与业务操作不在同一事务 | 调整事务边界,确保状态表写入和业务一起提交 |
这张表基本覆盖了事务型消息从生产到消费的常见问题。遇到异常,先回去看事务记录表的数据,再去看消费去重表,基本能定位大部分故障。
最后再分享一个我做这个专题时最深的体会:事务型消息解决的是“可靠投递”,不是“不重复消费”。如果只把发送端搞定,消费端不做幂等,上了生产环境照样出事。我在某跨平台交易系统里踩过消费幂等没做全而导致多扣库存的坑,后来把事务记录表保留时间延长到了回查窗口的三倍,并给回查接口单独加了线程池隔离,才算真正把这个机制用稳。事务型消息的本质,就是把分布式事务里最不可控的“多系统同时成功”问题,转化成一次可控的状态查询和消息重投,这比任何复杂的分布式事务框架都更适合交易链路的实际场景。