Springboot中用RocketMQ(rocketmq-spring-boot-starter)解决分布式事务问题

前言
上篇介绍了rocketmq-spring-boot-starter的使用过程,本篇文章介绍怎么用RocketMQ解决分布式环境下的事务问题

如果对 rocketmq-spring-boot-starter 不熟的,建议先看我上篇文章:Springboot整合RocketMQ使用教程

一:场景模拟

场景:假设我们现在有这样的业务:用户充值网费会获得积分,且1元=1积分,用户服务中充值100元,积分服务中要对该用户增加100积分

分析:像这种跨服务、跨库的操作,我们要保证这两个操作要么一起成功、要么一起失败,采用RocketMQ的方案就是:RocketMQ事务消息+本地事务+监听消费,来达到最终一致性

在实现之前,先得介绍一下RocketMQ的事务

二:RocketMQ事务介绍

1. 基本概念
(1)Half Message:也叫 Prepare Message,翻译为 “半消息”或“准备消息”,指的是暂时无法投递的消息,即消息成功发送到MQ服务器,暂时还不能给消费者进行消费,只有当服务器接收到生产者传来的二次确认时,才能被消费者消费
(2)Message Status Check:消息状态回查。网络断开连接或生产者应用程序重新启动可能会丢失对事务性消息的第二次确认,当MQ服务器发现某条消息长时间保持半消息状态时,它会向消息生产者发送一个请求,去检查消息的最终状态(“提交”或“回滚”)

2. 执行流程图(工具ProcessOn)
RocketMQ事务流程

  1. 生产者发送半消息到 MQ Server,暂时不能投递,不会被消费
  2. 半消息发送成功后,生产者这边执行本地事务
  3. 生产者根据本地事务执行结果,向 MQ Server 发送 commit 或 rollback 消息进行二次确认
  4. 如果 MQ Server 接收到的 commit,则将半消息标记为可投递状态,此时消费者就能进行消费;如果收到的是 rollback,则将半消息直接丢弃,不会进行消费
  5. 如果 MQ Server 未收到二次确认消息,MQ Server 则会定时(默认1分钟)向生产者发送回查消息,检查本地事务状态,然后生产者根据本地事务回查结果再次向 MQ Server 发送 commit 或 rollback消息

三:业务代码实现

1. 建表
(1)用户表

CREATE TABLE `t_user` (
   `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '用户表',
   `name` varchar(16) NOT NULL COMMENT '姓名',
   `id_card` varchar(32) NOT NULL COMMENT '身份证号',
   `balance` int(11) NOT NULL DEFAULT '0' COMMENT '余额',
   `state` tinyint(1) DEFAULT NULL COMMENT '状态(1在线,0离线)',
   `vip_flag` tinyint(1) NOT NULL DEFAULT '0' COMMENT 'VIP用户标识(1是,0否)',
   `create_time` datetime NOT NULL COMMENT '创建时间',
   `last_login_time` datetime DEFAULT NULL COMMENT '最后一次登录时间',
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4

(2)积分表

CREATE TABLE `t_credit` (
   `id` int(11) NOT NULL AUTO_INCREMENT COMMENT '积分表',
   `user_id` int(11) NOT NULL COMMENT '用户id',
   `username` varchar(16) NOT NULL COMMENT '用户姓名',
   `integration` int(11) NOT NULL DEFAULT '0' COMMENT '积分',
   PRIMARY KEY (`id`)
 ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8mb4

(3)事务日志表

CREATE TABLE `t_mq_transaction_log` (
   `transaction_id` varchar(64) NOT NULL COMMENT '事务id',
   `log` varchar(64) NOT NULL COMMENT '日志',
   PRIMARY KEY (`transaction_id`)
 ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4
  1. 我是模拟,这里就放在一个库里,至于为什么还要建个事务日志表,后面你就知道了
  2. 建完表后,为了方便自己先手动在用户表和积分表写一条数据
  3. 项目结构搭建这里略过,包括实体类、mapper接口等

2. 新建MQ事务生产者:MQTXProducerService

扫描二维码关注公众号,回复: 15697507 查看本文章
@Slf4j
@Component
public class MQTXProducerService {
    
    

    private static final String Topic = "RLT_TEST_TOPIC";
    private static final String Tag = "charge";
    private static final String Tx_Charge_Group = "Tx_Charge_Group";

    @Autowired
    private RocketMQTemplate rocketMQTemplate;

    /**
     * 先向MQ Server发送半消息
     * @param userCharge 用户充值信息
     */
    public TransactionSendResult sendHalfMsg(UserCharge userCharge) {
    
    
        // 生成生产事务id
        String transactionId = UUID.randomUUID().toString().replace("-", "");
        log.info("【发送半消息】transactionId={}", transactionId);

        // 发送事务消息(参1:生产者所在事务组,参2:topic+tag,参3:消息体(可以传参),参4:发送参数)
        TransactionSendResult sendResult = rocketMQTemplate.sendMessageInTransaction(
                Tx_Charge_Group, Topic + ":" + Tag,
                MessageBuilder.withPayload(userCharge).setHeader(RocketMQHeaders.TRANSACTION_ID, transactionId).build(),
                userCharge);
        log.info("【发送半消息】sendResult={}", JSON.toJSONString(sendResult));
        return sendResult;
    }
}
  1. 这里我就用UUID来生成事务id,就是上面事务日志表的id(实际情况可能会用雪花算法或根据业务来定义ID)
  2. 方法参数userCharge,额外加的,可理解为dto,就两个字段:userId、chargeAmount,代表用户id和充值金额
  3. 这里注意:发送半消息方法里有两个参数,参3和参4,看过上篇整合教程的应该知道,这个参3是给消费者的,而这个参4是给本地事务的,我这里是模拟写的是一样的,实际业务可能会不同

3、新建本地事务监听器:MQTXLocalService

@Slf4j
@RocketMQTransactionListener(txProducerGroup = "Tx_Charge_Group") // 这里的txProducerGroup的值要与发送半消息时保持一致
public class MQTXLocalService implements RocketMQLocalTransactionListener {
    
    

    @Autowired
    private UserService userService;
    @Autowired
    private MQTransactionLogMapper mqTransactionLogMapper;

    /**
     * 用于执行本地事务的方法
     */
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message message, Object obj) {
    
    
        // 获取消息体里参数
        MessageHeaders messageHeaders = message.getHeaders();
        String transactionId = (String) messageHeaders.get(RocketMQHeaders.TRANSACTION_ID);
        log.info("【执行本地事务】消息体参数:transactionId={}", transactionId);

        // 执行带有事务注解的本地方法:增加用户余额+保存mq日志
        try {
    
    
            UserCharge userCharge = (UserCharge) obj;
            userService.addBalance(userCharge, transactionId);
            return RocketMQLocalTransactionState.COMMIT; // 正常:向MQ Server发送commit消息
        } catch (Exception e) {
    
    
            log.error("【执行本地事务】发生异常,消息将被回滚", e);
            return RocketMQLocalTransactionState.ROLLBACK; // 异常:向MQ Server发送rollback消息
        }
    }

    /**
     * 用于回查本地事务执行结果的方法
     */
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message message) {
    
    
        MessageHeaders headers = message.getHeaders();
        String transactionId = headers.get(RocketMQHeaders.TRANSACTION_ID, String.class);
        log.info("【回查本地事务】transactionId={}", transactionId);

        // 根据事务id查询事务日志表
        MQTransactionLog mqTransactionLog = mqTransactionLogMapper.selectByPrimaryKey(transactionId);
        if (null == mqTransactionLog) {
    
     // 没查到表明本地事务执行失败,通知回滚
            return RocketMQLocalTransactionState.ROLLBACK;
        }
        return RocketMQLocalTransactionState.COMMIT; // 查到表明本地事务执行成功,提交
    }
}
@Service
public class UserService {
    
    

    @Autowired
    private UserMapper userMapper;
    @Autowired
    private MQTransactionLogMapper mqTransactionLogMapper;

    /**
     * 用户增加余额+事务日志
     */
    @Transactional(rollbackFor = Exception.class)
    public void addBalance(UserCharge userCharge, String transactionId) {
    
    
        // 1. 增加余额
        userMapper.addBalance(userCharge.getUserId(), userCharge.getChargeAmount());
        // 2. 写入mq事务日志
        saveMQTransactionLog(transactionId, userCharge);
    }

    @Transactional(rollbackFor = Exception.class)
    public void saveMQTransactionLog(String transactionId, UserCharge userCharge) {
    
    
        MQTransactionLog transactionLog = new MQTransactionLog();
        transactionLog.setTransactionId(transactionId);
        transactionLog.setLog(JSON.toJSONString(userCharge));
        mqTransactionLogMapper.insertSelective(transactionLog);
    }

}
  1. 这里代码是主要关键的地方,本地事务是给用户增加余额后再插入mq事务日志,这两个操作只有成功了,才返回 COMMIT,异常失败就返回 ROLLBACK
  2. 回查方法不一定会执行,但是得有,回查就是根据我们之前生成传过来的那个事务id(transactionId)来查询事务日志表,这样的好处是业务牵涉的表再多无所谓,我这个日志表也与你本地事务绑定,我只需查询这一张事务表就够了,能找到就代表本地事务执行成功了
  3. 这里提一点:上面的 addBalance 方法写的有瑕疵,如果 saveMQTransactionLog 方法异常,尽管 addBalance 方法上加了 @Transactional 注解,但是事务不会生效,这个牵涉到 spring 的事务机制原理(本质是通过AOP+动态代理实现),不过我这里也是为了模拟,所以就过滤掉这个细节问题

4. 新建事务消息消费者:MQTXConsumerService

@Slf4j
@Component
@RocketMQMessageListener(topic = "RLT_TEST_TOPIC", selectorExpression = "charge", consumerGroup = "Con_Group_Four") // topic、tag保持一致
public class MQTXConsumerService implements RocketMQListener<UserCharge> {
    
    

    @Autowired
    private CreditMapper creditMapper;

    @Override
    public void onMessage(UserCharge userCharge) {
    
    
        // 一般真实环境这里消费前,得做幂等性判断,防止重复消费
        // 方法一:如果你的业务中有某个字段是唯一的,有标识性,如订单号,那就可以用此字段来判断
        // 方法二:新建一张消费记录表t_mq_consumer_log,字段consumer_key是唯一性,能插入则表明该消息还未消费,往下走,否则停止消费
        // 我个人建议用方法二,根据你的项目业务来定义key,这里我就不做幂等判断了,因为此案例只是模拟,重在分布式事务

        // 给用户增加积分
        int i = creditMapper.addNumber(userCharge.getUserId(), userCharge.getChargeAmount());
        if (1 == i) {
    
    
            log.info("【MQ消费】用户增加积分成功,userCharge={}", JSONObject.toJSONString(userCharge));
        } else {
    
    
            log.error("【MQ消费】用户充值增加积分消费失败,userCharge={}", JSONObject.toJSONString(userCharge));
        }
    }
}
  1. 消费者其实比较简单,和普通消费者差不多,注意属性配置就行了
  2. 这里你可能质疑,前面的发送和本地事务都没啥问题,要么commit要么rollback,但如果这里消费失败怎么办呢?其实这里会产生问题的几率几乎不存在,首先RocketMQ就是高可用的,要真的你系统很庞大很庞大,你可以集群;再者,这里消费成功与否,源码内部已做处理,只要没异常,就会进行消费,而且它也有重试机制;最后,这里消费逻辑你可以扩展,当消费不成功时,你可以把该记录保存下来,定时提醒或人工去处理

四:测试

RocketMQController中添加:

@PostMapping("/charge")
public Result<TransactionSendResult> charge(UserCharge userCharge) {
    
    
    TransactionSendResult sendResult = mqtxProducerService.sendHalfMsg(userCharge);
    return Result.success(sendResult);
}

用postman调用:http://localhost:8080/rocketmq/charge
测试
控制台
看到是正常的,再去看下数据库,发现从余额和积分都加了100,事务日志表也有记录,成功!

总结:其实理解了事务的实现过程后会发现用RocketMQ解决分布式事务还是挺简单的,毕竟MQ非常友好,而且MQ用处很多,每个项目都可以有。当然现在也有其他热门且专业的分布式事务解决方案,这个就不得不提Seata了,但是如果你的项目不是特别的需要Seata,MQ就能解决的话,那不是就可以少引入了一个Seata组件了吗,何乐而不为?

这里提一点个人看法:选用分布式事务的解决方案一定要考虑与切合你的实际业务,那我的建议是:如果业务是用户做了某个操作,是一定会往下走的,哪怕出了问题也是往后捋的,那么百分百使用 RocketMQ 没问题。举个最简单的例子:用户付了钱,结果后续程序出了问题,你不可能联系用户说系统出了问题,我要不先把钱退给你,待会你再下单重新买一次,如果是这样,我觉得应该后面会没人用你的系统了。总之就是实际生产中得根据业务灵活运用。

猜你喜欢

转载自blog.csdn.net/qq_36737803/article/details/112360609
今日推荐