3.2. Seata in AT mode 工作流程详述
在上面的电商业务场景中,购物服务调用库存服务扣减库存,调用订单服务创建订单,显然这两个调用过程要放在一个事务里面。即:
start global_trx call 库存服务的扣减库存接口 call 订单服务的创建订单接口 commit global_trx
在库存服务的数据库中,存在如下的库存表 t_repo:
id | production_code | name | count | price |
10001 | 20001 | xx 键盘 | 98 | 200.0 |
10002 | 20002 | yy 鼠标 | 199 | 100.0 |
在订单服务的数据库中,存在如下的订单表 t_order:
id | order_code | user_id | production_code | count | price |
30001 | 2020102500001 | 40001 | 20002 | 1 | 100.0 |
30002 | 2020102500001 | 40001 | 20001 | 2 | 400.0 |
现在,id 为 40002 的用户要购买一只商品代码为 20002 的鼠标,整个分布式事务的内容为:
1)在库存服务的库存表中将记录
id | production_code | name | count | price |
10002 | 20002 | yy 鼠标 | 199 | 100.0 |
修改为
id | production_code | name | count | price |
10002 | 20002 | yy 鼠标 | 198 | 100.0 |
2)在订单服务的订单表中添加一条记录
id | order_code | user_id | production_code | count | price |
30003 | 2020102500002 | 40002 | 20002 | 1 | 100.0 |
以上操作,在 AT 模式的第一阶段的流程图如下:
从 AT 模式第一阶段的流程来看,分支的本地事务在第一阶段提交完成之后,就会释放掉本地事务锁定的本地记录。这是 AT 模式和 XA 最大的不同点,在 XA 事务的两阶段提交中,被锁定的记录直到第二阶段结束才会被释放。所以 AT 模式减少了锁记录的时间,从而提高了分布式事务的处理效率。AT 模式之所以能够实现第一阶段完成就释放被锁定的记录,是因为 Seata 在每个服务的数据库中维护了一张 undo_log 表,其中记录了对 t_order / t_repo 进行操作前后记录的镜像数据,即便第二阶段发生异常,只需回放每个服务的 undo_log 中的相应记录即可实现全局回滚。
undo_log 的表结构:
id | branch_id | xid | context | rollback_info | log_status | log_created | log_modified |
…… | 分支事务 ID | 全局事务 ID | …… | 分支事务操作的记录在事务前后的记录镜像,即 beforeImage 和 afterImage | …… | …… | …… |
第一阶段结束之后,Seata 会接收到所有分支事务的提交状态,然后决定是提交全局事务还是回滚全局事务。
1)若所有分支事务本地提交均成功,则 Seata 决定全局提交。Seata 将分支提交的消息发送给各个分支事务,各个分支事务收到分支提交消息后,会将消息放入一个缓冲队列,然后直接向 Seata 返回提交成功。之后,每个本地事务会慢慢处理分支提交消息,处理的方式为:删除相应分支事务的 undo_log 记录。之所以只需删除分支事务的 undo_log 记录,而不需要再做其他提交操作,是因为提交操作已经在第一阶段完成了(这也是 AT 和 XA 不同的地方)。这个过程如下图所示:
分支事务之所以能够直接返回成功给 Seata,是因为真正关键的提交操作在第一阶段已经完成了,清除 undo_log 日志只是收尾工作,即便清除失败了,也对整个分布式事务不产生实质影响。
2)若任一分支事务本地提交失败,则 Seata 决定全局回滚,将分支事务回滚消息发送给各个分支事务,由于在第一阶段各个服务的数据库上记录了 undo_log 记录,分支事务回滚操作只需根据 undo_log 记录进行补偿即可。全局事务的回滚流程如下图所示:
这里对图中的 2、3 步做进一步的说明:
1)由于上文给出了 undo_log 的表结构,所以可以通过 xid 和 branch_id 来找到当前分支事务的所有 undo_log 记录;
2)拿到当前分支事务的 undo_log 记录之后,首先要做数据校验,如果 afterImage 中的记录与当前的表记录不一致,说明从第一阶段完成到此刻期间,有别的事务修改了这些记录,这会导致分支事务无法回滚,向 Seata 反馈回滚失败;如果 afterImage 中的记录与当前的表记录一致,说明从第一阶段完成到此刻期间,没有别的事务修改这些记录,分支事务可回滚,进而根据 beforeImage 和 afterImage 计算出补偿 SQL,执行补偿 SQL 进行回滚,然后删除相应 undo_log,向 Seata 反馈回滚成功。
事务具有 ACID 特性,全局事务解决方案也在尽量实现这四个特性。以上关于 Seata in AT mode 的描述很显然体现出了 AT 的原子性、一致性和持久性。下面着重描述一下 AT 如何保证多个全局事务的隔离性的。
在 AT 中,当多个全局事务操作同一张表时,通过全局锁来保证事务的隔离性。下面描述一下全局锁在读隔离和写隔离两个场景中的作用原理:
1)写隔离(若有全局事务在改/写/删记录,另一个全局事务对同一记录进行的改/写/删要被隔离起来,即写写互斥):写隔离是为了在多个全局事务对同一张表的同一个字段进行更新操作时,避免一个全局事务在没有被提交成功之前所涉及的数据被其他全局事务修改。写隔离的基本原理是:在第一阶段本地事务(开启本地事务的时候,本地事务会对涉及到的记录加本地锁)提交之前,确保拿到全局锁。如果拿不到全局锁,就不能提交本地事务,并且不断尝试获取全局锁,直至超出重试次数,放弃获取全局锁,回滚本地事务,释放本地事务对记录加的本地锁。
假设有两个全局事务 gtrx_1 和 gtrx_2 在并发操作库存服务,意图扣减如下记录的库存数量:
id | production_code | name | count | price |
10002 | 20002 | yy 鼠标 | 198 | 100.0 |
AT 实现写隔离过程的时序图如下:
图中,1、2、3、4 属于第一阶段,5 属于第二阶段。
在上图中 gtrx_1 和 gtrx_2 均成功提交,如果 gtrx_1 在第二阶段执行回滚操作,那么 gtrx_1 需要重新发起本地事务获取本地锁,然后根据 undo_log 对这个 id=10002 的记录进行补偿式回滚。此时 gtrx_2 仍在等待全局锁,且持有这个 id=10002 的记录的本地锁,因此 gtrx_1 会回滚失败(gtrx_1 回滚需要同时持有全局锁和对 id=10002 的记录加的本地锁),回滚失败的 gtrx_1 会一直重试回滚。直到旁边的 gtrx_2 获取全局锁的尝试次数超过阈值,gtrx_2 会放弃获取全局锁,发起本地回滚,本地回滚结束后,自然会释放掉对这个 id=10002 的记录加的本地锁。此时,gtrx_1 终于可以成功对这个 id=10002 的记录加上了本地锁,同时拿到了本地锁和全局锁的 gtrx_1 就可以成功回滚了。整个过程,全局锁始终在 gtrx_1 手中,并不会发生脏写的问题。整个过程的流程图如下所示:
2)读隔离(若有全局事务在改/写/删记录,另一个全局事务对同一记录的读取要被隔离起来,即读写互斥):在数据库本地事务的隔离级别为读已提交、可重复读、串行化时(读未提交不起什么隔离作用,一般不使用),Seata AT 全局事务模型产生的隔离级别是读未提交,也就是说一个全局事务会看到另一个全局事务未全局提交的数据,产生脏读,从前文的第一阶段和第二阶段的流程图中也可以看出这一点。这在最终一致性的分布式事务模型中是可以接受的。
如果要求 AT 模型一定要实现读已提交的事务隔离级别,可以利用 Seata 的 SelectForUpdateExecutor 执行器对 SELECT FOR UPDATE 语句进行代理。SELECT FOR UPDATE 语句在执行时会申请全局锁,如果全局锁已经被其他全局事务占有,则回滚 SELECT FOR UPDATE 语句的执行,释放本地锁,并且重试 SELECT FOR UPDATE 语句。在这个过程中,查询请求会被阻塞,直到拿到全局锁(也就是要读取的记录被其他全局事务提交),读到已被全局事务提交的数据才返回。这个过程如下图所示:
4. 结束语
XA 协议是 X/Open 提出的分布式事务处理标准。文中提到的 2PC、3PC、TCC、本地事务表、Seata in AT mode,无论哪一种,本质都是事务协调者协调各个事务参与者的本地事务的进度,使使所有本地事务共同提交或回滚,最终达成一种全局的 ACID 特性。在协调的过程中,协调者需要收集各个本地事务的当前状态,并根据这些状态发出下一阶段的操作指令。这个思想就是 XA 协议的要义,我们可以说这些事务模型遵守或大致遵守了 XA 协议。
基于消息中间件的最终一致性事务方案是互联网公司在高并发场景中探索出的一种创新型应用模式,利用 MQ 实现微服务之间的异步调用、解耦合和流量削峰,保证分布式数据记录的最终一致性。它显然不遵守 XA 协议。
对于某项技术,可能存在业界标准或协议,但实践者针对具体应用场景的需求或者出于简便的考虑,给出与标准不完全相符的实现,甚至完全不相符的实现,这在工程领域是一种常见的现象。TCC 方案如此、基于消息中间件的最终一致性事务方案如此、Seata in AT mode 模式也如此。而新的标准往往就在这些创新中产生。
你难道真的没有发现 2.6 节(基于消息中间件的最终一致性事务方案)给出的正确方案中存在的业务漏洞吗?请各位重新看下这张图,仔细品一品两个微服务的调用方向,把你的想法留在评论区吧 :-)