RocketMQ实战一:事务消息保证分布式事务的一致性

5,822 阅读7分钟

michael-kroul-bTZU6EwVEQs-unsplash.jpg

先问一个问题:RocketMQ是如何保证消息与数据库事务的一致性

第一时间可能会想到RocketMQ的事务消息

我们以日常开发中的案例来进行分析:下单送积分。用户在下单后,订单系统保存订单数据,然后发送消息到MQ,积分系统订阅这个消息,然后给用户加积分。这就引出了一个问题,从生产者订单系统角度看,到底是先写库还是先发消息 呢?那我们接下来就分别看下这两种情况。

1. 先写库后发消息

我们先通过一段伪代码来分析下:

public void createOrder(final Order order) throws Exception {
    //模拟spirng的tx模板
    transactionTemplate.execute(new TransactionCallback<Boolean>() {
        @Override
        public Boolean doInTransaction(TransactionStatus status) {
            //本地数据插入
            orderMapper.save(order);
            orderDetailMapper.save(order.getOrderDetail());

            //模拟 mq 发送消息
            SendResult send = producer.send(orderMessage);;
            if (send.getSendStatus() == SendStatus.SEND_OK) {
                status.setRollbackOnly();
            }

            return Boolean.TRUE;
        }
    });

}

我们来分析下它的过程:

首先,执行本地数据库事务,插入数据,注意此时还没有commit, 紧接着发送消息到MQ, 这中间可能由于网络波动等原因,导致生产者迟迟没有收到broker的响应结果,比如5s内都没有返回SendResult给生产者,这也就意味着这5s内本地数据库事务是无法commit的,如果在高并发的场景下,数据库连接资源很快就会被耗尽,后续的请求则无法处理,最终系统将会崩溃。

既然我们知道了先写库后发消息有这样的问题,那么如果是先发消息后写库呢?

2.先发消息后写库

我们还是先看下代码:

public void createOrder(Order order) {
    try {
        //先发送消息
        SendResult send = producer.send(orderMessage);
        if (send.getSendStatus() == SendStatus.SEND_OK) {
            orderMapper.save(order);
            orderDetailMapper.save(order.getOrderDetail());

            //提交事务
            connection.commit();
        }
    } catch (Exception e) {
        //回滚
        connection.rollback();
    }
}    

这样也是有问题的:

  1. 首先他也存在先写库后发消息的问题,一旦MQ由于网络等原因长时间没有返回SendResult给生产者,将会导致本地事务无法被提交或回滚,高并发下资源将会被快速耗尽。
  2. 其次,生产者将消息发送出去并快速响应了,但是执行本地数据库事务时出现了错误,比如上述代码中的orderMapper.save(order)执行出错了,这也就意味着消息已经发送出去,消费者可以消费了,但是此时本地事务失败了,为了弥补错误,此时可能需要“回滚”之前发送的消息,但是此时这条消息可能已经被消费了,就算没有被消费,每次我都在发送消息后判断是否出现了异常,如果出现了异常在发送条"回滚"的消息,这无疑是增加了开发的复杂度,也显得冗余。

那么有没有什么更好的方式,既可以不阻塞本地数据库事务,还能保证最终一致性呢?

这就是接下来我们要说的RocketMQ的事务消息,它可以保证本地事务与MQ消息的最终一致性。

事务消息我们之前有分析过它的源码和流程,这里我们简单看下

image.png

知道了事务消息的大致流程后,接下来我们还是通过伪代码来看下它的实现过程。

3.事务消息

  1. 发送事务消息

发送的topic是 “tx_order_topic”,消费者订阅的也是这个,但是在发送到broker时,他会在内部将我们的topic做一次修改,这样对消费者就不可见了。

@Slf4j
@Controller
public class OrderCreateController {

    //rocketmq 发送消息的模板
    @Autowired
    private RocketMQTemplate rocketMQTemplate;


    @ResponseBody
    @GetMapping("/order/{buyer}")
    public String createOrder(@PathVariable String buyer) {

        //@Accessors(chain = true)
        OrderDetail orderDetail = new OrderDetail();
        orderDetail.setPhone("18883858508").setAddress("上海外滩xxxxx").setOrderDetailId(UUID.randomUUID().toString());

        Order order = new Order();
        order.setOrderId(UUID.randomUUID().toString()).setBuyer(buyer).setOrderDetail(orderDetail);

        Message<Order> message = MessageBuilder.withPayload(order).build();

        TransactionSendResult result = rocketMQTemplate.sendMessageInTransaction("tx_order_topic", message, null);
        if (SendStatus.SEND_OK == result.getSendStatus()) {
            log.info("发送消息成功, result: {}", result);
        }
        //回查订单表
        return "order create success";
    }
}

rocketMQTemplate.sendMessageInTransaction(...)要等本地事务执行完毕,才会返回 TransactionSendResult

  1. 执行本地事务
@Slf4j
@RocketMQTransactionListener
public class CreateOrderCheckerListener implements RocketMQLocalTransactionListener {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OrderDetailMapper orderDetailMapper;

    @Autowired
    private TransactionTemplate transactionTemplate;

    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
        log.info("message: {}, args: {}", msg, arg);

        String orderMsg = new String((byte[]) msg.getPayload());
        final Order order = JSON.parseObject(orderMsg, Order.class);
        log.info("order info : {}", order);
        try {
            //放到同一个本地事务中
            this.transactionTemplate.executeWithoutResult(status -> {
                this.orderMapper.saveOrder(order);
               // int x = 1 / 0;
                this.orderDetailMapper.saveOrderDetail(order.getOrderDetail());
            });

            return RocketMQLocalTransactionState.COMMIT;
        } catch (Exception e) {
            log.error("保存订单失败", e);
            //触发回查
            return RocketMQLocalTransactionState.UNKNOWN;
            //如果是ROLLBACK,则回滚消息,rocketmq将废弃这条消息
        }
    }

    //先忽略回查的逻辑
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {}
}

如果本地事务执行成功(订单正常入库),producer将给Broker发送一个COMMIT的标识,此时broker会将之前被替换了的topic给替换回去,这样消费者就可以消费了。

@Slf4j
@Component
@RocketMQMessageListener(consumerGroup = "qiuguan_test_consumer_group", topic = "tx_order_topic")
public class RewardsPoints implements RocketMQListener<Order> {

    @Override
    public void onMessage(Order message) {
        log.info("积分系统根据订单增加积分 : {}", message);
    }
}

如果本地执行过程中发生了异常,比如网络抖动等,没有正常入库,此时给Broker发送一个UNKNOW的标识,broker收到UNKNOW标识后,默认按照每分钟一次的频率发起回查。

  1. 消息回查
@Slf4j
@RocketMQTransactionListener
public class CreateOrderCheckerListener implements RocketMQLocalTransactionListener {

    @Autowired
    private OrderMapper orderMapper;

    @Autowired
    private OrderDetailMapper orderDetailMapper;

    @Autowired
    private TransactionTemplate transactionTemplate;

    //执行本地事务逻辑
    @Override
    public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {}


    //回查
    @Override
    public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
        log.info("执行本地事务回查:{}", LocalDateTime.now());
        String orderMsg = new String((byte[]) msg.getPayload());
        final Order order = JSON.parseObject(orderMsg, Order.class);
        log.info("回查order: {}", order);

        //回查次数
        //int checkTimes = msg.getHeaders().get("TRANSACTION_CHECK_TIMES", Integer.class);

        Order o = this.orderMapper.getOrder(order.getOrderId());
        if (o == null) {
            try {
                this.transactionTemplate.executeWithoutResult(status -> {
                    this.orderMapper.saveOrder(order);
                    this.orderDetailMapper.saveOrderDetail(order.getOrderDetail());
                });
            } catch (Exception e) {
                log.error("保存订单失败", e);
                return RocketMQLocalTransactionState.ROLLBACK;
            }
        }

        return RocketMQLocalTransactionState.COMMIT;
    }
}

在回查的时候我们可以检查数据库是否插入了订单,如果没有,此时我们可以再次尝试入库,如果入库成功,则响应给Broker一个COMMIT标识,此时该消息就可以被消费者消费了,如果依然入库失败,可以等待再次回查,或者回滚。如果是回滚,则Broker将丢弃该消费,消费者也将无法消费。

接下来我们分析下使用RocketMQ的事务消息有哪些问题:

  1. 生产者发送事务消息失败

image.png

这种情况就直接抛出异常即可,本地事务也不会执行,更不会存在数据不一致的问题。

  1. 生产者发送消息成功,但是本地事务执行失败
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
    log.info("message: {}, args: {}", msg, arg);
    try {
        this.transactionTemplate.executeWithoutResult(status -> {
           this.orderMapper.saveOrder(order);
           int x = 1 / 0;
           this.orderDetailMapper.saveOrderDetail(order.getOrderDetail());
        });

        return RocketMQLocalTransactionState.COMMIT;
    } catch (Exception e) {
        log.error("保存订单失败", e);
        //回滚消息
        return RocketMQLocalTransactionState.ROLLBACK;
    }
}

一旦本地事务执行失败,则数据库将会回滚,同时给broker发送ROLLBACK标识,broker收到该标识后,将废弃掉这条消息,消费者也无法消费这条消息,这样也不会出现数据不一致的问题。

  1. 生产者发送消息成功,本地事务也执行成功,但是在生产者将COMMIT标识发送给broker时,发生了网络抖动,没有及时收到COMMIT指令
public RocketMQLocalTransactionState executeLocalTransaction(Message msg, Object arg) {
    log.info("message: {}, args: {}", msg, arg);
    try {
        this.transactionTemplate.executeWithoutResult(status -> {
           this.orderMapper.saveOrder(order);
           this.orderDetailMapper.saveOrderDetail(order.getOrderDetail());
        });
        //网络抖动...
        return RocketMQLocalTransactionState.COMMIT;
    } catch (Exception e) {
        log.error("保存订单失败", e);
        //回滚消息
        return RocketMQLocalTransactionState.ROLLBACK;
    }
}

本地数据库事务执行成功,订单数据保存到表中,broker由于网络抖动没有及时收到COMMIT指令,此时消息还是一条半事务消息,消费者还是无法消费,这样本地事务与RocketMQ消息的一致性就被破坏了。

RocketMQ为了解决这个问题,引入了消息回查机制,对于半事务消息,如果没有及时收到COMMIT/ROLLBACK指令,它会尝试主动与broker进行通信,调用监听器的 checkLocalTransaction(..) 方法再次确认之前的本地事务是否成功。

public RocketMQLocalTransactionState checkLocalTransaction(Message msg) {
    log.info("执行本地事务回查:{}", LocalDateTime.now());

    final Order order = JSON.parseObject(new String((byte[]) msg.getPayload()), Order.class);
    log.info("回查order: {}", order);

    /**
     * 由于之前本地事务已经执行成功,数据插入了表中,只是在给broker发送COMMIT标识时发生了网络闪断
     * 所以这里回查的时候,是可以从数据库表中查询到订单数据的,此时就可以给broker发送一个COMMIT标识
     * 这样broker就会把这对消费者不可见的消息修改为可见,此时就可以消费了。
     */
    Order o = this.orderMapper.getOrder(order.getOrderId());


    /**
     * 如果数据库中没有订单数据,说明之前的插入就是失败的,此时这里尝试再次插入或者直接回滚就可以了
     */
    return o == null ? RocketMQLocalTransactionState.ROLLBACK : RocketMQLocalTransactionState.COMMIT;
}

不难发现,使用RocketMQ的事务消息具有以下好处:

将发送消息和本地事务分离开,如果发送消息失败,则整个流程失败,不会阻塞本地事务,如果本地事务执行失败,则可以直接回滚或者回查,不会影响消费者。

好了,关于RocketMQ的事务消息的实战就介绍到这里,欢迎大家批评指正。