背景
当我们的单个数据库的性能产生瓶颈的时候,为了降低单点压力,我们可能会对数据库进行分库,分片,将数据库就处于不同的节点上,这个时候单个数据库的ACID已经不能适应这种情况了。
还有最近火的微服务,每个功能模块都是独立拆开的,也就是每个模块的数据库处于多个服务器上。当a模块业务和b模块的业务有关联时候,在a模块事务中通过RPC调用b模块数据库时,这就造成了分布式事务问题。
举个简单的列子举例:在订单服务中,生成订单:1、先减库存;【本地调用】2、然后扣钱【远程调用】。这时,有一个问题,这两步无论操作的先后顺序,比如,第一步先操作,当第一步完成后,第二步操作失败。一般人都认为,这时数据库中的数据会回滚。回滚吗?答案不是的,操作两个服务,代表的是两个事物。
这就是一个分布式事务问题,分布式事务的本质在于:一个事务涉及到的数据库不在同一个节点上,可能部分节点提交失败,而提交成功的节点不会回滚。
总结:事务是数据库的一个逻辑单元,只要一个操作设计到多个数据库,不管是否在同一个节点,都会有分布式事务问题,因为数据库是事务的基本单元。简单来说分布式事务就是指会涉及到操作多个数据库的事务。
这时我们就需要引入一个新的理论原则来适应这种集群的情况,就是 CAP 原则或者叫CAP定理,
CAP理论
分布式中我们往往为了可用性和分区容错性,忍痛放弃强一致支持,转而追求最终一致性。大部分业务场景下,我们是可以接受短暂的不一致的。
CAP理论:
- 一致性(Consistency):客户端一系列的操作都会同时发生(生效)。简单点说就是各个节点数据同步的一致性问题。
- 可用性(Availability) :是指每一个请求,都能得到一个及时的响应。
-
分区容错性(Partition tolerance) :即使出现单个组件无法可用,操作依然可以完成。就是集群中一个节点故障不影响整个集群的使用。
CAP理论适用于所有分布式存储系统,最多能满足俩个条件,3个条件不能同时都满足,且分区容错性是必须满足的。也就是在一致性和可用性之间取舍,而一致性和可用性又是相对的,所以说最优的分布式方案是就是在一致性和可用性之间取得平衡,接着出现了base理论。
一些常见分布式存储系统分析
- pxc的特点就是同步复制,强一致性。既然是同步复制肯定响应时间会变长,也就是牺牲可用性,换取强一致性。是一种使用CP的分布式集群。
- replication的特点就是异步复制,有时候会发现从节点数据未同步主节点的数据。replication是数据最终一致性,也就是牺牲一致性换取可用性,能够及时响应。是一种AP的分布式集群。
- redis是非关系型数据库,属于nosql。有了cluster功能后,redis的CAP模型从AP变成了CP。redis cluster通过哈希槽进行自动分片,且有了冗余节点。在主节点写入数据时会同步到从节点,并不是强一致性从而保证了可用性。
- mongodb是关系型数据库,属于nosql。也是AP模型。
base理论
在大多数分布式存储系统中,我们往往追求的是可用性,它的重要性要比一致性高,都需要牺牲强一致性来换取系统的高可用性,系统往往只需要保证“最终一致性”,只要这个最终时间是在用户可以接受的范围内即可。
举个例子。我们比如买机票,支付完成以后,只支付完成状态,然后返回给用户了,我们过几分钟再刷新页面,才会看到变成已出票,订单完成状态。
这个时候,如果我们要求所有处理,都是强一致性的,那么就完蛋了。页面要死在那儿几分钟,才把这个事务处理完成,返回给用户。
前辈们就提出了base理论,也是对CAP理论的补充
- Basically Available(基本可用)
- Soft state(软状态)
- Eventually consistent(最终一致性)
base理论是一致性和可用性平衡的结果。理论的核心就是牺牲强一致性来保证可用性,且数据最终一致性。
常见的解决方案
- 俩阶段提交协议-2pc(two phase commit)
- 投票阶段:事务协调者(事务管理器)给每个参与者(资源管理器)发送Prepare消息,每个参与者要么直接返回失败,要么在本地执行事务,写本地的redo和undo日志,但不提交(也就是没有commit),也就意味着资源一直加锁,直到第二阶段完成后,资源释放。
- 提交阶段:根据反馈结果判断是否进行提交。其中只要有一个数据库否认提交,那么所有数据库都要求回滚他们在此事务的操作。
具体请看2pc和3pc的区别
- 三阶段提交协议
- CanCommit阶段:询问各个参与者是否可以提交,这个阶段并没有执行事务。
- PreCommit阶段:协调者根据CanCommit返会的结果判断是否执行事务,但事务此阶段未提交,只是写入事务日志,处于准备提交阶段。将执行结果返回给协调者。
- doCommit阶段:协调者根据PreCommit阶段的结果,来判断参与者是否提交事务。如果提交完成,资源释放,如果未提交,各个参与者根据在PreCommit阶段中的事务日志进行rollback。
具体请看2pc和3pc的区别
-
TCC 强一致性方案(补偿事务)
TCC的全称是:
Try(尝试)
Confirm(确认/提交)
Cancel(回滚)。这个其实是用到了补偿的概念,分为了三个阶段:
Try阶段:这个阶段说的是对各个服务的资源做检测以及对资源进行锁定或者预留;
Confirm阶段:这个阶段说的是在各个服务中执行实际的操作;
Cancel阶段:如果任何一个服务的业务方法执行出错,那么这里就需要进行补偿,就是执行已经执行成功的业务逻辑的回滚操作;还是给大家举个例子:比如跨银行转账的时候,要涉及到两个银行的分布式事务,如果用TCC方案来实现,思路是这样的:
Try阶段:先把两个银行账户中的资金给它冻结住就不让操作了
Confirm阶段:执行实际的转账操作,A银行账户的资金扣减,B银行账户的资金增加
Cancel阶段:如果任何一个银行的操作执行失败,那么就需要回滚进行补偿,就是比如A银行账户如果已经扣减了,但是B银行账户资金增加失败了,那么就得把A银行账户资金给加回去适用场景:
这种方案说实话几乎很少有人使用,我们用的也比较少,但是也有使用的场景。
因为这个事务回滚实际上是严重依赖于你自己写代码来回滚和补偿了,会造成补偿代码巨大,非常之恶心。
比如说我们,一般来说跟钱相关的,跟钱打交道的,支付、交易相关的场景,我们会用TCC,严格保证分布式事务要么全部成功,要么全部自动回滚,严格保证资金的正确性,在资金上不允许出现问题
本地消息表和消息事务本质都是将分布式事务拆分成多个本地事务
还是以上面那个下单为例子。那么现在问题来了,库存模块减完库存后如何通知支付模块扣钱呢?在微服务中是使用RPC来进行服务器与服务器之间通讯的。现在拆分成多个本地事务后通常采用以下俩种方式,为什么不使用RPC来通讯,请看思考。
本地消息表(异步确保)
库存模块实行减库操作后,将记录写到一个消息表里面。这个消息表记录库存模块减库操作是否完成。消息表一定和库存模块在一个库,这样库存模块的mysql事务能够保证减库操作和写消息表操作的一致性(出现分布式事务的原因就是一个事务内涉及到你多个库的操作。
在支付模块采用定时轮训的方式检查消息表,确认减库成功后,执行扣钱操作。
弊端:频繁的轮训,性能一定损耗大而且效率不是很好。支付模块如果事务执行失败,库存模块无法回退。-
消息事务 最终一致性方案(rocketMq)
消息队列分为事务消息和非事务消息,比较常见的rabbitMQ、kafka等都不支持事务属于非事物消息,阿里的rockerMQ 4.3 版本以上支持事务,属于消息事务。分析在生产者端需要考虑的几种场景:
- 先减库后mq。减库成功,mq发送失败。这种情况非事务消息和事务消息都会捕获异常,减库回退。
- 先减库后mq。减库失败,mq发送停止。这种情况非事务消息和事务消息也会捕获异常,不会执行mq发送消息操作。
- 先mq后减库。mq发送失败,减库操作不执行。种情况非事务消息和事务消息也会捕获异常,不会执行减库操作。
- 先mq后减库。mq发送成功,减库失败。如果遇到这种情况,非事务消息则无能为力,发送的消息则不会回退。而事务消息则是与你mq的执行顺序是没有关系的。它会先确定减库(本地事务)能否成功,如果失败的话则是不会发发送mq。所以说这是非事务与事务的关键。
分析在消费端需要考虑的几种场景:
- 生产者发送消息成功而消费端业务消费失败。这种情况非事务消息和事务消息都会执行重试,因为一般执行失败的原因是网络抖动或者数据库瞬间负载太高,都是暂时性问题,如果还是失败的话可以触发报警由人工回滚或者补偿。也是使用mq的好处。
- 在网络不稳定的情况下,在消费端有可能会出现重复消费。请看下面的消费幂等的思考。
库存模块实行减库操作后,往消息队列中发送消息。然后在支付模块订阅消息后,执行扣款操作。现在就有一个问题,如果是非事务消息的话,这里很难保证在减库操作完成后队列发送成功的。这里你可能会说如果队列发送不成功,我会捕获到异常,然后事务rollback。这样是可以,但是如果先发送消息,后执行减库,最后消息发送成功了,减库失败了,这时事务回退,但是消息已经发出去了。简单点说就是,如果是非事务消息的话,mysql的事务是保证不了mysql操作和消息队列的一致性的。
事务消息就可以保证一致性,以rocketMQ讲解,实质也是使用了2pc原理。在确认本地事务可以commit的情况下才会发送消息,保证了本地事务的一致性了
交互流程
消息队列 RocketMQ 事务消息交互流程如下所示:
其中:
- 发送方向消息队列 RocketMQ 服务端发送消息。
- 服务端将消息持久化成功之后,向发送方 ACK 确认消息已经发送成功,此时消息为半消息。
- 发送方开始执行本地事务逻辑。
- 发送方根据本地事务执行结果向服务端提交二次确认(Commit 或是 Rollback),服务端收到 Commit 状态则将半消息标记为可投递,订阅方最终将收到该消息;服务端收到 Rollback 状态则删除半消息,订阅方将不会接受该消息。
- 在断网或者是应用重启的特殊情况下,上述步骤 4 提交的二次确认最终未到达服务端,经过固定时间后服务端将对该消息发起消息回查。
- 发送方收到消息回查后,需要检查对应消息的本地事务执行的最终结果。
- 发送方根据检查得到的本地事务的最终状态再次提交二次确认,服务端仍按照步骤 4 对半消息进行操作。
那么现在可能有问题了,如果本地事务已经commit且消息也已经发送成功了,但是消息订阅方的事务却执行失败,这时本地事务已经提交了,该怎么办
- 首先消息会一直重试
),最大重试次数为16次,如果16次仍然失败的话,消息将不会投递- 这时这些消息成为死信消息,存储死信消息的队列为死信队列
。死信队列最多存储3天,3天后删除,所以在3天内需要尽快处理。可以登录消息队列 RocketMQ 控制台提供对死信消息的查询、导出和重发的功能
最后最好使用zookeeper,它们有非常及时的通知机制来通知服务消费者服务列表发生了变更。当消费失败后可以及时的得到通知。
思考
- 在没有分片的pxc和repliaction集群中会优分布式事务吗?
首先明确分布式事务产生的原因是由于事务中涉及到多个数据库的操作,从而无法保证事务的一致性。例如pxc集群上面有负载均衡,代码中连接的是负载均衡,在事务中的所有操作都是针对的同一数据库,经验证。replication使用主从复制,读写分离的特性,读从库,写主库,所以会产生分布式事务。 - 为什么不使用RPC来通讯而是使用本地消息表或者消息事务来通讯呢?
- 阻塞的。
- 如果需要和多个模块进行交互时,得调用多次RPC。貌似使用消息队列效率更好,只需发送一次消息即可。
- 使用RPC无法保证数据一致性和上面的非事务消息一种情况。但是使用本地消息表或者消息事务时通过事务可以保证数据一致性。
- 消息幂等问题
简单的说就是建一张消费状态表。messageId有可能重复,所以根据自己的业务逻辑生产一个唯一id,每次消费消息时都先判断该 id是否被消费,如果消费则退出,如果未消费则执行本地事务和插入消费状态表一条该id已消费记录。
参考文档
2pc和3pc的区别
聊聊分布式
讲解分布式事务的良心之作