头图

引言

或许你曾写过这样的代码:

@Transaction // 开启事务
public void craeteOrder(Order order) {
    saveOrder(order);
    sendMQ(order); // 或者是发送 rpc
}

在一个事务内,向 MySQL 写入数据,接下来发送 MQ 或 RPC 调用。在大部分情况下,这样写好像没什么问题

但如果此时我们下游执行反查操作,会发现找不到数据。更奇怪的是,这在业务的低谷期才会出现,而在高峰期反而不会出现?

存在问题

破坏事务原子性语义

数据库事务只能保证数据库操作的原子性(如 MySQL 的 InnoDB 事务),但无法控制外部系统的行为(如 MQ 或 RPC 服务)

  • 事务成功提交,但 MQ 消息发送失败
  • 事务提交失败,但 MQ 消息发送成功

我们所期望的事务原子性,就是操作要么全部执行成功,要么全部失败。以上两种情况,都将导致上下游数据不一致

以一个业务场景为例

电商场景中,用户支付成功后,在事务内:

  1. 更新订单状态为“已支付”(数据库操作)
  2. 发送物流服务的 MQ 消息(外部操作)

假设步骤 1 执行成功,步骤 2 执行失败,会出现——已支付,却不发货

假设步骤 1 执行失败,步骤 2 执行成功,会出现——未支付,期待收获

长事务

MQ 和 RPC 通常是网络 I/O 操作,耗时可能会高于本地数据库操作。同时,网络环境是不稳定的,随时可能会出现延迟、不可用、丢包等等。这些因素将延长事务的执行时间,导致:

  • 数据库锁竞争加剧,可能引发死锁
  • 高并发场景下,RPC 耗时长,将增加 DB 连接池占用时间,降低系统吞吐量

下游无法反查到数据

现在我们有个业务场景:用户支付后,需要创建订单,并发送 RPC 请求给权益中心,来加积分

事务提交前,权益中心需要反查数据,但因为事务隔离级别为读已提交以上,此时无法查询到还未提交事务的订单数据。那么 RPC 返回失败结果,导致本地事务无法提交。这就出现了个死循环——上游等待下游执行成功后才能提交事务,下游等待上游提交事务后才能返回执行成功

回答开头的问题,低谷期的 MQ 消息会出现反查无法查询到数据的情况,正是因为低谷期 MQ 消息能被及时消费,延迟几乎跟 RPC 请求一样,导致消费者会在事务提交前执行反查操作,出现和 RPC 请求一样的问题。而高峰期因为 MQ 消费不及时,使得反查操作被“延后”了,在事务提交后才开始消费,所以可以查询到数据

这可以看做是上游事务提交和下游消费的时序问题

解决方案

保证事务提交和消息发送的时序问题

让消费者等一会

依旧是在事务中嵌套发送消息,不过消费者接收到消息时,主动 sleep 一定时间,再进行消费。或者发送延迟消息,保证消费者晚点再消费。目的是通过等待一定时间,保证消费者的消费行为发生在提交事务之后执行

不过缺点也明显,延迟时间不好把控:

  • 延迟太短,消费者可能会在事务提交之前执行反查,使得延迟时间没有意义
  • 延迟太长,将加大延迟,降低吞吐量

(有点类似 Redis 延迟双删的思想,等一会再执行接下来的操作。它们的缺点也都是一样的,需要延迟多久不好把控)

在事务提交后再发消息

在事务提交后,再发送 MQ 消息和 RPC 请求,保证事务提交,在发送 MQ 消息和 RPC 请求之前执行,避免它们在事务中嵌套

public void craeteOrder(Order order) {
    saveOrderByTransaction(order); // 通过事务写入订单
    sendMQ(order); // 或者是发送 rpc,在事务之外执行
}

@Transaction // 只对 SQL 加事务
public void saveOrderByTransaction(order) {
    saveOrder(order);
}

但以上代码还存在一个问题:如果事务执行失败,代码可能还会继续向下执行,此时 MQ 消息依旧会被发送成功。即本地事务提交失败,但 MQ 消息成功发送,导致上下游状态不一致

所以我们需要判断事务是否成功提交,只有成功了,才发送消息。可以加个 if-else 解决,不过 Spring 给我们一个更优雅的解决方案:使用@TransactionalEventListener监听事务状态。当事务成功提交后,才执行某些逻辑。保证当事务提交失败时,不发送 MQ 消息。只有当事务成功提交,才发送 MQ 消息

// OrderService 
@Transactional
public void createOrder(Order order) {         
    // 假设订单创建成功后,发布事件
    OrderCreatedEvent event = new OrderCreatedEvent(order);
    eventPublisher.publishEvent(event);
    
    saveOrder(order);   
}

// OrderCreatedEventListener
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
    sendMQ(event);     
}

本地事务和 MQ 消息的原子性

上面的@TransactionalEventListener其实还存在一个问题:事务提交成功,但 MQ 消息发送失败,无法保证本地事务和消息发送的原子性问题,即要么都成功,要么都失败

分布式事务

分布式事务可以解决本地事务和 MQ 消息的原子性问题,但会带来可靠性、性能、使用成本等问题,给系统带来额外的复杂性。弊远大于利

事务消息

在 RocketMQ 中,支持事务消息,可以保证本地事务和 MQ 消息的原子性。执行逻辑如下:

  1. 生产者将消息发送至 Apache RocketMQ 服务端
  2. Apache RocketMQ 服务端将消息持久化成功之后,向生产者返回 Ack 确认消息已经发送成功,此时消息被标记为"暂不能投递",这种状态下的消息即为半事务消息
  3. 生产者开始执行本地事务逻辑
  4. 生产者根据本地事务执行结果向服务端提交二次确认结果(Commit 或是 Rollback),服务端收到确认结果后处理逻辑如下:

    • 二次确认结果为 Commit:服务端将半事务消息标记为可投递,并投递给消费者
    • 二次确认结果为 Rollback:服务端将回滚事务,不会将半事务消息投递给消费者

本地消息表+定时任务

比较常见的解决方案是使用「本地消息表+定时任务」

在本地事务中,除了写入业务数据外,还要将要发送的 MQ 消息写入到 MySQL 的「消息表」中。而发送消息不再由业务代码决定,而是由后台定时任务来轮询「消息表」,定时发送消息。从而保证:

  • 本地事务执行失败,不会发送 MQ 消息。因为消息表不会写入该消息(回滚),定时任务自然不会发送该消息了
  • 本地事务执行成功,可以保证 MQ 消息一定能发送成功。定时任务查询到消息表的消息后,发送消息。如果出现失败,可以继续重试。当达到一定重试次数还发送失败,可以发送信息,让人工介入处理

如果允许数据存在一定的延迟,即不是「强一致」的场景,只需要保证数据的「最终一致性」的话,「本地消息表+定时任务」是一个非常好的选择,同时它也能解决上面提到的「事务提交和消息发送的时序」问题

// OrderService 
@Transactional
public void createOrder(Order order) {         
    saveOrder(order);
    saveOrderMesaage(order);   // 写入本地消息表
    // 不需要在代码写发送 MQ 消息的逻辑
}

// MessageSendTask
@Scheduled(fixedRate = 1000) // 每隔 1 秒执行一次
public void handleOrderCreatedEvent(OrderCreatedEvent event) {
    List<Message> messages = findMessages();
    sendMQInBatches(messages);
    // 接下来还要更新消息表中消息的发送状态     
}

监听 binlog

可以通过 canal 监听上游数据库的 binlog 日志,解析日志后发送到 MQ 中,由下游自行决定如何消费

优势
  • 上游提交后才通知到相关系统,下游反查可以查到数据
  • 可以保证本地事务和 MQ 消息的最终一致性。只有事务提交了,才有 binlog,才能发送 MQ 消息,给下游消费
  • 解耦。业务只管正常写入数据就行,具体的发送 MQ 消息的操作不需要关心
缺点

实现复杂,需要额外维护监听 binlog 的第三方组件

避免反查

消息中包含了消费者所需要的字段,即通过冗余字段,避免反查操作。那么对于下游,就不需要关心上游事务什么时候提交。不过这样会带来额外的问题:

  • 使生产者的逻辑更复杂
  • 增大的消息的体积,对网络带宽和 MQ 带来额外负担
  • MQ 的引入是为了解耦,即生产者不需要关心消费者是如何去使用数据的。如果生产者需要根据各类消费者定制消息,那么就会将生产者和消费者耦合在一起。这样开发上游的同学,还要去梳理整个消费逻辑,开发上游的同学可能就不乐意了,导致上下游的开发很难协调,同时也需要有人去推动这种修改。所以,这不仅仅是个技术上的问题

而且在实际业务中,很多场景反查操作是不可避免的,我们不能假设反查的操作一定不存在

  • 在某些团队中,会一刀切,即消息只允许携带主键值,这就导致反查数据库是必然发生的
  • 下游需要查询到上游最新的数据

    • 网络拍卖场景中,加价是依赖当前最新的价格往上加的,此时我们必须拿到最新的数据,而不是用户当前在网页看到的“旧”的数据,即「当前读」,此时肯定要反查数据拿到最新值
    • 在电商场景中,我们订单的支付金额一般是用户创建订单时“看到的”价格。可能创建订单后,商品加价了,但我们一般还是以创建订单时的价格为准,即「快照读」,此时就不一定需要反查数据了

结语

事务中嵌套发送 MQ 消息和 RPC 调用,会导致:

  • 事务回滚导致上下游数据不一致
  • 增加事务执行时间,加大锁竞争,导致吞吐量下降(长事务)
  • 下游无法反查到未提交的数据

事务内应该只包含可靠的、可回滚数据。即,不要在事务中嵌套发送 MQ 消息和 RPC 调用

常见的解决方案:

  • 本地消息表+定时任务。由定时任务来发送 MQ 消息。实现简单,可靠,效果好
  • 事务消息。依赖 RocketMQ 实现
  • 监听 binlog。实现成本较高

同时因为数据库主从延迟的存在,反查不保证一定能查到数据,适当的重试也是不可避免的


如果文章对你有帮助,欢迎点赞+收藏+关注,有问题欢迎在评论区评论哦!

公众号【牛肉烧烤屋】

参考资料

b 站:BV1BtKNeDEkX

b 站:BV1S4woeBEuE

https://rocketmq.apache.org/zh/docs/featureBehavior/04transac...


牛肉烧烤屋
1 声望0 粉丝