为什么分布式系统下,事务的ACID原则难以满足?
这得从CAP定理和BASE理论说起。
- Consistency(一致性)
- Availability(可用性)
- Partition tolerance (分区容错性)
它们的第一个字母分别是 C、A、P。这三个指标不可能同时做到。这个结论就叫做 CAP 定理。
Partition tolerance,中文叫做"分区容错"。大多数分布式系统都分布在多个子网络。每个子网络就叫做一个区(partition)。分区容错的意思是,区间通信可能失败。比如,一台服务器放在上海,另一台服务器放在北京,这就是两个区,它们之间可能因网络问题无法通信。
上图中,G1 和 G2 是两台跨区的服务器。G1 向 G2 发送一条消息,G2 可能无法收到。系统设计的时候,必须考虑到这种情况。一般来说,分布式系统,分区容错无法避免,因此可以认为 CAP 的 P 总是成立。根据CAP 定理,剩下的 C 和 A 无法同时做到。
Consistency 中文叫做"一致性"。意思是,写操作之后的读操作,必须返回该值。举例来说,某条记录是 v0,用户向 G1 发起一个写操作,将其改为 v1。
接下来,用户的读操作就会得到 v1。这就叫一致性。
问题是,用户有可能向 G2 发起读操作,由于 G2 的值没有发生变化,因此返回的是 v0。G1 和 G2 读操作的结果不一致,这就不满足一致性了。
为了让 G2 也能变为 v1,就要在 G1 写操作的时候,让 G1 向 G2 发送一条消息,要求 G2 也改成 v1。
这样的话,用户向 G2 发起读操作,也能得到 v1。
Availability 中文叫做"可用性",意思是只要收到用户的请求,服务器就必须给出回应(对和错不论)。用户可以选择向 G1 或 G2 发起读操作。不管是哪台服务器,只要收到请求,就必须告诉用户,到底是 v0 还是 v1,否则就不满足可用性。
答案很简单,因为可能通信失败(即出现分区容错)。
如果保证 G2 的一致性,那么 G1 必须在写操作时,锁定 G2 的读操作和写操作。只有数据同步后,才能重新开放读写。锁定期间,G2 不能读写,没有可用性不。
如果保证 G2 的可用性,那么势必不能锁定 G2,所以一致性不成立。
综上所述,G2 无法同时做到一致性和可用性。系统设计时只能选择一个目标。如果追求一致性,那么无法保证所有节点的可用性;如果追求所有节点的可用性,那就没法做到一致性。
BASE是三个单词的缩写:
而我们解决分布式事务,就是根据上述理论来实现。
还以上面的下单减库存和扣款为例:
订单服务、库存服务、用户服务及他们对应的数据库就是分布式应用中的三个部分。
由上面的两种思想,延伸出了很多的分布式事务解决方案:
分布式事务的解决手段之一,就是两阶段提交协议(2PC:Two-Phase Commit)
那么到底什么是两阶段提交协议呢?
1994 年,X/Open 组织(即现在的 Open Group )定义了分布式事务处理的DTP 模型。该模型包括这样几个角色:
在该模型中,一个分布式事务(全局事务)可以被拆分成许多个本地事务,运行在不同的AP和RM上。每个本地事务的ACID很好实现,但是全局事务必须保证其中包含的每一个本地事务都能同时成功,若有一个本地事务失败,则所有其它事务都必须回滚。但问题是,本地事务处理过程中,并不知道其它事务的运行状态。因此,就需要通过CRM来通知各个本地事务,同步事务执行的状态。
因此,各个本地事务的通信必须有统一的标准,否则不同数据库间就无法通信。XA就是 X/Open DTP中通信中间件与TM间联系的接口规范,定义了用于通知事务开始、提交、终止、回滚等接口,各个数据库厂商都必须实现这些接口。
二阶提交协议就是根据这一思想衍生出来的,将全局事务拆分为两个阶段来执行:
这个过程中需要一个协调者(coordinator),还有事务的参与者(voter)。
正常情况:
投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行成功的信息(agree
)
提交阶段:协调组发现每个参与者都可以执行事务(agree
),于是向各个事务参与者发出commit
指令,各个事务参与者提交事务。
异常情况:
投票阶段:协调组询问各个事务参与者,是否可以执行事务。每个事务参与者执行事务,写入redo和undo日志,然后反馈事务执行结果,但只要有一个参与者返回的是Disagree
,则说明执行失败。
提交阶段:协调组发现有一个或多个参与者返回的是Disagree
,认为执行失败。于是向各个事务参与者发出abort
指令,各个事务参与者回滚事务。
二阶段提交的问题:
面对二阶段提交的上述缺点,后来又演变出了三阶段提交,但是依然没有完全解决阻塞和资源锁定的问题,而且引入了一些新的问题,因此实际使用的场景较少。
TCC模式可以解决2PC中的资源锁定和阻塞问题,减少资源锁定时间。
它本质是一种补偿的思路。事务运行过程包括三个方法,
执行分两个阶段:
这种实现方式的思路,其实是源于ebay,其基本的设计思想是将远程分布式事务拆分成一系列的本地事务。
一般分为事务的发起者A和事务的其它参与者B:
几个注意事项:
那么问题来了,我们如何保证消息发送一定成功?如何保证消费者一定能收到消息?
RocketMQ本身自带了事务消息,可以保证消息的可靠性,原理其实就是自带了本地消息表。
RabbitMQ确保消息不丢失的思路比较奇特,并没有使用传统的本地表,而是利用了消息的确认机制:
经过上面的两种确认机制,可以确保从消息生产者到消费者的消息安全,再结合生产者和消费者两端的本地事务,即可保证一个分布式事务的最终一致性。
总结上面的几种模型,消息事务的优缺点如下:
2019年 1 月份,Seata 开源了 AT 模式。AT 模式是一种无侵入的分布式事务解决方案。可以看做是对TCC或者二阶段提交模型的一种优化,解决了TCC模式中的代码侵入、编码复杂等问题。
在 AT 模式下,用户只需关注自己的“业务 SQL”,用户的 “业务 SQL” 作为一阶段,Seata 框架会自动生成事务的二阶段提交和回滚操作。
先来看一张流程图:
有没有感觉跟TCC的执行很像,都是分两个阶段:
但AT模式底层做的事情可完全不同,而且第二阶段根本不需要我们编写,全部有Seata自己实现了。也就是说:我们写的代码与本地事务时代码一样,无需手动处理分布式事务。
那么,AT模式如何实现无代码侵入,如何帮我们自动实现二阶段代码的呢?
一阶段
在一阶段,Seata 会拦截“业务 SQL”,首先解析 SQL 语义,找到“业务 SQL
”要更新的业务数据,在业务数据被更新前,将其保存成“before image
”,然后执行“业务 SQL
”更新业务数据,在业务数据更新之后,再将其保存成“after image
”,最后获取全局行锁,提交事务。以上操作全部在一个数据库事务内完成,这样保证了一阶段操作的原子性。
这里的before image
和after image
类似于数据库的undo和redo日志,但其实是用数据库模拟的。
二阶段提交
二阶段如果是提交的话,因为“业务 SQL
”在一阶段已经提交至数据库, 所以 Seata 框架只需将一阶段保存的快照数据和行锁删掉,完成数据清理即可。
二阶段回滚:
二阶段如果是回滚的话,Seata 就需要回滚一阶段已经执行的“业务 SQL
”,还原业务数据。回滚方式便是用“before image
”还原业务数据;但在还原前要首先要校验脏写,对比“数据库当前业务数据”和 “after image
”,如果两份数据完全一致就说明没有脏写,可以还原业务数据,如果不一致就说明有脏写
,出现脏写就需要转人工处理。
不过因为有全局锁机制,所以可以降低出现脏写
的概率。
AT 模式的一阶段、二阶段提交和回滚均由 Seata 框架自动生成,用户只需编写“业务 SQL”,便能轻松接入分布式事务,AT 模式是一种对业务无任何侵入的分布式事务解决方案。
Seata中的几个基本概念:
一阶段:
before_image
after_image
undo_log
并写入数据库二阶段:
before_image
和after_image
信息,释放全局锁before_image
,清除before_image
和after_image
优点: