分布式事务问题是分布式系统绕不开的技术话题。
“谈谈你对分布式事务的理解”、“分享下你团队在分布式事务上的解决方案”、“你用过哪几种分布式事务的中间件”?在技术交流/面试中很容易讨论到这些个话题,得到的反馈大概率是这样的:
的确,分布式事务问题比中间件技术问题难回答的多,因为:
1、它是一个解决方案。而不仅仅是某个功能的技术实现解答。
2、分布式事务是一项“有损解决方案”,是一项取舍决策,背后是业务适用性。
3、解决方案要基于业务场景,以及业务场景分析。不存在一招鲜吃遍天的最佳方案。
3、分布式事务是个综合的技术问题,涉及:业务代码、RPC、幂等性、DB、并发处理、局部高可用、网络分区、一致性视图等问题。
据笔者交流反馈的经验,在多数团队中并未落地“像样”的分布式方案。分布式事务在线上系统的应用比例是很小的。相比分布式事务的学习复杂度和实际应用概率,真有点“面试造火箭,实际拧螺丝”的感觉。
但,不论线上应用比例怎样。作为对技术深度的好奇和掌握(尤其是一堆技术的协同的原理掌握),我们仍要扎实的掌握分布式事务。做到知一返三、游刃有余。
如上图:
分布式事务的目的或本质是追求分布式的多个DB数据的一致性。
和本地数据库事务的本质原理是一样的。只不过是放大到一套技术栈中去实现,更多考虑因素和复杂度。
(以Mysql为例)本地数据库事务原理:undo log(原子性) + redo log(持久性) + 数据库锁(原子性&隔离性) + MVCC(隔离性)
分布式事务原理:全局事务协调器(原子性) + 全局锁(隔离性) + DB本地事务(原子性、持久性)
注,“一致性” 靠 “原子性 + 持久性 + 隔离性” 三者共同完成
问题:
1、分布式系统中,日常研发线上发版,需要考虑分布式事务问题吗?如果要考虑,怎么应对?
2、分布式事务相关的技术问题:RPC、幂等性、DB、并发处理、局部高可用、网络分区、一致性视图等,胸有成竹吗?
下文并不会基于这两理论分解,毕竟是理论,不能解决实际的问题
但理论知识还是需要了解一些的。故,快速浏览下。
CAP原则:
一个分布式系统最多只能同时满足一致性(Consistency)、可用性(Availability)和分区容错性(Partition tolerance)中的两项。
一致性(Consistency):一致性指“all nodes see the same data at the same time”,即更新操作成功并返回客户端完成后,所有节点在同一时间的数据完全一致。
可用性(Availability):可用性指“Reads and writes always succeed”,即服务一直可用,而且是正常响应时间。
分区容错性(Partition tolerance):分区容错性指“the system continues to operate despite arbitrary message loss or failure of part of the system”,即分布式系统在遇到某节点或网络分区故障的时候,仍然能够对外提供满足一致性和可用性的服务。
CAP的取舍策略:
策略 | 解释 |
---|---|
CA | 单点集群,满足一致性,可用性的系统,通常可扩展性较差。例如传统的单机数据库。 特点:故障时完全不可用。 |
【常选】AP | 满足可用性,分区容错的系统,对一致性要求低一些,是很多分布式系统设计时的选择。 例如:Redis,HBase,Eureka。 例如:各云厂商的SLA几个9,也是牺牲了强一致性。 |
CP | 满足一致性,分区容错的系统,通常性能不是特别高 例如:Zookeeper(通过ZAB协议达到强一致性) |
BASE理论是对CAP理论的延伸,对AP的细化。
核心思想是“即使无法做到强一致性,但应可以采用适合的方式达到最终一致性”。
BASE是指:基本可用(Basically Available)、软状态( Soft State)、最终一致性( Eventual Consistency)。
基本可用(Basically Available)
软状态( Soft State)
最终一致性( Eventual Consistency)
实际性问题:
在商业中,越是大型的公司越是需要在线服务能力强的大型分布式系统,在线可用性和响应能力是大型公司的生命支柱(很多线上一次大故障毁掉一个公司的案例)【基本已排除CA和CP】。
且,数据最终一致的特点留给架构很多发挥的余地。即使是关键异常,也可以通过补偿的方式或人工的方式处理。
- 阿里Seata:http://seata.io/zh-cn/
- Hmily:https://github.com/dromara/hmily
仅提供数据参考
一、一般业务都倾向选最终一致性
绝大部分使用“自研补偿/MQ方案 + 人工介入”。
选型取向:
笔者也赞同一般团队选型此方式。毕竟分布式事务问题是小概率事件,留有补救余地就行,性能的损失可是实打实的反应在线上每一个请求上。
二、阿里Seata AT模式,平均性能会降低35%以上
三、RocketMQ事务消息
两阶段提交(Two-phase Commit,2PC)
5.1.1、运行原理
2PC通过引入协调者(Coordinator)来协调分布式事务参与者的行为,并最终决定这些参与者是否要真正执行事务。
2PC 把事务的执行分为两个阶段:
一阶段:准备阶段
二阶段:提交阶段
如果事务在每个参与者上都执行成功,事务协调者发送通知让参与者提交事务;否则,协调者发送通知让参与者回滚事务。
*【注意】*需要注意的是,在准备阶段,参与者在DB层只执行了事务,未提交事务。只有在提交阶段接收到协调者发来的通知后,才进行提交或者回滚。
5.1.2、方案背后的隐性要求
1、需要实现一个全局协调器。引发考虑全局协调器自身的可用性问题。
2、应用需配合协调者要求,实现多个接口。
3、需要将DB层面的事务commit分离(需要DB支持)。拖长的一致性过程,对并发情况采取的应对策略(一般往往是同步阻塞)。
4、假设阶段二的commit/rollback肯定执行成功,故二阶段的实现不能复杂。且,一旦出错就人工介入。
5.1.3、存在的问题
1、同步阻塞:所有事务参与者在等待其它参与者响应的时候都处于同步阻塞状态,阻塞级别(应用级、表级、数据行级)视实现方案。
2、单点问题:协调者发生故障将会造成很大影响。特别是在阶段二发生故障,所有参与者会一直等待状态,无法完成其它操作。
3、数据不一致:在阶段二,如果协调者只发送了部分 Commit 消息,此时网络发生异常,那么只有部分参与者接收到 Commit 消息,也就是说只有部分参与者提交了事务,使得系统数据不一致。抑或协调者发送的commit消息到达参会与者的速度不一样,抑或参与者处理commit消息的调度及时性不一样,都会出现全局数据不一致的一个时间窗口。
4、太过保守: 任意一个节点失败就会导致整个事务失败,没有完善的容错机制。
TCC 本质上也是一个2PC型原理,但其属于“补偿型” “柔性事务”方案。是最常用的分布式事务的思路。
理解TCC,重要的是去理解“补偿型”方案的思路。基于“补偿型”思路在实际业务场景中灵活应变。
故,要往补偿型方案上去理解,勿死记硬背!
【敲黑板】TCC特点:
TCC的状态推进逻辑:
1、Try阶段:如果有1个Try失败。则发起全局Cancel。
2、Confirm阶段:假设必须成功。若有个别应用Confirm失败,则由事务管理器进行重试。若重试无效,则人工介入。
3、Cancel阶段:也是假设必须成功。Cancel失败的基本都转人工。
TCC实现注意点:
1、实现幂等:因为网络调用无法保证请求一定能到达,所以都会有重调机制,因此对于 Try、Confirm、Cancel 三个方法都需要幂等实现,避免重复执行产生错误。
2、空回滚问题:指由于网络问题,Try 方法没收到,超时了。此时事务管理器就会发出 Cancel 命令,那么需要支持 Cancel 在未执行 Try 的情况下能正常的 Cancel。
3、考虑单应用多实例架构:在单应用多实例的架构下,TCC的协调者需要保证一个事务的Try、Confirm、Cancel操作在同一个实例上。
4、防悬挂:(尤其是异步方式的TCC)是指 Try 方法由于网络阻塞,超时触发了事务管理器发出了 Cancel 命令, 但是执行了 Cancel 命令之后 Try 请求到了。
TCC应用的难度不在于实现三个方法,在于结合具体TCC工具选型和技术架构下的逻辑严密性,常思考几个问题:
国内开源的ByteTCC、hmily、tcc-transaction
TCC的缺点:
因地制宜,这些也是TCC思路:
例如,购买联程机票,换乘的又是不同的航空公司。比如从 A 飞到 B,再从 B 飞到 C,只有 A - B 和 B - C 都买到票了才有意义。
但有可能受限于各航空公司未必都实现TCC的接口,故为了方案的通用性,会做方案降级应对。这时候的选择就不 Try 了,直接调用航空公司的买票操作,当两个航空公司都买成功了那就直接成功了,如果某个公司买失败了,那就需要调用取消订票接口。
也就是在第一阶段直接就执行完整个业务操作了,所以要重点关注回滚操作,如果回滚失败得有提醒,需要人工介入等。
类SAGA的方案哦
TCC 异步其实也是一种折衷。
比如某些服务很难改造,但又不会影响主业务决策,也就是它不那么需要即时执行。这时候可以引入可靠消息服务,通过消息服务来替代个别服务来进行 Try、Confirm、Cancel 。
Try 的时候只是写入消息,消息还不能被消费,Confirm 就是真正发消息的操作,Cancel 就是取消消息的发送。
是不是有点耳熟?对!就是RocketMQ。
事务消息的方案哦
XA规范 是 X/Open 组织定义的分布式事务处理(DTP,Distributed Transaction Processing)标准。
XA 规范 描述了全局的事务管理器与局部的资源管理器之间的接口。 XA规范 的目的是允许的多个资源(如数据库,应用服务器,消息队列等)在同一事务中访问,这样可以使 ACID 属性跨越应用程序而保持有效。
XA中大致分为两部分:事务管理器和本地资源管理器。
XA 规范 使用两阶段提交(2PC,Two-Phase Commit)来保证所有资源同时提交或回滚任何特定的事务
XA实现分布式事务的原理如下:
看,背后还是2PC的原理。
总的来说,XA协议比较简单,而且一旦商业数据库实现了XA协议,使用分布式事务的成本也比较低。
但是,XA也有致命的缺点,那就是性能不理想,特别是在交易下单链路,并发量高,XA无法满足高并发场景。
XA目前在商业数据库支持的比较理想,在mysql数据库中支持的不太理想,mysql的XA实现,没有记录prepare阶段日志,主备切换回导致主库与备库数据不一致。
至于实战经验/代码demo,笔者也没使用过。此章节略带“键盘侠”的感觉。
带XA能力的框架:Seata。
http://seata.io/zh-cn/index.html
前提:
整体机制:
在 Seata 定义的分布式事务框架内,利用事务资源(数据库、消息服务等)对 XA 协议的支持,以 XA 协议的机制来管理分支事务的一种 事务模式。
工作机制:
XA 模式 运行在 Seata 定义的事务框架内:
XA 模式需要 XA Connection。
获取 XA Connection 两种方式:
第一种方式,给开发者增加了认知负担,需要为 XA 模式专门去学习和使用 XA 数据源,与 透明化 XA 编程模型的设计目标相违背。
第二种方式,对开发者比较友好,和 AT 模式使用一样,开发者完全不必关心 XA 层面的任何问题,保持本地编程模型即可。
虽然第二种方式对使用者更友好,但仍优先推荐第一种自实现的方式:
为什么?
关于第二种方式,其实是Seata来开发XA Connection,这种方法是在做数据库驱动程序要做的事情。不同的厂商、不同版本的数据库驱动实现机制是厂商私有的,Seata只保证在充分测试过的驱动程序上是正确的(无法针对使用者各色各样的驱动版本给出通用版本),开发者使用的驱动程序版本差异很可能造成机制的失效。这点在 Oracle 上体现非常明显。参见 Druid issue:https://github.com/alibaba/druid/issues/3707 。
XA start 需要 Xid 参数。
这个 Xid 需要和 Seata 全局事务的 XID 和 BranchId 关联起来,以便由 TC 驱动 XA 分支的提交或回滚。
目前 Seata 的 BranchId 是在分支注册过程,由 TC 统一生成的,所以 XA 模式分支注册的时机需要在 XA start 之前。
XA 模式的使用
可以参考 Seata 官网的样例:https://github.com/seata/seata-samples/tree/master/seata-xa
样例场景是 Seata 经典的,涉及库存、订单、账户 3 个微服务的商品订购业务。
复习下Seata XA模式下的不足:
然后,Seata AT模式就是Seata XA模式的演化版本,优化了Seata XA模式下的2个不足。更灵活、性能有所提升(不绝对,建议实测比较)。
前提:
整体改进原理:
1、【自动代理,插入逻辑】【低入侵】自动代理应用层的数据源,拦截并插入Seata的操作和逻辑。
2、【应用层XA Prepare】【灵活】在应用层实现 XA Prepare部分,大大提升适用面和灵活性。实际实现上,通过对数据源做代理,以此拦截应用的事务SQL,进而反向解析出undo sql log(供于rollback)。
3、【行级-全局锁】【隔离性&性能】在全局TC上实现行级全局锁。全局事务在执行期间,需要先拿到全局锁,才能处理临界区逻辑。以此达到全局的读写隔离。
代码层面参照Seata-Sample,最大特点是在事务入口打上@GlobalTransactional的代理注解,让Seata能干预SQL的执行。
基本概念:
两阶段提交:
写隔离:
示例:
两个全局事务 tx1 和 tx2,分别对 a 表的 m 字段进行更新操作,m 的初始值 1000。
tx1 先开始,开启本地事务,拿到本地锁,更新操作 m = 1000 - 100 = 900。本地事务提交前,先拿到该记录的 全局锁 ,本地提交释放本地锁。
tx2 后开始,开启本地事务,拿到本地锁,更新操作 m = 900 - 100 = 800。本地事务提交前,尝试拿该记录的 全局锁 ,tx1 全局提交前,该记录的全局锁被 tx1 持有,tx2 需要重试等待 全局锁 。
如果回滚:
如果 tx1 的二阶段全局回滚,则 tx1 需要重新获取该数据的本地锁,进行反向补偿的更新操作,实现分支的回滚。
此时,如果 tx2 仍在等待该数据的 全局锁,同时持有本地锁,则 tx1 的分支回滚会失败。分支的回滚会一直重试,直到 tx2 的 全局锁 等锁超时,放弃 全局锁 并回滚本地事务释放本地锁,tx1 的分支回滚最终成功。
因为整个过程 全局锁 在 tx1 结束前一直是被 tx1 持有的,所以不会发生 脏写 的问题。
读隔离:
在数据库本地事务隔离级别 读已提交(Read Committed) 或以上的基础上,Seata(AT 模式)的默认全局隔离级别是 读未提交(Read Uncommitted) 。
如果应用在特定场景下,必需要求全局的 读已提交 ,目前 Seata 的方式是通过 SELECT FOR UPDATE 语句的代理。
SELECT FOR UPDATE 语句的执行会申请 全局锁(并发阻塞的代价点) ,如果 全局锁 被其他事务持有,则释放本地锁(回滚 SELECT FOR UPDATE 语句的本地执行)并重试。这个过程中,查询是被 block 住的,直到 全局锁 拿到,即读取的相关数据是 已提交 的,才返回。
出于总体性能上的考虑,Seata 目前的方案并没有对所有 SELECT 语句都进行代理,仅针对 FOR UPDATE 的 SELECT 语句。
回滚日志表:
AT模式的特点是通过代理数据源,从而能拦截SQL,并生成undo sql。这个特点在实际实战中要引起注意,要实测真实业务sql的undo sql生成正确性(担心Seata AT模式对于复杂更新sql 的解析能力)。
注,UNDO_LOG Table 一定要创建哦。
MySQL 为例:
Field | Type |
---|---|
branch_id | bigint PK |
xid | varchar(100) |
context | varchar(128) |
rollback_info | longblob |
log_status | tinyint |
log_created | datetime |
log_modified | datetime |
-- 注意此处0.7.0+ 增加字段 context
CREATE TABLE `undo_log` (
`id` bigint(20) NOT NULL AUTO_INCREMENT,
`branch_id` bigint(20) NOT NULL,
`xid` varchar(100) NOT NULL,
`context` varchar(128) NOT NULL,
`rollback_info` longblob NOT NULL,
`log_status` int(11) NOT NULL,
`log_created` datetime NOT NULL,
`log_modified` datetime NOT NULL,
PRIMARY KEY (`id`),
UNIQUE KEY `ux_undo_log` (`xid`,`branch_id`)
) ENGINE=InnoDB AUTO_INCREMENT=1 DEFAULT CHARSET=utf8;
AT模式的缺点:
1、性能损耗:
2、特别注意应对:补偿型方式的通病:
Seata已经支持的AT、TCC、SAGA都是补偿型的。
补偿型事务处理机制构建在事务资源之上,事务资源本身对分布式事务是无感知的。无法做到真正的全局一致性。
比如,一条库存记录处在补偿型事务处理过程中由100扣减为50。此时,仓库管理员链接数据库查询统计库存,查到了50。然后因事务回滚,库存由50补偿变回100。显然,仓库管理员查询统计到的50就是脏数据。
AT的锁能解决部分这类问题,故,实际应用时一定要仔细分析并发读写场景特点,做最小代价的应对方案。
关于原理,《5.2、TCC》章节后,此处不再详述。
但,在实际应用中务必记得考虑TCC模式下各种情况的应对。见《5.2、TCC》章节。
根据两阶段行为模式的不同,将分支事务划分为 Automatic (Branch) Transaction Mode 和 TCC (Branch) Transaction Mode.
Automatic模式 基于支持本地 ACID 事务的关系型数据库(即Seata AT模式):
TCC 模式,不依赖于底层数据资源的事务支持:
所谓 TCC 模式,是指支持把 自定义 的分支事务纳入到全局事务的管理中。
概述:
Saga模式是Seata提供的长事务解决方案。
事务长不长不知道,但Saga有它存在的意义和只有它能解决的场景。
在Saga模式中,业务流程中每个参与者都提交本地事务,当出现某一个参与者失败则补偿前面已经成功的参与者,一阶段正向服务和二阶段补偿服务都由业务开发实现。
适用场景:
优势:
缺点:
需要注意的是:
更多了解详见:
缺乏隔离性的应对:
由于 Saga 事务不保证隔离性, 在极端情况下可能由于脏写无法完成回滚操作。
比如举一个极端的例子, 分布式事务内先给用户A充值, 然后给用户B扣减余额, 如果在给A用户充值成功, 在事务提交以前, A用户把余额消费掉了, 如果事务发生回滚, 这时则没有办法进行补偿了。这就是缺乏隔离性造成的典型的问题, 实践中一般的应对方法是:
业务流程设计时遵循“宁可长款, 不可短款”的原则, 长款意思是客户少了钱机构多了钱, 以机构信誉可以给客户退款, 反之则是短款, 少的钱可能追不回来了。所以在业务流程设计上一定是先扣款。
有些业务场景可以允许让业务最终成功, 在回滚不了的情况下可以继续重试完成后面的流程, 所以状态机引擎除了提供“回滚”能力还需要提供“向前”恢复上下文继续执行的能力, 让业务最终执行成功, 达到最终一致性的目的。
属TCC的方案变种。
适用于场景有限: 限于可异步处理的分布式事务场景(即分支事务不影响主业务的决策)。
市面上支持事务消息的仅:RocketMQ。见《4.2、怎么选》。
RocketMQ 中间件思路大致为:
也就是说在业务方法内要向消息队列提交两次请求,一次发送消息和一次确认消息。如果确认消息发送失败了RocketMQ会定期扫描消息集群中的事务消息,这时候发现了Prepared消息,它会向消息发送者确认,所以生产方需要实现一个check接口,RocketMQ会根据发送端设置的策略来决定是回滚还是继续发送确认消息。这样就保证了消息发送与本地事务同时成功或同时失败。
优点: 实现了最终一致性,不需要依赖本地数据库事务。一部分可用性保障转移给/借力MQ的能力。
缺点: 实现难度大,主流MQ不支持。
本地消息表与业务数据表处于同一个数据库中,这样就能利用本地事务来保证在对这两个表的操作满足事务特性,并且使用了消息队列来保证最终一致性。
优点: 一种非常经典/简单的实现,避免了分布式事务,实现了最终一致性。
缺点: 消息表会耦合到业务系统中,如果没有封装好的解决方案,(重点)会有很多杂活需要处理。同样要考虑TCC方案下的多种技术因素,见《5.2、TCC》。在运行环境中,怀疑一切才能一切放心。
看完上文后,大家应该有几个感觉:
关于分布式事务实现方案的选型/设计,笔者的心得和倾向:
1、【价值取向】分布式事务方案在“性能”和“数据一致性”两方面是相悖的,在价值取向上,我更倾向于保障“性能”。
原因:从影响面和可恢复性角度,分布式事务问题是小概率事件,留有补救余地就行,性能的损失是实打实的反应在线上每一个请求上。
2、【编码风格】落实“宁可长款, 不可短款”的原则。优先做扣除行为。
3、【做好监控】既然允许线上分布式事务问题的发生,那么就要有兜底应对的手段。笔者会要求团队在分布式事务的流程中认真打日志。并将此类日志作为线上业务监控范围,并与公司内部邮件系统、钉钉打通。做到一个有问题,秒级感知。
4、【双重兜底】开发统计对账服务,对线上分布式事务数据做周期性巡检。对线上数据不一致情况做通知:工单、钉钉、邮件。