蚂蚁金服架构演进-服务化拆分
从单系统到微服务转变,其实是一个资源横向扩展的过程,资源的横向扩展是指当单台机器达到资源性能瓶颈,无法满足业务增长需求时,就需要横向扩展资源,形成集群。通过横向扩展资源,提升非热点数据的并发性能,这对于大体量的互联网产品来说,是至关重要的。服务的拆分,也可以认为是资源的横向扩展,只不过方向不同而已。
资源横向扩展可能沿着两个方向发展,包括业务拆分和数据分片:
1.业务拆分:根据功能对数据进行分组,并将不同的微服务分布在多个不同的数据库上,这实际上就是SOA架构下的服务化。业务拆分就是把业务逻辑从一个单系统拆分到多个微服务中。
2.数据分片:在微服务内部将数据拆分到多个数据库上,为横向扩展增加一个新的维度。数据分片就是把一个微服务下的单个DB拆分成多个DB,具备一个Sharding的功能。通过这样的拆解,相当于一种资源的横向扩展,从而使得整个结构可以承载更高的吞吐
横向扩展的两种方法可以同时进行运用:交易、支付与账务三个不同微服务可以存储在不同的数据库中。另外,每个微服务内根据其业务量可以再拆分到多个数据库中,各微服务可以相互独立地进行扩展。
Seata 关注的就是微服务架构下的数据一致性问题,是一整套的分布式事务解决方案。Seata 框架包含两种模式,一种是 AT 模式。AT 模式主要从数据分片的角度,关注多 DB 访问的数据一致性,当然也包括多服务下的多 DB 数据访问一致性问题。
另外一个就是 TCC 模式,TCC 模式主要关注业务拆分,在按照业务横向扩展资源时,解决微服务间调用的一致性问题,保证读资源访问的事务属性。
TCC模式
Seata 框架把每组 TCC 接口当做一个 Resource,称为 TCC Resource。这套 TCC 接口可以是 RPC,也以是服务内 JVM 调用。在业务启动时,Seata 框架会自动扫描识别到 TCC 接口的调用方和发布方。如果是 RPC 的话,就是 sofa:reference、sofa:service、dubbo:reference、dubbo:service 等。
如果是调用方,Seata 框架会给调用方加上切面,与 AT 模式一样,在运行时,该切面会拦截所有对 TCC 接口的调用。每调用一次 TCC 接口,切面会先向 TC(Transaction Coordinator,事务协调器) 注册一个分支事务,然后才去执行原来的 RPC 调用。当请求链路调用完成后,TC 通过分支事务的资源 ID 回调到正确的参与者去执行对应 TCC 资源的 Confirm 或 Cancel 方法。
TCC接口怎么实现?
框架本身很简单,主要是扫描TCC接口,注册资源,拦接口调用,注册分支事务,最后回调二阶段接口。最核心的实际上是TCC接口的实现逻辑。
TCC业务模式与并发控制
TCC设计原则
从 TCC 模型的框架可以发现,TCC 模型的核心在于 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 资源里,Try 接口预留资源扣除余额,Confirm 接口空操作,Cancel 接口释放资源,增加余额。在加钱 TCC 资源里,Try 接口无需预留资源,空操作;Confirm 接口直接增加余额;Cancel 接口无需释放资源,空操作。
2.3 账务系统模型并发控制
之前提到,设计一套 TCC 接口需要有两点,一点是需要拆分业务逻辑成两阶段完成。这个我们已经介绍了。另外一点是要根据自身的业务模型控制并发。
Seata 框架本身仅提供两阶段原子提交协议,保证分布式事务原子性。事务的隔离需要交给业务逻辑来实现。隔离的本质就是控制并发,防止并发事务操作相同资源而引起的结果错乱。
举个例子,比如金融行业里管理用户资金,当用户发起交易时,一般会先检查用户资金,如果资金充足,则扣除相应交易金额,增加卖家资金,完成交易。如果没有事务隔离,用户同时发起两笔交易,两笔交易的检查都认为资金充足,实际上却只够支付一笔交易,结果两笔交易都支付成功,导致资损。
可以发现,并发控制是业务逻辑执行正确的保证,但是像两阶段锁这样的并发访问控制技术要求一直持有数据库资源锁直到整个事务执行结束,特别是在分布式事务架构下,要求持有锁到分布式事务第二阶段执行结束,也就是说,分布式事务会加长资源锁的持有时间,导致并发性能进一步下降。
因此,TCC 模型的隔离性思想就是通过业务的改造,在第一阶段结束之后,从底层数据库资源层面的加锁过渡为上层业务层面的加锁,从而释放底层数据库锁资源,放宽分布式事务锁协议,将锁的粒度降到最低,以最大限度提高业务并发性能。
还是以上面的例子举例,“账户 A 上有 100 元,事务 T1 要扣除其中的 30 元,事务 T2 也要扣除 30 元,出现并发”。在第一阶段 Try 操作中,需要先利用数据库资源层面的加锁,检查账户可用余额,如果余额充足,则预留业务资源,扣除本次交易金额,一阶段结束后,虽然数据库层面资源锁被释放了,但这笔资金被业务隔离,不允许除本事务之外的其它并发事务动用。
并发的事务 T2 在事务 T1 一阶段接口结束释放了数据库层面的资源锁以后,就可以继续操作,跟事务 T1 一样,加锁,检查余额,扣除交易金额。
事务 T1 和 T2 分别扣除的那一部分资金,相互之间无干扰。这样在分布式事务的二阶段,无论 T1 是提交还是回滚,都不会对 T2 产生影响,这样 T1 和 T2 可以在同一个账户上并发执行。
大家可以感受下,一阶段结束以后,实际上采用业务加锁的方式,隔离账户资金,在第一阶段结束后直接释放底层资源锁,该用户和卖家的其他交易都可以立刻并发执行,而不用等到整个分布式事务结束,可以获得更高的并发交易能力。
这里稍微有点抽象,下面我们将会针对业务模型进行优化,大家可以更直观的感受业务加锁的思想。
2.4 账务系统模型优化
前面的模型大家肯定会想,为啥一阶段就把钱扣除了?是的。之前只是为了简单说明 TCC 模型的设计思想。在实际中,为了更好的用户体验,在第一阶段,一般不会直接把账户的余额扣除,而是冻结,这样给用户展示的时候,就可以很清晰的知道,哪些是可用余额,哪些是冻结金额。
那业务模型变成什么样了呢?如图所示,需要在业务模型中增加冻结金额字段,用来表示账户有多少金额处以冻结状态。
既然业务模型发生了变化,那扣钱和加钱的 TCC 接口也应该相应的调整。还是以前面的例子来说明。
在扣钱的 TCC 资源里。Try 接口不再是直接扣除账户的可用余额,而是真正的预留资源,冻结部分可用余额,即减少可用余额,增加冻结金额。Confirm 接口也不再是空操作,而是使用 Try 接口预留的业务资源,即将该部分冻结金额扣除;最后在 Cancel 接口里,就是释放预留资源,把 Try 接口的冻结金额扣除,增加账户可用余额。加钱的 TCC 资源由于不涉及冻结金额的使用,所以无需更改。
那并发控制又变成什么样了呢?跟前面大部分类似,在事务 T1 的第一阶段 Try 操作中,先锁定账户,检查账户可用余额,如果余额充足,则预留业务资源,减少可用余额,增加冻结金额。并发的事务 T2 类似,加锁,检查余额,减少可用余额金额,增加冻结金额。
这里可以发现,事务 T1 和 T2 在一阶段执行完成后,都释放了数据库层面的资源锁,但是在各自二阶段的时候,相互之间并无干扰,各自使用本事务内第一阶段 Try 接口内冻结金额即可。这里大家就可以直观感受到,在每个事务的第一阶段,先通过数据库层面的资源锁,预留业务资源,即冻结金额。虽然在一阶段结束以后,数据库层面的资源锁被释放了,但是第二阶段的执行并不会被干扰,这是因为数据库层面资源锁释放以后通过业务隔离的方式为这部分资源加锁,不允许除本事务之外的其它并发事务动用,从而保证该事务的第二阶段能够正确顺利的执行。
通过这两个例子,为大家讲解了怎么去设计一套完备的 TCC 接口。最主要的有两点,一点是将业务逻辑拆分成两个阶段完成,即 Try、Confirm、Cancel 接口。其中 Try 接口检查资源、预留资源、Confirm 使用资源、Cancel 接口释放预留资源。另外一点就是并发控制,采用数据库锁与业务加锁的方式结合。由于业务加锁的特性不影响性能,因此,尽可能降低数据库锁粒度,过渡为业务加锁,从而提高业务并发能力。
3 TCC 异常控制
在有了一套完备的 TCC 接口之后,是不是就真的高枕无忧了呢?答案是否定的。在微服务架构下,很有可能出现网络超时、重发,机器宕机等一系列的异常 Case。一旦遇到这些 Case,就会导致我们的分布式事务执行过程出现异常。根据蚂蚁金服内部多年的使用来看,最常见的主要是这三种异常,分别是空回滚、幂等、悬挂。
因此,TCC 接口里还需要解决这三类异常。实际上,这三类问题可以在 Seata 框架里完成,只不过我们现在的 Seata 框架还不具备,之后我们会把这些异常 Case 的处理移植到 Seata 框架里,业务就无需关注这些异常情况,专注于业务逻辑即可。
虽然业务之后无需关心,但是了解一下其内部实现机制,也能更好的排查问题。下面我将为大家一一讲解这三类异常出现的原因以及对应的解决方案。
3.1 空回滚
首先是空回滚。什么是空回滚?空回滚就是对于一个分布式事务,在没有调用 TCC 资源 Try 方法的情况下,调用了二阶段的 Cancel 方法,Cancel 方法需要识别出这是一个空回滚,然后直接返回成功。
什么样的情形会造成空回滚呢?可以看图中的第 2 步,前面讲过,注册分支事务是在调用 RPC 时,Seata 框架的切面会拦截到该次调用请求,先向 TC 注册一个分支事务,然后才去执行 RPC 调用逻辑。如果 RPC 调用逻辑有问题,比如调用方机器宕机、网络异常,都会造成 RPC 调用失败,即未执行 Try 方法。但是分布式事务已经开启了,需要推进到终态,因此,TC 会回调参与者二阶段 Cancel 接口,从而形成空回滚。
那会不会有空提交呢?理论上来说不会的,如果调用方宕机,那分布式事务默认是回滚的。如果是网络异常,那 RPC 调用失败,发起方应该通知 TC 回滚分布式事务,这里可以看出为什么是理论上的,就是说发起方可以在 RPC 调用失败的情况下依然通知 TC 提交,这时就会发生空提交,这种情况要么是编码问题,要么开发同学明确知道需要这样做。
那怎么解决空回滚呢?前面提到,Cancel 要识别出空回滚,直接返回成功。那关键就是要识别出这个空回滚。思路很简单就是需要知道一阶段是否执行,如果执行了,那就是正常回滚;如果没执行,那就是空回滚。因此,需要一张额外的事务控制表,其中有分布式事务 ID 和分支事务 ID,第一阶段 Try 方法里会插入一条记录,表示一阶段执行了。Cancel 接口里读取该记录,如果该记录存在,则正常回滚;如果该记录不存在,则是空回滚。
3.2 幂等
接下来是幂等。幂等就是对于同一个分布式事务的同一个分支事务,重复去调用该分支事务的第二阶段接口,因此,要求 TCC 的二阶段 Confirm 和 Cancel 接口保证幂等,不会重复使用或者释放资源。如果幂等控制没有做好,很有可能导致资损等严重问题。
什么样的情形会造成重复提交或回滚?从图中可以看到,提交或回滚是一次 TC 到参与者的网络调用。因此,网络故障、参与者宕机等都有可能造成参与者 TCC 资源实际执行了二阶段防范,但是 TC 没有收到返回结果的情况,这时,TC 就会重复调用,直至调用成功,整个分布式事务结束。
怎么解决重复执行的幂等问题呢?一个简单的思路就是记录每个分支事务的执行状态。在执行前状态,如果已执行,那就不再执行;否则,正常执行。前面在讲空回滚的时候,已经有一张事务控制表了,事务控制表的每条记录关联一个分支事务,那我们完全可以在这张事务控制表上加一个状态字段,用来记录每个分支事务的执行状态。