Seata为用户提供了AT、TCC、SAGA和XA事务模式。其中AT模式是seata主推的事务模式,使用AT有一个前提,那就是微服务使用的数据库必须是支持事务的关系型数据库。
Seata的AT模式建立在关系型数据库的本地事务特性的基础之上,通过数据源代理类拦截并解析数据库执行的SQL,记录自定义的回滚日志,如需回滚,则重放这些自定义的回滚日志即可。AT模式虽然是根据XA事务模型(2PC)演进而来的,但是AT打破了XA协议的阻塞性制约,在一致性、和性能上取得了平衡。
使用Seata in XA mode的基本流程如下图所示:
registry branch/
+----------+ report status +----------+
|service1 |---------------------------->| |
| |<----------------------------| |
+----------+ branch commit/ | |
|DB1 | rollback | |
+----------+ | |
^ | |
| | |
RPC calls | |
| | |
+----------+ | |
|service2 |------begin global trx------>| Seata |
| |<---global commit/rollback---| Server |
| | | in AT |
| | registry branch/ | mode |
| |------report status--------->| |
| |<----------------------------| |
+----------+ branch commit/ | |
|DB2 | rollback | |
+----------+ | |
| | |
RPC calls | |
| | |
v registry branch/ | |
+----------+ report status | |
|service3 |---------------------------->| |
| |<----------------------------| |
+----------+ branch commit/ | |
|DB3 | rollback | |
+----------+ +----------+
这张图表述了这样一种业务场景:service2通过rpc调用service1和service3的写数据接口,调用成功后,service2又调用了自己身的写数据接口。为了保证三处写操作的事务特性,需要Seata控制整个过程。
在Seata in AT mode下,上体描述的全局事务执行流程为:
1)service2向Seata注册全局事务,并产生一个全局事务标识XID
2)service1.DB1、service2.DB2、service3.DB3向Seata注册分支事务,并将其纳入该XID对应的全局事务范围
3)service1.DB1、service2.DB2、service3.DB3向Seata汇报本地事务的准备状态
4)Seata汇总所有的DB的本地事务的准备状态,决定全局事务是该提交还是回滚
5)Seata通知service1.DB1、service2.DB2、service3.DB3提交/回滚本地事务
AT模式是基于XA事务模型演进而来的,可以看出它的整体机制也是一个改进版本的两阶段提交协议。AT模式的两个基本阶段是:
1)第一阶段:业务数据和回滚日志记录在同一个本地事务中提交,释放本地锁和连接资源;
2)第二阶段:提交异步化,这个过程很快就能完成。若需要回滚,则通过第一阶段的回滚日志进行反向补偿。
下面以电商平台的购物系统为例来讲解Seata in AT mode的工作原理。为了简便,这里最大程度地简化业务场景,在这个场景中,购物服务调用订单服务创建订单,调用库存服务扣减库存,显然这两个调用过程要放在一个事务里面。即:
start global_trx
call 订单服务的创建订单接口
call 库存服务的扣减库存接口
commit global_trx
在库存服务的数据库中,存在如下的库存表:
id | production_code | name | count | price |
---|---|---|---|---|
10001 | 20001 | xx键盘 | 98 | 200.0 |
10002 | 20002 | yy鼠标 | 199 | 100.0 |
在订单服务的数据库中,存在如下的订单表:
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模式的第一阶段的流程图如下:
# 库存服务中第一阶段的流程
数据源代理类解析通过JDBC执行的业务SQL,提取元素及条件
|
v
0. 开启本地事务
+---------+ 1. 查询修改前记录 10002|20002|yy鼠标|199|100.0
|t_repo |<----2. 对库存表执行修改库存记录中数量字段的SQL(199-1)
+---------+ 3. 查询修改后记录的快照 10002|20002|yy鼠标|198|100.0
|undo_log |<----4. 将修改前记录、修改后记录的快照组成一个回滚日志插入undo_log
+---------+ | 表(该表由seata维护),第二阶段seata如需全局回滚,在库存服
| 务数据库上回放该undo_log日志记录即可
+---------+ v
|seata |<----5. 事务提交之前向seata注册分支事务,申请t_repo表中主键为10002
| | | 的记录的全局锁,避免其他事务(无论是本地还是分布式事务)干扰
| | v
| | 6. 提交本地事务,内容包括对t_repo表的修改和对undo_log表的插入
| |<----7. 事务提交之后向seata报告本地事务的提交结果
+---------+ |
v
# 订单服务中第一阶段的流程
数据源代理类解析通过JDBC执行的业务SQL,提取元素及条件
|
v
+---------+ 0. 开启本地事务
|t_order |<----1. 对订单表表执行插入订单记录的SQL
+---------+ 2. 查询插入后记录的快照 30003|2020102500002|40002|20002|1|100.0
|undo_log |<----3. 将插入后的记录的快照组成一个回滚日志插入undo_log表,第二阶段
+---------+ | seata如需全局回滚,在库存服务数据库上回放该undo_log日志记录
| 即可
+---------+ v
|seata |<----4. 事务提交之前向seata注册分支事务,申请t_order表中主键为30003
| | | 的记录的全局锁,避免其他事务(无论是本地还是分布式事务)干扰
| | v
| | 5. 提交本地事务,内容包括对t_order表的插入和对undo_log表的插入
| |<----6. 事务提交之后向seata报告本地事务的提交结果
+---------+ |
v
从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不同的地方)。这个过程如下图所示:
# 库存服务中第二阶段(全局提交)的流程
+--------+ branch commit
|seata |-----------------> 1. 接收Seata的分支提交消息,放入
| |<---commit succ--- 缓冲队列,返回提交成功
| | |
| | v
| | +-------------+
| | |MQ |
| | +-------------+
| | |
| | v
| | 2. 删除库存服务数据库中本地分支事
| | 务对应的undo_log日志记录
+--------+
# 订单服务中第二阶段(全局提交)的流程
+--------+ branch commit
|seata |-----------------> 1. 接收Seata的分支提交消息,放入
| |<---commit succ--- 缓冲队列,返回提交成功
| | |
| | v
| | +-------------+
| | |MQ |
| | +-------------+
| | |
| | v
| | 2. 删除订单服务数据库中本地分支事
| | 务对应的undo_log日志记录
+--------+
分支事务之所以能够直接返回成功给Seata,是因为真正关键的提交操作在第一阶段已经完成了,清除undo_log日志只是收尾工作,即便清除失败了,也对整个分布式事务不产生实质影响。
2)若任一分支事务本地提交失败,则Seata决定全局回滚,将分支事务回滚消息发送给各个分支事务,由于在第一阶段各个服务的数据库上记录了undo_log记录,分支事务回滚操作只需根据undo_log记录进行补偿即可。全局事务的回滚流程如下图所示:
# 库存服务中第二阶段(全局回滚)的流程
+--------+ branch rollback
|seata |-----------------> 0. 接收Seata的分支回滚消息
| | |
| | v
| | 1. 开启本地事务
| | 2. 查询当前分支事务的undo_log记录
| | 3. 对比修改前后的记录镜像,生成undo sql
| | 4. 执行undo sql
| | 5. 删除相应的undo_log
| | 6. 提交本地事务,内容包括2、4、5
| |<--report status-- 7. 事务提交之后
+--------+
# 订单服务中第二阶段(全局回滚)的流程
+--------+ branch rollback
|seata |-----------------> 0. 接收Seata的分支回滚消息
| | |
| | v
| | 1. 开启本地事务
| | 2. 查询当前分支事务的undo_log记录
| | 3. 对比修改前后的记录镜像,生成undo sql
| | 4. 执行undo sql
| | 5. 删除相应的undo_log
| | 6. 提交本地事务,内容包括2、4、5
| |<--report status-- 7. 事务提交之后
+--------+
这里对图中的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实现写隔离过程的时序图如下:
+-----------+ +-----------+
|gtrx_1 | |gtrx_2 |
+-----------+ +-----------+
| 1. begin local trx, get local lock, succ |
| 2. update t_repo set count=count-1 |
| where id=10002 |
| 3. get global lock, success |
| 4. local trx commit, release local lock | (每个本地事务要修改该记录,需要拿到这个
| | 记录的本地行锁,行锁是互斥的)
| | 1. begin local trx, get local lock
| | 2. update t_repo set count=count-1
| | where id=10002
| | 3. get global lock, fail, retry
| 5. branch commit | get global lock, fail, retry
| 6. release global lock | get global lock, fail, retry
| | get global lock, success
| | 4. local trx commit, release local lock
| | 5. branch commit
| | 6. release global lock
| |
图中,1、2、3、4属于第一阶段,5、6属于第二阶段
在上图中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手中,并不会发生脏写的问题。整个过程的流程图如下所示:
+-----------+ +-----------+
|gtrx_1 | |gtrx_2 |
+-----------+ +-----------+
| 1. begin local trx, get local lock, succ |
| 2. update t_repo set count=count-1 |
| where id=10002 |
| 3. get global lock, success |
| 4. local trx commit, release local lock | (每个本地事务要修改该记录,需要拿到这个
| | 记录的本地行锁,行锁是互斥的)
| | 1. begin local trx, get local lock
| | 2. update t_repo set count=count-1
| | where id=10002
| | 3. get global lock, fail, retry
| 5. branch rollback | get global lock, fail, retry
| 6. start local trx, get local lock, fail, retry | get global lock, fail, retry
| get local lock, fail, retry | get global lock, fail, retry
| ...... | ......
| get local lock, fail, retry | get global lock, fail, retry
| get local lock, fail, retry | get global lock, fail, timeout, give up
| get local lock, fail, retry | 4. local rollback
| get local lock, fail, retry | 5. release local lock
| get local lock, success |
| 7. update t_repo set count=count+1 |
| where id=10002 |
| 8. local commit |
| 9. branch rollback done, release global lock |
| |
图中,1、2、3、4属于第一阶段,5、6属于第二阶段
2)读隔离:数据库设置了不同的本地事务隔离级别,包括:读未提交、读已提交、可重复读、串行化。在数据库本地事务的隔离级别为读已提交、可重复读、串行化时(读未提交一般不使用),Seata AT全局事务模型产生的隔离级别是读未提交,从上述的第一阶段和第二阶段的流程图中也可以看出这一点。也就是说一个全局事务会看到另一个全局事务未全局提交的数据,产生脏读,这在最终一致性的分布式事务模型中是可以接受的。
如果要求AT模型一定要实现读已提交的事务隔离级别,可以利用Seata的SelectForUpdateExecutor执行器对SELECT FOR UPDATE语句进行代理。SELECT FOR UPDATE语句在执行时会申请全局锁,如果全局锁已经被其他全局事务占有,则回滚SELECT FOR UPDATE语句的执行,释放本地锁,并且重试SELECT FOR UPDATE语句。在这个过程中,查询请求会被阻塞,直到拿到全局锁(也就是要读取的记录被其他全局事务提交),读到已被全局事务提交的数据才返回。这个过程如下图所示:
+-----------+ +-----------+
|gtrx_1 | |gtrx_2 |
+-----------+ +-----------+
| 1. begin local trx, get local lock, succ |
| 2. update t_repo set count=count-1 |
| where id=10002 |
| 3. get global lock, success |
| 4. local trx commit, release local lock |
| |
| |
| | 1. select count from t_repo where id=10002
| | for update(require local lock)
| | 2. get global lock, fail, retry
| | 3. release local lock, retry select for
| | update(require local lock), blocking
| | get global lock, fail, retry
| | get global lock, fail, retry
|5. global commit, release global lock | get global lock, fail, retry
| | get global lock, success
| | 4. select for update done
| | 5. release local lock
| | 6. release global lock
| |