分布式系统的事务一致性是一个技术难题

OLTP系统领域,很多业务场景都会面临事务一致性方面的需求,最经典转账的案例

传统企业开发,系统往往是以单体应用形式存在,没有横跨多个数据库

通常只需借助开发平台中特有数据访问技术和框架(Spring、JDBC、ADO.NET),结合rdbms自带事务管理机制来实现事务性的需求(ACID)

互联网平台往往由一系列分布式系统构成,开发语言平台和技术栈也相对比较杂,尤其微服务架构盛行,看起来简单的功能,内部可能需要调用多个“服务”并操作多个数据库或分片来实现,情况复杂很多

单一技术手段和解决方案,无法应对和满足这些复杂的场景

分布式系统中,同时满足“CAP定律”三者不可能,比现实中找对象需同时满足“高、富、帅”或“白、富、美”更加困难

互联网领域绝大多数的场景,都需要牺牲强一致性来换取系统的高可用性,往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可

分布式事务

分布式系统,必然要提分布式事务

理解分布式事务,得介绍两阶段提交协议

简单但不精准的例子来说明:

第一阶段,张老师作为“协调者”,给小强和小明(参与者、节点)发微信,组织他们俩明天8点在学校门口集合,一起去爬山,然后开始等待小强和小明答复

第二阶段,如果小强和小明都回答没问题,那么大家如约而至。如果小强或者小明其中一人回答说“明天没空,不行”,那么张老师会立即通知小强和小明“爬山活动取消”

这个过程中可能有很多问题。小强没看手机,张老师会一直等着答复,小明可能在家里把爬山装备都准备好了却一直等着张老师确认信息

更严重的是,如果到明天8点小强还没有答复,那么就算“超时”了,那小明到底去还是不去集合爬山呢?

这就是两阶段提交协议的弊病,业界引入三阶段提交协议解决该类问题

两阶段提交协议在主流开发语言平台,数据库产品中都有广泛应用和实现的

XOpen组织的DTP模型图

XA协议指TM(事务管理器)和RM(资源管理器)间的接口

主流RDBMS都实现了XA接口

JTA符合X/Open DTP模型,事务管理器和资源管理器之间也使用了XA协议

本质上也是借助两阶段提交协议来实现分布式事务

XA事务成功和失败的模型图

WebLogic、Webshare提供了JTA的实现和支持。Tomcat没有实现的(不是JavaEE应用服务器),需要第三方的框架Jotm、Automikos等来实现,均支持spring事务整合

.NET平台,借助ado.net中TransactionScop API编程实现,还必须配置和借助MSDTC服务

mysql部署在Linux平台,无法支持分布式事务

总结:

这种方式实现难度不算太高,比较适合传统的单体应用,同一个方法中存在跨库操作的情况

但分布式事务对性能的影响较大,不适合高并发和高性能要求场景

提供回滚接口

服务化架构,功能X,需要去协调后端的A、B甚至更多的原子服务

如A和B其中一个调用失败了,怎么办?

往往提供一个BFF层来协调调用A、B服务

如果有些需要同步返回结果,尽量按照“串行”的方式去调用

调A失败,不去调B

调A成功,调用B失败,尝试去回滚刚刚对A的调用操作

有时不必严格提供单独对应的回滚接口,可以通过传递参数实现

尽量把可提供回滚接口的服务放在前面。举例:

论坛网站,每天登录成功后奖励5个积分,但积分和用户是两套独立的子系统服务,对应不同DB,控制起来比较麻烦。解决思路:

  1. 登录和加积分的服务调用放在BFF层一个本地方法中
  2. 请求登录接口时,先加积分操作,成功后再执行登录操作
  3. 登录成功,最好,积分也加成功。登录失败,则调用加积分对应的回滚接口(执行减积分的操作)

总结

缺点比较多,复杂场景不推荐使用,适用简单场景,容易提供回滚,依赖的服务也非常少的情况

代码量庞大,耦合性高。非常有局限性,很多的业务无法简单实现回滚,串行的服务很多,回滚的成本实在太高

本地消息表

思路源于ebay,后支付宝等公司布道,业内广泛使用

基本设计思想,将远程分布式事务拆分成一系列的本地事务

经典跨行转账的例子

第一步,扣款1W,通过本地事务保证凭证消息插入到消息表

begin transaction
    update A set amount=amount-1000 where userId=1;
    insert into message(userId,amount,status) values(1,1000,1);
end transaction
commit;

第二步,通知对方银行账户上加1W。如何通知到对方?

通常采用两种方式:

  1. 时效性高的MQ,由对方订阅消息并监听,有消息时自动触发事件
  2. 定时轮询扫描,去检查消息表的数据

各有利弊,仅靠MQ,可能通知失败;频繁定时轮询,90%无用功。一般两种方式结合使用

解决了通知的问题,还有消息有重复消费问题,往用户帐号上多加了钱

其实可以在消息消费方,也通过“消费状态表”记录消费状态

执行“加款”操作之前,检测下该消息(提供标识)是否已经消费过,消费完成后,通过本地事务控制来更新这个“消费状态表”。避免重复消费的问题

总结

基本避免分布式事务,实现“最终一致性”

但 RDBMS吞吐量和性能存在瓶颈,频繁读写消息给数据库造成压力

真正的高并发场景下,也会有瓶颈和限制

MQ(非事务消息)

非事务消息支持的MQ产品,难将业务操作与MQ操作放在一个本地事务域中管理

跨行转账,难保证扣款完成后对MQ投递消息一定能成功。一致性似乎很难保证。

先从消息生产者这端来分析

public void trans(){
    try{
        bool result=dao.update(model);
        if(result){
            mq.send(model);
        }
    }catch(Exception e){
        rollback();
    }
}
  1. DB成功,MQ投递也成功,皆大欢喜
  2. DB失败,不向MQ发消息
  3. DB成功,但MQ投递失败,向外抛出异常,DB回滚

没有问题

消费端

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

如何保证消息与业务操作一致,不丢失?

主流MQ都有持久化消息的功能。消费者宕机或消费失败,都可执行重试机制(甚至自定义重试次数)

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

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

总结

比较常见,性能和吞吐量优于RDBMS消息表方案

MQ自身和业务都具有高可用性,理论上可满足大部分业务场景

没有充分测试的情况下,不建议在交易业务中直接使用

MQ(事务消息)

Bob向Smith转账 ,到底先发送消息,还是先执行扣款操作?

都可能会出问题

先发消息,扣款失败,Smith会多出一笔钱

反过来,先扣款,后发消息,可能扣款成功但是消息没发出去,Smith收不到钱

除异常捕获和回滚方式外

RocketMQ设计和实现思路

第一阶段发送Prepared消息时,拿到消息地址

第二阶段执行本地事物

第三阶段通过第一阶段拿到的地址去访问消息,并修改状态

但如果确认消息发送失败怎么办?

RMQ定期扫描消息集群中的事物消息,发现了Prepared消息,会向消息发送者确认,Bob的钱到底是减了还是没减呢?

如果减了是回滚还是继续发送确认消息?

RMQ根据发送端设置的策略来决定是回滚还是继续发送确认消息

保证了消息发送与本地事务同时成功或同时失败

总结

各大知名的电商平台和互联网公司,几乎都是采用类似的设计思路来实现“最终一致性”

适合的业务场景广泛,而且比较可靠。技术实现难度比较大

主流开源MQ(ActiveMQ、RabbitMQ、Kafka)均未实现对事务消息的支持,需二次开发或者新造轮子

RMQ 事务消息部分的代码也并未开源,需要自己去实现

其他补偿方式

支付宝交易接口,一般会在支付宝的回调接口里,解密参数,然后调用系统中更新交易状态相关的服务,将订单更新为付款成功

同时,只有当回调页面中输出了success字样或者标识业务处理成功相应状态码时,支付宝才会停止回调请求。否则,支付宝会每间隔一段时间后,再向客户方发起回调请求,直到输出成功标识为止。

这就是一个很典型的补偿例子,跟一些MQ重试补偿机制很类似

一般成熟的系统中,对于级别较高的服务和接口,整体的可用性通常都会很高。如果有些业务由于瞬时的网络故障或调用超时等问题,这种重试机制非常有效

极端的场景,系统自身有bug或者程序逻辑有问题,重试1W次也无济于事

“明明已经付款,却显示未付款不发货”类似的悲剧

为了交易系统更可靠,一般会在类似交易这种高级别的服务代码中,加入详细日志记录,系统内部引发类似致命异常,会有邮件通知

同时,后台定时任务扫描和分析此类日志,检查出这种特殊的情况,尝试通过程序来补偿并邮件通知相关人员

某些特殊的情况下,还会有“人工补偿”,最后一道屏障