分布式系统事务一致性解决方案

Posted by Clear Blog on September 8, 2017

在分布式系统中,同时满足“CAP定律”中的“一致性”、“可用性”和“分区容错性”三者是不可能的。 在互联网领域的绝大多数的场景,都需要牺牲强一致性来换取系统的高可用性, 系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。

提供回滚接口

在多个原子操作前加BFF层来协调调用A、B服务, 如果有些需要同步返回结果,可以按照”串行”的方式去调用。 如果调用A失败,则不会盲目去调用B。 如果调用B失败,则尝试去回滚调用A的操作。

举个例子说明: 我们的某个论坛网站,每天登录成功后会奖励用户5个积分, 但是积分和用户又是两套独立的子系统服务,对应不同的DB,这控制起来就比较麻烦了。

解决思路: 1.把登录和加积分的服务调用放在BFF层一个本地方法中。

2.当用户请求登录接口时,先执行加积分操作,加分成功后再执行登录操作 如果登录成功,那当然最好了,积分也加成功了。 如果登录失败,则调用加积分对应的回滚接口(执行减积分的操作)。

总结:这种方式缺点比较多,通常在复杂场景下是不推荐使用的,除非是非常简单的场景, 非常容易提供回滚,而且依赖的服务也非常少的情况。

这种实现方式会造成代码量庞大,耦合性高。而且非常有局限性,因为有很多的业务是无法很简单的实现回滚的, 如果串行的服务很多,回滚的成本实在太高。

本地消息表

举个经典的跨行转账的例子来描述。

第一步伪代码如下,扣款1W,通过本地事务保证了凭证消息插入到消息表中。

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
cmmmit;

第二步,通知对方银行账户上加1W了。 通常采用两种方式: 采用时效性高的MQ,由对方订阅消息并监听,有消息时自动触发事件。 采用定时轮询扫描的方式,去检查消息表的数据。 两种方式其实各有利弊,仅仅依靠MQ,可能会出现通知失败的问题。 而过于频繁的定时轮询,效率也不是最佳的(90%是无用功)。 所以,我们一般会把两种方式结合起来使用。

通知的问题解决了,那么消息消费方如果消息重复消费,往用户账号上多加了钱。 对此我们可以在消费方,也通过一个消费状态表来记录消费状态。在执行“加款”操作之前, 检测下该消息(提供标识)是否已经消费过,消费完成后,通过本地事务控制来更新这个“消费状态表”。 这样子就避免重复消费的问题。

上诉的方式是一种非常经典的实现,基本避免了分布式事务,实现了“最终一致性”。 但是,关系型数据库的吞吐量和性能方面存在瓶颈,频繁的读写消息会给数据库造成压力。 所以,在真正的高并发场景下,该方案也会有瓶颈和限制的。

MQ(非事务消息)

先看伪代码

1
2
3
4
5
6
7
8
9
10
11
12
public void trans(){
    try{
        //1.操作数据库
        bool result = dao.update(model);//操作数据库失败,会抛出异常
        //2.如果第一步成功,则操作消息队列(投递消息)
        if(result) {
            mq.append(model);//如果向mq中投递消息失败,方法内部抛出异常
        }
    }catch (Exception e) {
        rollback();//如果发生异常,回滚
    }
}

根据上述代码及注释,我们来分析下可能的情况:

  • 操作数据库成功,向MQ中投递消息也成功,皆大欢喜
  • 操作数据库失败,不会向MQ中投递消息了
  • 操作数据库成功,但是向MQ中投递消息时失败,向外抛出了异常,刚刚执行的更新数据库的操作将被回滚
  • 从上面分析的几种情况来看,貌似问题都不大的。那么我们来分析下消费者端面临的问题:

消息出列后,消费者对应的业务操作要执行成功。如果业务执行失败,消息不能失效或者丢失。 需要保证消息与业务操作一致, 尽量避免消息重复消费。如果重复消费,也不能因此影响业务结果。 如何保证消息与业务操作一致,不丢失?

主流的MQ产品都具有持久化消息的功能。如果消费者宕机或者消费失败,都可以执行重试机制的 (有些MQ可以自定义重试次数)。

如何避免消息被重复消费造成的问题?

  • 保证消费者调用业务的服务接口的幂等性
  • 通过消费日志或者类似状态表来记录消费状态,便于判断(建议在业务上自行实现,而不依赖MQ产品提供该特性)

总结:这种方式比较常见,性能和吞吐量是优于使用关系型数据库消息表的方案。 如果MQ自身和业务都具有高可用性,理论上是可以满足大部分的业务场景的。 不过在没有充分测试的情况下,不建议在交易业务中直接使用。

MQ(事务消息)

举个例子,Bob向Smith转账,那我们到底是先发送消息,还是先执行扣款操作?

好像都可能会出问题。如果先发消息,扣款操作失败,那么Smith的账户里面会多出一笔钱。 反过来,如果先执行扣款操作,后发送消息,那有可能扣款成功了但是消息没发出去,Smith收不到钱。

RocketMQ第一阶段发送Prepared消息时,会拿到消息的地址, 第二阶段执行本地事物, 第三阶段通过第一阶段拿到的地址去访问消息,并修改状态。 如果确认消息发送失败了怎么办? RocketMQ会定期扫描消息集群中的事物消息,这时候发现了Prepared消息,它会向消息发送者确认, Bob的钱到底是减了还是没减呢?如果减了是回滚还是继续发送确认消息呢? RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。 这样就保证了消息发送与本地事务同时成功或同时失败。 transaction_mq 最靠谱的实现方式。