深入浅出消息队列----【事务消息的实现原理】
本文仅是文章笔记,整理了原文章中重要的知识点、记录了个人的看法
文章来源:编程导航-鱼皮【yes哥深入浅出消息队列专栏】
什么是事务
简单理解就是指一些列的操作全部执行成功,或者全部失败,不会出现一些成功一些失败的情形。
经典的例子就是转账:A 转给 B 1000元,那么从 A 账户扣了 1000 和往 B 账户打 1000 这两件事必须全部成功,要么全部失败,不然这个钱就对不上了。
当然,按照正常科班教育,说道事务肯定就是 ACID,就是指事务必须具备:原子性、一致性、隔离性和持久性。
这个是事务的严格定义。
什么是分布式事务
一般在大学时候,所说的事务更多是数据库层面的,例如一个操作需要插入 A、B 两张表,A 表是这个人的姓名等身份信息,B 表是这个人对应的住址邮编等信息,这两张表都在一个数据库中。
如果是正常流程,那么一个 A、B 两张表的记录都需保存成功。
如果出现异常情况,那么要保证 A、B 两张表的记录都不能插入,不然就可能出现这个人有身份信息,但是没有住址信息,或者有住址信息,没有身份信息。
这个时候就可以用事务来保证两个操作的一致性。
但是在企业级场景,偏向互联网层面的公司用的都是微服务架构,因为扩展性和性能原因,很多情况下多个操作涉及的数据都存在不同的数据库中。
好比订单和商品,分别对应订单服务和商品服务,它们都有各自的数据库。
如果一个用户下单成功,那么同时需要扣减对应的商品库存,这两个操作同样需要保证一致性,也就是得上事务。
我们前面提到的 A、B 两个表,因为这两张表都在一个数据库中,所以可以用数据库自身的机制来保证事务。
但是现在订单和商品分别属于不同的数据库,这个时候就用不上数据库自身的事务了,于是乎只能上分布式事务。
简单来说,能实现上述场景中下单成功和扣库存的事务性操作,就叫分布式事务。
事务消息
实现分布式事务的方案有很多,比如:2PC、3PC、TCC、本地消息、事务消息。
事务消息更适合应用在异步更新的场景,用来保证最终一致性。
最终一致性的意思就是可能某一时刻两端数据是不一致的,比如 A 账户钱扣了,而 B 账户钱也还没进来,但是最终能保证 A 扣的 1000 一定会打到 B 账户上。
也就是说事务消息的实时性不高,在强实时性场景下这个方案就不合适了。
但是在对数据实时性要求不高的场景,就很 nice 了。
比如哪些场景呢?
点外卖场景:选择食物加入到购物车,然后下单、付款,等着拿外卖。
但是有没有想过,下单之后,之前加入到购物车的数据需要被清理掉。
最简单的方式就是在下单时候同步调用购物车清除接口来清除数据,但是这会增加下单时的耗时。
并且购物车所属接口的可能是另一个服务,那么因为网络原因,很可能购物车清除成功了,但是返回请求超时。
这时候同步的下单流程就报错了,下单失败,然后用户返回外卖店铺页面一看,呀!加入到购物车里面的东西怎么都没了!
这样的体验就很不好。
这个场景就非常适合异步的事务消息,也就是保证我们下单成功后,购物车的数据一定会被清理且不增加下单接口的整体耗时。
这里用普通消息不能实现这个功能吗?
数据库插入订单数据后,发送一条消息让购物车清除数据不就行了,用得着用啥事务消息吗?
是的,简单想想从流程上好像确实没有什么问题,但是出现异常情况呢?
因为数据库相关操作可以利用数据库自身的实现保证数据库层面的数据一致性。
最最简化版的代码如下所示:
- savexxx();
- saveOrder();
- senMQToClearCart();
savexxx 和 saveOrder 如果操作的表在同一个库内,可以保证数据库层面的事务性。
但是发送消息完全不属于数据库的范畴,这时候就保证不了了。
很可能 1、2、3 步执行下来都是 ok 的,这样一来消息已经发送出去了。
但是最后提交事务的时候报错了,这时候 1、2 两步回滚了,但是消息已经发出了啊!也就是说购物车已经被清理了,这不就出问题了吗!
所以普通消息无法满足这个需求,这时候就得上事务消息,来保证下单和发送删除购物车数据的消息要么都成功,要么都失败!
如何实现事务消息
实际上 RocketMQ 实现事务消息的原理很简单,前面我们已经提到,主要就是异常的场景才容易导致事务的不一致,因此主要解决的矛盾就是异常的情况。
在事务开始时,我们就发送一条半消息(half message)给 Broker,所谓的半消息从字面理解就是不完整的消息,这种消息不回被消费者消费到。
然后执行本地事务,在我们举例的场景就是下单的一系列操作。
最后根据本地事务的执行结果来决定是向 Broker 发送提交消息,还是发送回滚消息。
如果发送提交消息,那么半消息就会变 “变完整”,即可被消费者消费,最终消费者消费这条消息,整个分布式事务就完整了,保证了最终一致性。
如果发送回滚消息,那么这条半消息就废了,不会被消费者消费到,这就跟本地事务结果保持一致。
如果出现意外,导致执行本地事务后,没有进一步发送提交或回滚消息怎么办?如果本地事务失败了还行,但是如果执行时成功了,那么半消息不就一致不会被消费者消费了?
这时候数据就不一致了!
因此针对这种情况,RocketMQ 还设计了一个反查5机制:Broker 可以得知事务到底有没有执行成功,没成功就返回回滚。
当然因为有可能事务还在执行中,这时候可以返回 UNKOWN,这样 Broker 后续会继续查询,可以在接口逻辑上实现多次查询还未结果再返回回滚。
按照上述的场景,生产者仅需要提供一个查询订单是否存在的接口,如果订单存在说明下单成功,那么就提交消息事务,如果订单部存在那么可能是还未生成或者生成失败,那么多少次查询后还未生成就返回回滚即可。
还记得我们之前提到过的生产组(producer group)的概念吗?
Broker 会反查生产者提供的接口,如果发送的生产者挂了,还有同一个生产组的其他生产者可以供 Broker 反查,这就是 producer group 的作用之一。
如何使用 RocketMQ 事务消息
在使用上,首先需要实现 RocketMQ 定义的 TransactionListener 接口:
一共有两个方法,一个方法是实现执行本地事务的逻辑,另一个方法是给 Broker 反查用的。
源码给了哥演示的实现,可以看到代码还是很简单的,随机数生成状态模拟事务的成功和失败,然后将结构存储在本地存储中,供反查时候使用。
实现类弄完了,实际的使用了。
这里的截图用的也是源码里的示例,主要看标记的地方就行:
RocketMQ 实现原理
其实事务消息的需求跟上篇的延时消息需求有点一致,都是在一定条件前无法被消费者消费,只有当条件成立后,才能被消费者消费。
因此事务消息可以跟延迟消息用一样的套路。
即发送半消息的时候,发往的不是原先的 Topic,而是将发往特定的 Topic:RMQ_SYS_TRANS_HALF_TOPIC。
同样还是偷梁换柱,将原先的 Topic 和队列存储在属性里,替换 Topic 为 RMQ_SYS_TRANS_HALF_TOPIC,队列默认为 0。
这样一来消息被存储后也不会被消费者消费。
然后等待生产者的提交或回滚事务的请求,如果收到提交,那么从属性中获取消息原先的 Topic 和队列,将消息发往原 Topic 即往 commitlog 里面存储这条消息,这样消费者就能消费到了。
如果回滚,那么就不往 commitlog 里面存储,这样消费者就不会消费到,等同于事务回滚了。
并且 Broker 起了一个定时线程 TransactionalMessageCheckService 服务,它会定时的扫描 RMQ_SYS_TRANS_HALF_TOPIC 这个 Topic 下的消息,去请求生产者的反查接口看看事务成功了没,如果成功就恢复原先的 Topic 供消费者消费,失败的话就不重新投递。