分布式事务中的 TCC 模式, 貌似是阿里提出来的, 所以阿里自研的分布式事务框架总是少不了 TCC 的影子.
服务拆分
很多系统早期都是单系统服务架构, 所有业务聚合在少数几个系统中对外提供服务. 随着业务发展, 服务之间耦合比较严重, 一般会对服务进行重构, 重构的主要思想也就是围绕 "拆分" 展开.
比如按照功能进行解耦的垂直拆分, 拆分之后原有系统中的业务调用, 就变成了分布式的调用了, 但是由于网络的不可靠性, 数据一致性问题, 可扩展性问题, 高可用容灾问题成为分布式事务的主要挑战. 而对于在服务之间数据交付的时候容易造成的数据不一致问题, 一般需要引入分布式事务对数据一致性做控制.
单系统到微服务拆分的过程, 是一个资源横向扩展的过程, 当单台机器资源无法承担更大的请求时, 可以多台机器形成集群.
资源拆分主要有两个执行方向:
按业务拆分, 也就是将数据按照业务分组, 将不同服务的数据放到不同的存储上, 类似于 soa 架构下的服务化, 已业务单元为核心.
按数据拆分, 也就是常说的数据分片, 按照横向扩展纬度, 将单个 DB 拆分成多个 DB, 数据存储具备统一的 Sharding 功能, 达到资源横向扩展, 承担更高的吞吐.
Seata 模式
Seata 关注的是微服务架构下的数据一致性问题, 是整套的分布式事务解决方案. Seata 框架包含两种模式:
AT 模式, 关注的是数据分片角度, 关注 DB 访问的数据一致性, 多服务下多 DB 数据访问的一致性
TCC 模式, TCC 模式主要是围绕业务拆分展开, 当业务在横向扩展资源时, 解决了服务之间调用的一致性, 保证资源访问的事务性
AT 模式
AT 模式下会把每个 DB 当作一个 Resource, 数据库就是 DataSource Resource. 业务通过标准的 JDBC 接口访问数据库资源, Seata 框架会对所有请求进行拦截, 做事务操作.
在每个事务提交时, Seata Resource Manager(RM 资源管理器) 都会向 Transaction Coorrdinator(TC 事务协调器) 注册一个分支事务.
当请求链路调用完成后, 发起方通知 TC 事务提交或者进行事务回滚, 进入两阶段提交调用流程.
二阶段操作时, TC 根据之前注册的分支事务回调对应参与者去执行对应资源的第二阶段.
每个资源都有全局唯一的资源 ID, 在初始化时用这个 ID 向 TC 注册, 之后的事务协调过程中, TC 就可以根据事务 ID 找到事务和资源的对应关系. 事务协调过程中, 每个事务的注册都会携带这个资源 ID, 这样 TC 可以通过资源 ID 在第二阶段调用时找到正确的资源了.
简单来说 AT 模式, 就是把数据库当作一个 Resource, 本地事务提交时会去注册一个分支事务.
TCC 模式
在 Seata 框架中, 每组 TCC 接口当作一个 Resource, 称为 TCC Resource. 当然一组 TCC 接口可以是 RPC, 也可以是服务内 JVM 调用.
业务启动时, Seata 框架自动扫描识别到对应的 TCC 接口及其调用方和发布方.
如果是事务的发布方, 会在业务启动时向 TC 注册 TC Resource, 类似于 DataSource Resource, 每个资源有唯一的全局资源 ID.
如果是事务的调用方, Seata 框架给调用方加上切面, 类似于 AT 模式, 运行时拦截所有 TCC 接口调用.
每调用一次 Try 接口, 切面会先向 TC 注册一个分支事务, 然后才会执行原有的 RPC 调用.
当请求链路调用完成后, TC 通过分支事务的资源 ID 回调正确的参与者去执行对应的 TCC 资源的 Confirm 或 Cancel 方法.
了解了框架模型后, 可以知道框架本身会扫描 TCC 接口, 注册资源, 拦截接口调用, 注册分支事务, 之后回调第二阶段接口.
核心是 TCC 接口的实现逻辑.
TCC 接口实现
在业务接入事务框架的 TCC 模式之后, 大部分工作都是在考虑如何实现 TCC 服务上.
设计 TCC 接口需要注意业务逻辑的拆解和资源调用的隔离.
业务逻辑分解
需要将操作分成两阶段完成的方式, TCC=Try-Confirm-Cancel 相对于 XA 等传统模式, 特征在于不依赖 RM 对分布式事务的支持, 而是通过业务逻辑分解来实现分布式事务.
TCC 模式对于业务系统存在假设, 其对外提供的服务需要接受一些不确定性, 外部对于业务逻辑的调用首先是个临时操作, 外部调用对于后续的业务处理保留取消权. 如果业务调用认为全局事务应该回滚, 就需要取消之前的临时操作. 如果业务调用认为全局事务可以提交, 就会放弃之前临时操作的取消权. 初步的临时操作最后都会被确认或取消.
TCC 对假设抽象成以下逻辑:
初步操作 Try: 完成所有业务检查, 预留必要的业务资源.
确认操作 Confirm: 真正执行业务逻辑, 不做任何检查, 只使用 Try 阶段预留的业务资源. 所以只要 try 成功, confirm 必须成功. 同时 confirm 需满足幂等性, 因为框架面对不确定性普遍会进行重试, 以保证事务提交并只成功一次.
取消操作 Cancel: 释放 Try 阶段预留的资源, 同样, cancel 操作需要满足幂等性.
资源调用隔离
业务系统需要根据自身业务特点和业务模型控制并发, 类似于 ACID 的隔离性.
以金融核心链路的简化模型为例:
每个账户或商户有一个账号及其可用余额. 交易逻辑涉及到交易, 充值, 转账, 退款等这些都是对账户进行加钱和扣钱.
于是可以把账务系统拆分成两套 TCC 接口, 两个 TCC Resource, 一个加钱 TCC 接口, 一个扣钱 TCC 接口.
扣钱 TCC
A 转账 30 元给 B,A 的余额需要从 100 元减去 30 元, 余额就是所谓的业务资源.
按照 TCC 原则, 第一阶段需要检查并预留业务资源:
检查: 在 TCC 资源的 Try 接口中检查 A 是否有足够的余额
预留: 然后预留余额紫玉啊, 并扣除 30 元
由于业务资源已经在第一阶段的 try 接口里面扣除了, 第二阶段的 confirm 接口可以什么都不做, 是个空实现.
cancel 接口需要把 try 接口里面扣除的 30 元还给账户, 进行资源释放.
加钱 TCC
第一阶段的 try 接口不能直接给账户加钱, 因为如果加钱之后, 账户的余额就会被使用了. 因此真正的加钱操作需要放到 confirm 接口中.
第一阶段的 try 接口不需要预留任何资源, 可以设计为空实现.
Cancel 接口没有资源需要释放, 所以也可以是个空实现.
真正提交时, 执行 confirm 接口增加可用余额.
事务并发控制
Seata 框架本身提供两阶段原子提交, 保证分布式事务原子性. 事务的隔离则是交给了业务逻辑来实现. 隔离的本质就是控制并发, 防止并发事务操作相同资源引起结果错乱.
以经典的转账为例, 当用户发起交易时, 首先检查用户资金, 资金充足, 扣除交易金额, 增加卖家资金, 完成交易.
如果没有事务隔离, 用户发起两笔交易, 两笔交易都认为资金充足, 实际上只够一笔交易, 结果两笔交易都支付成功, 导致资损.
所以并发控制是业务逻辑正确执行的保证, 如果采用基于数据库的两阶段锁控制并发访问, 需要在事务中一直持有数据库资源锁到整个事务执行结束, 如果在分布式架构下, 锁需要持有到事务第二阶段结束, 由于锁的持有时间过长, 会导致并发能力的下降.
因此 TCC 模式的隔离思想体现在通过业务改造实现.
第一阶段结束之后, 从底层数据库资源层面加锁过度到上层业务层面的加锁, 从而降低底层数据库锁资源, 放宽分布式事务锁协议, 将锁粒度降到最低, 更大限度提高并发性能.
如果 A 账户有 100 元, 事务 T1 需要扣除 30 元, 事务 T2 需要扣除 20 元, 出现了并发.
TCC 对于这种操作, 在第一阶段 Try 操作中, 需要利用数据库资源层面加锁, 检查账户可用余额, 如果余额充足, 则预留业务资源, 扣除本次交易金额, 一阶段结束后, 虽然数据库层面资源锁释放了, 但是这笔资金被业务隔离, 不允许本次事务之外的其他并发事务动用.
事务 T1 结束之后释放数据库层面资源锁, 事务 T2 可以发起自己的第一阶段操作, 进行加锁, 检查余额, 扣除金额等操作.
事务 T1 和事务 T2 分别扣除自己资金, 相互直接不受干扰, 这样在第二阶段时, 无论 T1 是提交还是回滚都不会对 T2 产生影响, 这样 T1 和 T2 就可以在同一个账户上并发执行了.
所以第一阶段结束后, 实际上采用业务加锁方式, 隔离账户资金. 第一阶段结束后, 释放底层资源锁, 用户和卖家的其他交易都可以立刻并发执行, 而不用等到整个分布式事务结束.
转账模型优化
在系统了解了 TCC 模型的思想后, 可以对我们之前的转账模型进行优化了.
真实项目中, 为了更好的用户体验, 第一阶段一般不会直接把账户的余额自动扣除, 而是冻结, 这样给用户展示的时候, 可以清晰的知道, 可用余额有哪些, 冻结中金额有哪些.
业务模型变成如下:
需要在模型中增加冻结金额字段, 用来表示账户中多少金额处于冻结状态.
优化之后的 TCC 模型里面的扣钱 TCC 逻辑如下:
try 接口不再直接扣除账户可用余额, 而是真正预留资源, 冻结部分空用余额, 也就相应减少了可用金额.
confirm 接口不再是空操作, 而是使用 try 接口预留的业务资源, 将冻结金额扣除.
cancel 接口中, 释放预留资源, 把 try 里面冻结的金额扣除, 增加可用金额.
加钱 TCC 逻辑不涉及冻结金额的使用, 无需修改.
优化后的模型可以规整的看到预留资源, 使用资源, 释放资源的过程.
并发控制逻辑如下:
事务 T1 在第一阶段 try 操作中, 先锁定账户, 检查账户可用余额, 如果余额充足, 预留业务资源, 减少可用金额, 增加冻结金额.
并发的事务 T2, 类似的需要加锁, 检查余额, 减少可用余额, 增加冻结余额.
在第二阶段各自事务使用第一阶段 try 锁定的冻结金额资源即可.
所以第一层面的是通过数据库层面的锁, 预留业务资源, 冻结金额. 通过业务隔离方式将这部分资源加锁, 不允许本地事务之外的其他并发事务调用, 保证事务在第二阶段正确顺利执行.
所以整个 TCC 模式核心是进行业务逻辑拆分, 拆成两个阶段, try,confirm,cancel.try 进行资源检查, 资源预留, confirm 使用资源, cancel 接口释放预留资源.
并发控制采用数据库锁和业务加锁组合方式实现, 由于业务加锁特性不影响性能, 可以降低数据库锁粒度, 提高并发能力.
TCC 异常处理
在面对分布式系统需要面对的网络超时, 重发, 宕机等不可用问题时, 事务框架往往有不同的问题, 最常见的有: 空回滚, 幂等, 悬挂.
因此在 TCC 接口里面需要处理这三类异常.
空回滚
就是对于一个分布式事务, 在没有调用 TCC 资源 try 方法的情况下, 调用了第二阶段的 cancel 方法, cancel 方法需要识别出这是一个空回滚, 然后返回成功.
什么情况会返回空回滚呢?
在进行 RPC 调用时, Seata 框架会进行切面拦截请求, 进行分支事务注册, 先向 TC 注册分布式事务, 然后执行 RPC 调用逻辑.
如果 RPC 调用逻辑有问题, 比如调用方机器宕机, 网络异常, 会造成 RPC 调用失败, 也就是未能成功执行 Try 方法. 但事务已经开启, 需要推进到终态, 因此 TC 会回调第二阶段 cancel 接口, 从而形成空回滚.
解决空回滚需要额外的一个事务控制表, 其中有分布式事务 id 和分支事务 id, 第一阶段 try 方法里面插入一条记录, 表示一阶段执行了. cancel 接口读取该记录, 如果记录存在, 正常回滚. 如果记录不存在, 执行空回滚.
幂等
事务框架里面幂等的目的是为了解决, 同一个分布式事务里面同一个分支事务, 调用该分支事务的第二阶段接口, 因此 TCC 里面的二阶段提交的 confirm 和 cancel 接口需要保证幂等, 不会重发使用或者释放资源. 幂等控制没有做好的话, 很有可能导致资损等问题.
什么样情况会造成重复提交呢?
提交或回滚是一次 TC 到参与者网络的调用. 因此, 网络故障, 参与者宕机等都有可能造成参与者 TCC 资源实际执行第二阶段方法, 但是 TC 没有收到返回结果的情况, 这是 TC 会重复调用, 直到调用成功, 整个分布式事务结束.
解决重复执行幂等问题的思路是, 可以记录每个分支事务的执行状态, 在执行前状态, 如果执行已执行, 就不再执行. 否则, 正常执行.
参照事务控制表, 事务控制表的每条记录关联一个分支事务, 可以在这张事务控制表增加一个状态字段, 用来记录每个分支事务的执行状态.
该状态字段有三个值, 分别是初始化, 已提交, 已回滚.
try 方法插入时, 是初始化状态.
第二阶段 confirm 和 cancel 方法执行后修改为已提交或回滚状态.
当重复调用二阶段接口时, 先获取该事务控制表对应记录, 检查状态, 如果已执行, 则返回成功, 否则正常执行.
悬挂
悬挂就是对于一个分布式事务, 第二阶段 cancel 接口比 try 接口先执行, 因为允许空回滚, cancel 接口认为 try 接口没有执行, 空回滚执行返回成功, seata 框架认为, 分布式事务第二阶段接口已经执行成功, 整个分布式事务就结束了.
但是此时有可能真正的 try 方法才真正执行, 预留业务资源, 由于 try 过程中会加锁预留资源, 并且只有当前事务可以使用, 但 seata 框架认为分布式事务已经结束, 就会出现第一阶段预留的业务资源没人能够处理, 这种情况属于悬挂.
在 RPC 调用时, 先注册分支事务, 在执行 RPC 调用, 如果此时 RPC 调用网络阻塞, 通常 RPC 调用是有超时时间的, RPC 超时以后, 发起方通知 TC 回滚该事务, 可能回滚完成后, RPC 请求才到达参与者, 真正执行, 从而造成悬挂.
为了防止悬挂, 如果第二阶段完成, 一阶段就不能在继续了, 因此一阶段执行时, 需要先检查二阶段释放已经执行完成, 如果执行完成, 则一阶段不再执行. 否则可以正常执行.
同样依赖于事务控制表, 在二阶段执行时插入一条事务控制记录, 状态为回滚, 这样当一阶段执行时, 先读取该记录, 如果存在, 就认为二阶段已执行. 否则认为二阶段没有执行.
异常控制
分析完回滚, 幂等, 悬挂之后, 考虑如何通过 TCC 解决问题.
try 方法需要考虑两个问题, try 方法能够告诉二阶段接口已经预留资源成功. 还需要检查二阶段是否执行完成, 如果完成不再执行.
先插入事务控制表, 如果插入成功, 说明二阶段还没有执行, 可以继续执行第一阶段, 如果插入失败, 说二阶段已经执行或正在执行, 抛出异常, 终止.
confirm 方法不允许空回滚, 所以 confirm 方法一定要在 try 方法之后执行, 所以 confirm 方法只需要关注重复提交的问题, 可以先锁事务记录, 如果事务记录为空, 则说明是一个空提交, 不允许, 终止执行.
如果事务记录不为空, 则继续检查状态是否为初始化, 如果是, 说明一阶段正确执行, 二阶段正常执行即可. 如果状态为已提交, 则认为重复提交, 直接返回成功即可. 如果状态是已回滚, 就是一个异常事务, 一个已经回滚的事务不能重新提交, 需要拦截到这种情况, 并报警.
cancel 方法不允许空回滚, 在先执行时, 需要让 try 感知到, 所以需要锁定事务记录, 如果事务记录为空, 则认为 try 方法还没有执行, 为空回滚. 空回滚情况下先插入一条事务记录, 确保后续 try 方法不会再执行.
如果插入成功, 说明 try 还没有执行, 空回滚继续执行. 如果插入失败, 认为 try 方法正在执行, 等待 tc 重试即可.
如果一开始读取事务记录不为空, 说明 try 方法已经执行完毕, 在检查状态是否为初始化, 如果是, 则还没有执行二阶段方法, 正常执行 cancel 逻辑.
如果状态为已回滚, 说明是重复调用, 允许幂等, 直接返回成功即可. 如果状态为已提交, 则同样是个异常, 一个已提交的事务, 不能再次回滚.
性能优化
随着业务中对于 Seata 框架的使用越来越多, TCC 的性能问题越来越明显.
同数据库
分支事务记录和业务数据在相同的数据库中, 在切面调用时不再向 TC 注册, 而是直接向业务数据库里面插入一条记录.
一个分布式事务的提交和回滚还是由发起方通知 TC, 但是由于分支事务记录保存在业务数据库, 不是 TC 端, 所以 TC 不知道哪些分支事务记录, 在收到提交或回滚通知后, 仅仅记录下该分布式事务的状态.
为了执行二阶段操作, 各个参与者内部启动一个异步任务, 定时捞取业务数据库中未结束的分支事务记录, 然后向 TC 检查整个分布式事务的状态, 就是 statecheckrequest 请求. TC 在收到这个请求后, 根据之前保存的分布式事务状态, 告诉参与者是提交还是回滚, 从而完成分支记录.
左边是同步模式前调用图, 每次调用一个参与者的时候, 都是向 TC 注册一个分布式事务记录, TC 持久化存储在自己的数据库中, 就是说一个分支事务注册包含了一次 RPC 和一次持久化存储.
右边是优化后的调用图, 每次调用一个参与者的时候, 都是直接保存在业务数据库中, 减少了和 TC 之间的 RPC 调用, 优化后, 有多少个参与者, 就节约了多少 RPC 调用.
一个数据库方案, 把分支记录保存在业务数据库中, 减少了和 TC 的 RPC 调用.
异步化
TCC 模型把两阶段拆分成了两个独立的阶段, 通过资源业务锁定方式进行关联. 资源锁定好处是, 不会阻塞其他事务第一阶段对于相同资源的继续使用, 也不会影响第二阶段的正确执行, 理论上说, 只要业务允许, 事务的二阶段什么时候执行都可以, 反正资源已经锁定了, 不会被其他事务锁定该资源.
对于一些资源锁定, 但是资源执行间隔比较久的业务场景来说, 可以在第一阶段后, 认为本次交易环节完成, 并向用户和商户返回支付成功结果, 并不需要马上执行二阶段的 confirm 操作, 可以降低热点数据性能问题, 在业务低峰期慢慢消化, 异步的执行.
总结
整体上了解了一个分布式事务框架的原理和实现, 并解决常见的异常问题和性能问题, 可以帮助我们自研一套框架解决业务分布式事务需求.
当然不同业务要求不同, 一个好的分布式事务需要适配自身业务特点, 找到更合适的结合点.
更多内容:
来源: https://www.cnblogs.com/xiguain/p/10893231.html