Colingo碎碎念

Programming language, 架构, 分布式, 微服务, iOS, Android

0%

MQ in distributed transaction

系统都微服务了,事务怎么办?今天来说说。

在电商系统中,当有用户下单后,除了在订单表插入一条记录外,对应商品表的这个商量数量必须减一,怎么保证?用户参加活动下单记录保存后,也需要在活动系统中增加一个记录,如何保证?在搜索广告系统中,当用户点平台广告后,除了在点击事件表中增加一条记录外,还得去商家账户表中找到这个商家,并扣除平广告费,如何保证都操作成功?等等,很多系统中都会有类似的情景。

这些问题本质上都可以抽象为:当一个表数据更新后,怎么保证另一个表的数据也必须更新成功。

让我们从简单的说起:本地事务

本地事务

以支付宝转账余额宝为例:假设有

支付宝账户表:A (id, user_id, amount)
余额宝账户表:B(id, user_id, amount)
用户的user_id=1;

从支付宝转账1W到余额宝的动作为分两步:

  1. 支付宝扣除1W: update A set amount = amount-10000 where user_id =1;
  2. 余额宝增加1W: update B set amount =amount+10000 where user_id = 1;

如何确保存支付宝余额宝收支平衡呢?可能有人说这个很简单嘛,可以用事务解决:

1
2
3
4
5
begin transaction
update A set amount=amount-10000 where user_id =1;
update B set amount=amount+10000 where user_id =1;
end transaction
commit;

非常好!如果使用Spring的话,一个annotation就完成上面的功能:

1
2
3
4
5
@Transactional(rollbackFor=Exception.class)
public void update(){
updateATable();
updateBTable();
}

如果两张表都在一个数据库实例上,上面描述没有问题,可以按你的预期完成。但是,如果系统规模较大,比如支付宝账户表和余额宝账户表用户表肯定不会放到同一个数据库实例上,它们往入分布在不同的物理节点上,那么本地事务就不在能满足需求。

既然本地事务解决不了问题,那么分布式事务自然就登上舞台了。

分布式事务:两阶段提交协议

两阶段提交协议(Two-phase Commit:2PC)经常用来实现分布式事务。一般分为协调器(TC)若干事务执行者(SI)两种角色,这里的事务执行者就是具体的数据库,协调器可以和事务自执行器在同一台物理机上。

img

注意:
TC或SI把发送或接收到的消息先写到日志里,主要是为了故障后恢复用。如某一SI从故障中恢复后,先检查本机的日志,如果已收到,则提交,否则回滚。如果是,则再向TC询问一下,确定下一步。如果什么都没有,则很可能在阶段Si就崩溃了,因此需要回滚。

当前实现基于2PC分布试事务相对简单了,java可以使用开源的atomikos,快速实现。

不过使用2pc存在一个问题,就是性能太差,不适合高并发的系统。为什么?

  1. 2PC提交设计多次节点的网络通信,耗时!
  2. 事务时间相对变长了,锁定资源的时间也变长了,造成资源等待时间增加(有切身体会)

那怎么办呢?就是我们今天的主题了,通过解决数据一致性问题这个途径来解决。

使用消息队列(MQ)来避免分布式事务

如果仔细观察生活的话,生活的很多场景已经给了我们提示。

比如在鹅哟点了包心肉饭并付了钱后,他们并不会直接把你点的包心肉饭给你,微信上给你一凭证,然后让你拿着手机到店里等待叫号。为什么他们要将付钱和取餐两个动作分开呢?原因很多,其中一个很重要的原因是为了使他们 接待 能力增强(并发量更高)。

还是回到我们的问题,只要这张凭证在,你最终是能拿到包心肉饭的。同理转账服务也是如此,当支付宝账户扣除1W后,我们只要生成一个凭证(消息)即可,这个凭证(消息)上写着“让余额宝账户增加1W”,只要这个凭证(消息)能可靠保存,我们最终是可以拿着这个凭证(消息)让余额宝账户增加1W的,即我们能依靠这个凭证(消息)完成最终一致性。

如何可靠保存凭证(消息)

两种方法

1.业务与消息耦合的方式

支付宝在完成扣款的同时,同时记录消息数据,这个消息数据与业务数据保存在同一数据库实例里(消息记录表表名为message);

1
2
3
4
5
begin transaction
update A set amount=amount-10000 where userId=1;
insert into message(userId, amount,status) values(1, 10000, 1);
end transaction
commit;

上述事务能保证只要支付宝账户里被扣了钱,消息一定能保存下来。

当上述事务提交成功后,我们通过实时消息服务将此消息通知余额宝,余额宝处理成功后发送回复成功消息,支付宝收到回复后删除该条消息数据。

2. 业务与消息解耦方式

上述保存消息的方式使得消息数据和业务数据紧耦合在一起,从架构上看不够优雅,而且容易诱发其他问题。为了解耦,可以采用以下方式。

  1. 支付宝在扣款事务提交之前,向实时消息服务请求发送消息,实时消息服务只记录消息数据,而不真正发送,只有消息发送成功后才会提交事务;
  2. 当支付宝扣款事务被提交成功后,向实时消息服务确认发送。只有在得到确认发送指令后,实时消息服务才真正发送该消息;
  3. 当支付宝扣款事务提交失败回滚后,向实时消息服务取消发送。在得到取消发送指令后,该消息将不会被发送;
  4. 对于那些未确认的消息或者取消的消息,需要有一个消息状态确认系统定时去支付宝系统查询这个消息的状态并进行更新。为什么需要这一步骤,举个例子:假设在第2步支付宝扣款事务被成功提交后,系统挂了,此时消息状态并未被更新为“确认发送”,从而导致消息不能被发送。

优点:消息数据独立存储,降低业务系统与消息系统间的耦合;
缺点:一次消息发送需要两次请求;业务处理服务需要实现消息状态回查接口。

如何解决消息重复投递的问题

还有一个很严重的问题就是消息重复投递,以我们支付宝转账到余额宝为例,如果相同的消息被重复投递两次,那么我们余额宝账户将会增加2万而不是1万了。

为什么相同的消息会被重复投递?比如余额宝处理完消息msg后,发送了处理成功的消息给支付宝,正常情况下支付宝应该要删除消息msg,但如果支付宝这时候悲剧的挂了,重启后一看消息msg还在,就会继续发送消息msg。

解决方法很简单,在余额宝这边增加消息应用状态表(message_apply),通俗来说就是个账本,用于记录消息的消费情况,每次来一个消息,在真正执行之前,先去消息应用状态表中查询一遍,如果找到说明是重复消息,丢弃即可,如果没找到才执行,同时插入到消息应用状态表(同一事务)。

1
2
3
4
5
6
7
8
for each msg in queue
begin transaction
select count(*) as cnt from message_apply where msg_id=msg.msg_id;
if cnt==0 then
update B set amount=amount+10000 where userId=1;
insert into message_apply(msg_id) values(msg.msg_id);
end transaction
commit;

Ebay的研发人员早在2008年就提出了应用消息状态确认表来解决消息重复投递的问题。