关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

开心一刻

  昨晚和一哥们一起吃夜宵,点了几瓶啤酒

  不一会天空下起了小雨,哥们突然道:糟了

  我:怎么了

  哥们:外面下雨了,我老婆还在等着我去接她

  他给了自己一巴掌,说道:真他妈不是个东西

  我心想:哥们真是个好丈夫

  很快他补充道:喝酒怎么能分心呢

  我一口啤酒直接笑喷而出

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

知识回顾

  本文不讲什么是 RocketMQ ,不讲它的实现原理,只想和大家探讨下它的事务消息的正确使用方式

  再探讨之前,先带大家回顾下知识点

  事务消息的设计原理

   RocketMQ 在 4.3.0 版中已经支持分布式事务消息,采用 2PC 的思想实现事务消息提交,同时增加一个补偿逻辑来处理二阶段超时或者失败的消息,如下图所示

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  什么,英文看不懂?贴心的我早已想到,中文版的也有

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  其中有两个点:半事务、回查事务状态,值得我们重点回顾

  Half 消息

  何谓 half 消息?

  消息发送方把消息发送到 MQ 服务,但是此消息的状态被标记为不能投递,处于这种状态下的消息称为 half 消息;消费方不能消费 half 消息

  发送方对 half 消息二次确认后,也就是 Commit 之后,消费方才可以消费到;如果是 Rollback,该消息则会被删除,永远不会被消费到

  事务状态回查

  如果在 RocketMQ 事务消息的二阶段过程中失败了,例如在做 Commit 操作时(上图中的第 4 步),出现网络问题导致 Commit 失败,那么需要通过一定的策略使这条消息最终被 Commit

  RocketMQ 采用了一种补偿机制,称为“回查”。Broker 端对未确定状态的消息发起回查,将消息发送到对应的 Producer 端(同一个 Group 的 Producer),由 Producer 根据消息来检查本地事务的状态,进而执行 Commit 或者 Rollback

  值得注意的是,RocketMQ 并不会无休止的的信息事务状态回查,默认回查 15 次,如果 15 次回查还是无法得知事务状态,RocketMQ 默认回滚该消息

  更多细节请查看:事务消息

实战示例

  理论知识理解之后,就需要我们进行实操与分析了

  需求背景

  假设我们有两个服务:订单服务、积分服务,当用户成功下单之后,需要给用户加相应的积分

  实现方式有很多种,你知道哪些?

  假设我们用 RocketMQ 事务消息来保证最终一致性,我们又该如何实现?

  环境准备

  RocketMQ:4.8.0

  rocketmq-client:4.9.2

  Spring Boot:2.1.0.RELEASE

  MySQL:5.7.29

  MyBatis Plus:3.4.2

  建表 SQL

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗
-- order
CREATE TABLE `order`.`t_order` (
  `order_id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT '主键',
  `order_no` char(20) NOT NULL COMMENT '订单号',
  `user_id` bigint(32) NOT NULL COMMENT '用户id',
  `order_amount` decimal(16,2) NOT NULL,
  `note` varchar(255) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`order_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
-- 不一定非要存half消息的事务id,实现方式有很多,甚至可以不用这张表,直接通过 t_order 新增字段来实现
CREATE TABLE `order`.`t_order_transaction_log` (
  `transaction_id` varchar(32) NOT NULL COMMENT '主键(half 消息的事务id)',
  `order_id` bigint(20) NOT NULL COMMENT '订单主键',
  `note` varchar(500) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`transaction_id`) USING BTREE
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;


-- points
CREATE TABLE `points`.`t_point` (
  `point_id` bigint(20) NOT NULL AUTO_INCREMENT COMMENT '自增主键',
  `order_no` char(20) NOT NULL COMMENT '订单号',
  `user_id` bigint(20) NOT NULL COMMENT '用户id',
  `point_num` decimal(16,2) NOT NULL COMMENT '积分数量',
  `note` varchar(255) DEFAULT NULL COMMENT '备注',
  PRIMARY KEY (`point_id`)
) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4;
View Code

  项目地址:spring-boot-rocketmq-orderspring-boot-rocketmq-points

  后续只会对关键代码进行讲解,所以建议大家把代码 down 下来看看,保证有个基本的印象

  回到标题,楼主为什么会强调:正确的打开方式

  你猜对了,RocketMQ 事务消息的使用方式有很多种,楼主就结合工作项目中的使用方式,来和大家一起讨论下,哪些方式是正确的,哪些方式是不正确的(以及不正确的原因)

  结合 Half 消息发送的时机,大致可分为三种:

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  根据 half 消息的位置,我们暂且将这三种方式命名为:half 消息后置、half 消息中置、half 消息前置

  我们逐个来讨论使用是否正确

  half 消息后置

  这种方式有没有觉得似曾相识?与发普通消息是不是很类似? 本地业务执行完之后,发普通消息给积分中心,是不是熟悉的味道?

  但还是有区别的,至少有回查机制,我们结合伪代码具体看看

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  我们来分析下各种异常情况,看看这种方式是否有问题

  1、订单数据或订单事务日志落库异常,事务回滚,half 消息不会发送,没问题

  2、half 消息发送异常,事务会回滚,没问题

  3、half 消息发送未发生异常,但返回的不是 SEND_OK 状态,代码抛出了异常,事务回滚,没问题

    思考:如果我们不关注 half 消息发送的结果,像这样

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

    最终,消息会推送给积分服务吗?

  虽然看起来怪怪的,但又挑不出毛病

  half 消息中置

  我们直接看伪代码

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  我们来分析下各种异常情况,看看这种方式是否有问题

  1、订单数据落库异常,事务回滚,half 消息不会发送,没问题

  2、half 消息发送异常,事务会回滚,没问题

  3、half 消息发送未发生异常,但返回的不是 SEND_OK 状态,代码抛出异常,事务会回滚,没问题

    思考:与之前的思考问题一样,如果我们不关注 half 消息发送的结果,最终消息会推送给积分服务吗?

    只有发送 half 消息成功,并且发送状态为 SEND_OK ,才会执行 executeLocalTransaction ,向 t_order_transaction_log 表写入事务日志

    那么即使 Broker 回查事务状态,它得到的结果始终是 UNKNOW ,最终 half 消息会被回滚,积分服务收不到消息

    导致的问题就是:用户下单成功,但却没有增加积分

    可见关注 half 消息发送结果的重要性

  4、half 消息发送成功,且返回的是 SEND_OK 状态,但 executeLocalTransaction 执行异常了,会是什么结果?

    代码很明显,我们进行了 catch ,异常不会向上抛,订单落库还是成功的,只是订单事务日志落库失败了

    返回 ROLLBACK_MESSAGE ,half 消息会回滚,积分服务收不到消息

    那么同样的问题又出现了:用户下单成功,但却没有增加积分

    如果我们不 catch ,像这样

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

    理论上来讲,异常往上抛,订单数据会回滚, Broker 回查事务状态,一直返回 UNKNOW ,最终积分服务收不到消息

    理论上来讲没问题,但事实呢? 我们来实践一下

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

    哦豁,竟然没有打印异常日志,也就说异常被 catch 没有往外抛,订单数据也落库了

    那么又会出现同样的问题:用户下单成功,但却没有增加积分

    至于谁把异常 catch 了没往外抛,相信大家都能想到,这算是 rocketmq-client 的一个 bug ;源码稍后再跟,我们先看完前置

  half 消息前置

  直接上伪代码

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

  我们来分析下各种异常情况,看看这种方式是否有问题

  1、half 消息发送异常,本地事务不会执行,没问题

  2、half 消息发送未发生异常,但返回的不是 SEND_OK 状态,代码抛出异常,本地事务不会执行,没问题

    思考:与之前的思考问题一样,如果我们不关注 half 消息发送的结果,会是什么结果?

    只有 half 消息发送成功,且返回状态是 SEND_OK 才会执行 executeLocalTransaction 

    即使 Broker 回查事务状态,得到的结果始终是 UNKNOW ,最终 half 消息会被回滚,积分服务收不到消息

    订单服务与积分服务都没有落库成功,也就说是没问题的

  3、half 消息发送成功,且返回的状态是 SEND_OK ,但 executeLocalTransaction 执行异常了,会是什么结果

    也就是 save 方法执行异常了,我们来实践下

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

     异常还是被 catch 了没往外抛,但是订单数据却回滚了,就结果而言是没问题的

    half 消息发送成功了,但是 Broker 一直未收到本地事务的确认消息, Broker 会回查,得到的结果始终是 UNKNOW ,最终 half 消息会被回滚,积分服务收不到消息

    订单数据回滚了,积分服务未收到消息,那么此种情况是没问题的

  看起来挺顺眼,异常情况下也没什么问题

rocketmq-client 的 bug

  需要弄清楚的问题有两个:

  1、half 消息中置, executeLocalTransaction 的异常为什么没有抛出来

  2、half 消息前置, 异常同样没有抛出来,为什么订单数据却回滚了

  先看第一个问题,我们来跟下源码

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

   rocketmq-client 捕获了异常,但并未向外抛

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗  其实 RocketMQ 是有打印日志的,只是楼主的日志配置的不对,导致控制台未打印出来

  对于第 1 个问题,相信大家已经清楚了

  关于第 2 个问题,我就不具体分析了,我给个提示,从事务 AOP 的控制范围与异常抛出点来考虑,如下图

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

关于 RocketMQ 事务消息的正确打开方式 → 你学废了吗

最终一致性

  前面讲了那么多,都是讲的订单服务,总结起来就是:事务消息(而非 half 消息)发送成功,那么本地事务一定是执行成功的

  保证的是事务消息的发送与订单服务的强一致

  如果积分服务消费异常呢?

  那对不起,RocketMQ 事务消息处理不了这种情况,回滚不了订单服务的数据,只能通过补偿机制(比如人工修复)修复积分服务的数据

总结

  1、三种方式的抉择

    half 消息中置,问题比较多,不推荐

    half 消息后置,看起来挺别扭的(难道只是楼主这么觉得?),倒是没什么问题

    half 消息前置,符合 RocketMQ 事务消息的设计原理,推荐采用此种方式

  2、一定要关注 half 消息发送的结果,不抛异常不代表一定成功了,必要时需要根据 half 消息发送的结果做后续逻辑处理

  3、最终一致性

    RocketMQ 考虑的是数据最终一致性,上游服务提交之后,下游服务最终只能成功,做不到回滚上游服务的数据

参考

  基于RocketMQ分布式事务 - 完整示例

上一篇:SpringBoot整合RocketMQ


下一篇:rocketmq广播消息的(五)