seadt:金融级分布式事务解决方案 —— 技术选型和实现

原创
2022/04/15 14:07
阅读数 3.2K

本文首发于微信公众号“Shopee技术团队”。

摘要

seadt 是 Shopee 金融产品团队使用 Golang,针对真实的业务场景提供的分布式事务解决方案。

Golang 目前没有成熟的中间件、组件支持分布式事务,如何快速支持业务需求且同时解决分布式事务问题,是业务团队面临的棘手问题。

这将会是一系列文章,分多个章节进行阐述。本文作为开篇文章,会概要介绍金融业务中遇到的分布式事务问题,详细介绍团队的选型和实现。

1. 分布式事务的挑战

Shopee 金融产品(Financial Products)团队在东南亚及其他地区提供信贷等金融业务,同时面向 C 端和 B 端用户,目前已经在多个市场上线。

金融业务,最大的挑战是对数据准确性的要求非常高。用户在平台进行贷款,我们要保证每一笔借款都准确无误,不能多也不能少。多了会侵害用户的财产,少了就引起公司的资损。如何保证数据准确无误,除了系统计算算法本身准确无误外,最大的挑战就是系统由于分布式带来的事务处理。

1.1 分布式事务挑战

例如:用户申请贷款,系统返回贷款申请成功,背后的后台系统会发生什么?先来看看 Cashloan 的系统架构(非关联部分脱敏):

在 Cashloan 中会发生很多事情,例如:冻结优惠券、冻结额度、调外部支付网关进行放款、等待放款结果、放款结果后置处理等。

这些处理就是靠交易模块 Cashloan-Transaction 管理编排。虽然内部的处理很多,分了很多接口,站在用户角度看贷款的后续处理就是一个事务,要么贷款成功、要么贷款失败。至于贷款成功/失败内部各个系统间的数据状态一致,则需要交易模块 Cashloan-Transaction 保证。

贷款业务场景中,涉及的事务问题很多:1)冻结优惠券和冻结额度如何保证状态一致(同时成功同时失败);2)支付网关成功/失败,如何保证后续处理状态一致;3)其他。

本文主要对第一个问题展开讲解,即冻结优惠券和冻结额度如何保证状态一致。后续会有其他文章介绍余下的问题解决方案,欢迎持续关注。

相信很多同学第一时间想到的解决方案是使用 seata。但是我们的开发语言是 Golang,不能直接使用 seata。

于是就有了本篇的技术选型,包括对现有开源的中间件还是自研的选择。当然在此之前,我们团队更加关心选择什么模式更加适合我们的业务。例如 seata 提供了四种模式:TCC 模式、Saga 模式、AT 模式、XA 模式。这些模式都有适用场景,但是第一步需要确定优先级。

1.2 模式选型:TCC

结合项目现状,我们团队做了一些调研分析。

  AT 模式 TCC 模式 Saga 模式 现有实现
资料链接 link AT link TCC link Saga 内部文档
原理说明 框架层面记录数据变更前后的镜像,在应用侧做类似 redo、undo 操作 服务提供方提供 TCC 的 2 阶段接口 业务方提供一阶段正向服务,和与之对应的冲正服务 业务逻辑中做到最终一致性,将每一个 RPC 调用的超时/失败/宕机的处理考虑进去,在业务中自实现补偿恢复/回滚等逻辑
适用场景 AT 模式(参考链接)基于支持本地 ACID 事务的关系型数据库:<br/>1) 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录<br/>2) 二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志<br/>3) 二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚 TCC 模式,不依赖于底层数据资源的事务支持:<br/>1) 一阶段 prepare 行为:调用自定义的 prepare 逻辑<br/>2) 二阶段 commit 行为:调用自定义的 commit 逻辑<br/>3) 二阶段 rollback 行为:调用自定义的 rollback 逻辑<br/><br/>所谓 TCC 模式,是指支持把自定义的分支事务纳入到全局事务的管理中 1) 业务流程长、业务流程多<br/>2) 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口 简单的业务场景,只有本地和一次 RPC 写调用
优势 对业务无侵入 1) 一阶段提交本地事务,无锁,高性能<br/>2) 扩展性好,增加一个参与者无额外开发成本<br/>3) 易理解<br/>4) 对外屏蔽复杂性 1)一阶段提交本地事务,无锁,高性能<br/>2) 事件驱动架构,参与者可异步执行,高吞吐<br/>3) 补偿服务易于实现 简单易懂
不足 1) 保证隔离性,但是锁的时间长<br/>2) 实现较为复杂,严重依赖数据库,而且需要根据数据库事务类型做不同处理,容易出错 1) 对业务有侵入,需要提供中间状态<br/>2) 对业务开发要求高 1) 不保证隔离性<br/>2) 对业务存在一定入侵 1) 不易维护,扩展性差<br/>2) 每个场景处理都不一样,可复制性低

1.2.1 AT 模式

贷款流程引入 AT 模式流程如下:

虽然该方式对业务无入侵,影响点最小。但是该模式资源锁的时间长,对于中间并发可能带来灾难性后果,并且实现难度大。出现问题排查及恢复困难,团队也没有该方面的经验和知识储备。

因此不选择 AT 模式。

1.2.2 Saga 模式

贷款流程引入 Saga 模式交互如下:

业务流程长,对于各业务节点要求高,需要支持回滚接口,而回滚接口业务上能否支持存疑。例如:贷款中如果使用该模式,优惠券/资金方的回退能否支持未知(可能存在长时间跨度的回退,例如支付通过后的处理本身就存在跨多日),并且其他附带业务的金额回退比较麻烦,需走线下回退给用户。

因此先不支持 Saga 模式。

1.2.3 业务自实现

贷款流程当前业务实现逻辑如下:

针对第一步“扣减额度处理”展开说明,其时序如下:

说明:为了保证额度扣减与整个用户操作的状态一致性(即用户贷款成功额度才会冻结扣减,用户贷款未成功,则额度最终未冻结未扣减),做了 ①~⑦ 处理。

其中:

  • ① 在还未冻结额度前就添加延时队列执行恢复额度操作,是为了避免 ② 冻结额度调用后应用宕机,额度冻结无法解冻的特殊处理;
  • ③ 在额度冻结成功后,则需要将恢复冻结额度的延时队列删除,避免恢复冻结额度而导致用户贷款成功而额度未扣减;
  • ⑥ 利用可靠事件保证最终一定执行并成功。

由此可见,异常场景的处理融合在业务代码中,导致业务流程特别复杂,并且无法扩展和复用。例如贷款中的实现,在还款场景下,不能完全照搬过去,依然需要考虑还款的特殊性。在贷款场景中,如果添加其他外部调用,需要重新考虑整个流程的一致性,如上图,只实现了冻结额度的一致性处理,加入其他业务处理,不能按照同样的方式实现。

因此不选择业务自实现。

1.2.4 TCC 模式

贷款流程引入 TCC 模式流程如下(同 AT):

对业务存在侵入,对开发水平要求高,需要考虑几种异常情况并处理。好在这几种异常情况有统一解决模板,降低问题出现概率。

因此使用 TCC 模式。

1.3 技术选型:自研

模式确定后,就考虑如何技术选型了。我们做了 4 种对比:seata-golang、seata-go-server、内部其他系统实践(不对外公开对比)、自研。

调研过程中结合了很多维度进行考量:维护状态、支持团队、社区建设、成熟度、文档、功能性、License、可维护性、集成性、团队意向等方面。

以下分析基于 2021-6-25 调研数据。

1.3.1 seata-go-server

  • 维护状态:停止更新(上次更新 2019-7-10)
  • 成熟度:无 release 版本
  • 文档:无

已经 2 年未更新,因此不考虑。

1.3.2 seata-golang

  • 维护状态:维护中,更新较少(最近活跃起来,有持续更新)
  • 成熟度:无 release 版本
  • 支持团队:目前仅一人
  • 文档:无有效文档,均为 Java 版 seata 的文档
  • 功能性:仅支持 TCC 模式(12 月份后支持 Saga 模式)
  • 集成性:集成了 ORM 框架 go-sql-driver/mysql,与本团队当前使用的 ORM 冲突
  • License:Apache License 2.0
  • 开发模式:
    • 原分支开发:与 seata 团队共同开发,对需要改的内容发 merge 请求,需 alibaba/opentrx 团队审核。
    • 新分支开发:需要托管在 GitHub 的 opentrx 代码仓库下,由于开发权限无法控制,可能会被他人更改。好处:opentrx 团队的变更较方便的同步到分支中。
    • 新仓库:独立一套,放在内网 GitLab 中。劣势:opentrx 的更改无法同步更新;opentrx 的很多预留功能点难以理解,改动较难。

虽然持续更新中,但是没有稳定的 release 版本,也没有任何的商业应用,Cashloan 当前业务量大,直接使用风险高。因此也不考虑。

1.3.3 自研

  • 维护状态:项目不倒,维护不停
  • 支持团队:至少 3 人
  • 文档:目前已有丰富设计文档,内部分享文档,还会持续建设
  • 功能性:优先支持 TCC 模式,再支持 Saga 模式
  • 集成性:基于金融团队技术栈开发,无集成问题
  • 团队意向:团队意向高,可以解决部门内大量分布式场景问题

Cashloan 在各市场一直受强监管约束。例如,数据库敏感信息加密、日志脱敏等。外部团队为此做特殊版本较难。Cashloan 的代码托管如果在外部仓库,监管方面风险大。

自研可针对上述的问题设计支持,缺点则需要额外投入人力从零开始设计和开发,并且上线之后可能会遇到很多问题。

可行性分析:

  • 技术:团队技术储备雄厚,对 TCC 熟练掌握,有类似框架组件的开发经验(去年本团队做了可靠事件组件且上线后大量场景应用);
  • 应用:团队作为业务团队,本身有大量业务场景可以应用,可以保证可落地;
  • 投入产出:投入产出比可观。团队在实现贷款流程一致性中,投入了 1 人/月设计和开发联调,后续半年在贷款需求变更中,陆陆续续投入 2 人/月。类似场景光本团队就有 5 处之多。而自研开发人力投入 2 人/月左右,改造对接联调 0.25 人/月场景,后续业务无维护成本。

除此之外,自研有如下好处:

  • 解决 Cashloan 自身业务问题:放款/还款流程等;
  • 提升系统可维护性、可扩展性;
  • 符合适用原则,满足当前业务需求;
  • 符合简单原则,第一阶段的实现够简单,清晰明了,确保团队内成员能够接受理解,提升可维护性;
  • 符合演化原则,预留好其他功能的演化迭代扩展,便于日后业务变更带来的需求挑战;
  • 可作为能力输出,给整个金融团队带来效率提升,解决分布式事务共性问题。

2. seadt-TCC 设计与实现

既然已经确定自研 TCC,首先就要考虑 TCC 的架构设计。有两种设计方案,一是纯 SDK 模式(SDK 模式),另一种是 SDK+独立中央服务(TC 全局模式)。

这两种有何区别,先看看分布式事务组件的结构:

  • TM(Transaction Manager):客户端 SDK,开启/结束分布式事务;
  • RM(Resource Manager):客户端 SDK,管理本地资源;
  • TC(Transaction Coordinator):客户端 SDK 或者服务端,管理全局事务及分支事务状态,以及推进事务执行。

SDK 模式:TM 和 TC 在一起。

TC 全局模式:TC 单独作为服务部署。

考虑到未来使用嵌套事务,方便统一监控管理,以及 Saga 模式支持等,我们选择了 TC 全局模式。

全局模式的交互如下:

由业务方 Transaction 模块启动分布式事务,由 TC 与各模块交互,推进整个事务往下执行。

2.1 Cashloan 新系统架构

引入 seadt 后的整体架构如下:

每个业务系统模块按需引入 seadt-SDK(使用分布式事务),所有的系统可以公用同一套 TC 服务。

各个业务系统模块引入 seadt-SDK 后依然可以水平扩展,同时也支持分库分表。

seadt-TC 作为公共服务,很容易成为新的瓶颈,因此做了高可用设计,可以水平扩展、分库分表,允许多租户模式公用,也允许各业务进行物理隔离部署。

业务系统模块引入 seadt-SDK 结构如下(以示例中的 Transaction 模块为例):

Transaction 模块引入 seadt-SDK,SDK 包含事务管理器 TM 和资源管理器 RM,同时还包含可靠事件管理器 Reliable_Event(本地消息方式保证最终一致性)。

2.2 TC 全局模式

TC 全局模式,对 TCC 的支持:

  • 发起者 TM 向 TC 注册全局事务;
  • 发起者冻结额度和冻结优惠券;
  • 参与者 RM 注册分支事务;
  • 参与者执行一阶段 Try 方法,做优惠券冻结业务处理;
  • 发起者 RM 执行本地业务处理;
  • 发起者 TM 提交全局事务;
  • TC 执行二阶段 Confirm/Cancel;
  • 参与者 RM 执行二阶段 Confirm/Cancel 方法,做真正的业务处理。

TC 全局模式,对 Saga 的支持(本期暂不详细介绍):

2.3 状态机设计

分布式事务中有两个核心的状态机,主事务状态机、分支事务状态机。

主事务状态机图

分支事务状态机图

TM 与 RM 状态矩阵(行代表主事务,列代表分支事务):

分支\主 Prepared Committing Committed Rollbacking Rollbacked
- Y N N Y N
Prepared N Y N N Y N
Tried N Y Y N Y N
Confirmed N N Y Y N N
Canceled N N N N Y Y

注意:这里有个特殊的情况,即主事务在 Rollbacking 状态,分支事务可能在 Prepared 状态。

对应的场景为:TM 向多个参与者中发送一阶段 T 请求的时候,如果有一个业务执行报错则分支状态会停留在 Prepared 状态,而 TM 收到 T 失败处理后立即进入 Rollback 流程,同时向所有参与者立即广播二阶段 Cancel 处理,所以会出现主事务在 Rollbacking 状态,分支事务在 Prepared 状态。

在这个场景下,由于多个参与者处理速度和结果不一样,会同时存在无数据、Prepared、Tried,以及 Canceled 状态。

该特殊场景,也会体现在 RM 与 RM 之间,其中一个为 Canceled,另一个无数据(空回滚)、Prepared、Tried、Canceled。但是绝对不可能存在一个 Canceled,另一个为 Confirmed。

RM 与 RM 状态矩阵:

分支\分支 Prepared Tried Confirmed Canceled
Y Y Y N Y
Prepared Y Y Y N Y
Tried Y Y Y Y Y
Confirmed N N Y Y N
Canceled Y Y Y N Y

2.4 详细流程

2.4.1 术语说明

  • Commit、Rollback:整个分布式事务的状态以及分支事务的状态。
  • Confirm、Cancel:只有在调用参与者接口的时候会使用 Confirm、Cancel 表述。
  • commit、rollback:底层代码具体的事务操作方法。

2.4.2 业务 TCC 处理

seadt 的 TCC 模式设计目标,就是业务处理中外部调用能像本地事务一样简单。使用 seadt 后,业务处理如下:

2.4.3 SDK 中 TCC 的 Commit 处理

1)先看业务启动分布式事务,SDK 中的处理:

SDK 提供了一个全新的事务模板 SDK-TT,会注册事务触发器,该事务触发器贯穿整个 TCC 事务。事务模板中事务触发器详情见 2.4.5 事务模板。

2)业务调外部 Try 接口,SDK 内部实现。

3)业务走 Commit 流程,SDK 内部实现。

特殊说明,上图中的 tx-global 代表的业务开启的分布式事务,4.1.3 Activity 置为 Commit,需要同业务开启的分布式事务在一个事务内。如果是开启新事务 tx-sub,则有可能全局事务状态为Commit,但是后面出现异常,导致实际走的是 rollback 流程。

seadt-SDK 将分布式问题统一处理,让业务代码依然能够保持像本地事务处理一样简单。

各类异常处理均由 seadt-SDK 实现,例如:

  • 主事务、分支事务状态维护;
  • Commit 流程的 rollback 处理;
  • 二阶段的推进处理。

2.4.4 SDK 中 TCC 的 Rollback 处理

1)如果发生异常进入 Rollback 流程,SDK 的处理。

说明:启动分布式事务发生异常,会进入 rollback 流程。调用外部 Try 方法异常/超时会进入 rollback 流程。

2)Rollback 的 SDK 处理。

2.4.5 SDK 中 TCC 的事务恢复处理

事务恢复管理器会定时触发,将分布式事务已经确定 Commit/Rollback,而分支事务未进入终态的进行补偿处理。处理如下:

2.4.6 事务模板

无论上面的 Commit 流程处理还是 Rollback 流程处理,都依赖事务模板的各类触发器,而这个也是 Golang 事务模板未提供的,因此我们重新设计了一个新的事务模板,其触发器设计如下:

这个事务模板及它包含的各类事务触发器,才是 seadt-SDK 的基石。

2.4.7 注意事项

分布式事务在进入 Commit 前,任何异常报错都进入 Rollback 流程。Commit 流程中红框部分是往往忽略的点,虽然参与者都 Try 成功,并且业务代码准备 commit 事务,但是在 seadt-SDK 内部依然存在红框部分执行失败,导致整个分布式事务最终走向 Rollback。此后发生失败,则由事务恢复处理器补偿处理。

Rollback 流程中,大部分报错都可以简化到由事务恢复处理器补偿处理。为了分布式事务快速失败,因此做了立即触发调用参与者 Cancel 处理。

需要区分 Commit 的 commit 和 rollback,Rollback 的 commit 和 rollback。

2.5 seadt 约束与规范

seadt 的 TCC 模式,采用的是 2PC 思想提交事务,需要满足原子承诺协议(atomic commitment protocol)。参考该协议,seadt 也提出了自身的一些协议规范,确保事务流转高效可控。

AC1: All participants that decide reach the same decision.
AC2: If any participant decides COMMIT, then all participants must have voted YES.
AC3: If all participants vote YES and no failures occur, then all participants decide COMMIT. 
AC4: Each participant decides at most once (that is, a decision is irreversible).
					
A protocol that satisfies all four of the above properties is called an atomic commitment protocol.

—— 引自:Ozalp Babaoglu. Understanding Non-Blocking Atomic Commitment. January 1993

2.5.1 发起者约束与规范

  • 全局事务状态只能由发起者决定;
  • 所有参与者一阶段成功才能进入 Commit;
  • 提供分布式事务反查接口;
  • 启动分布式事务,需要考虑自身是否为嵌套事务,SDK 是否支持。

说明:

  • seadt 的 TCC 模式,定位为 Blocking Atomic Commitment,只能由发起者 TM 决定事务状态,不允许参与者决定;
  • 所有参与者 Try 执行成功后事务才能进入 Commit 流程。有一个参与者失败则进入 Rollback 流程;
  • SDK 提供统一的事务反查接口,发起者无需实现。由于存在发起者本地事务提交,未通知 TC 宕机的情况。TC 不允许决策事务最终状态,因此只能反查 TM;
  • 当前不支持嵌套事务,因此不允许使用嵌套事务。

2.5.2 参与者约束与规范

  • 实现 TCC 接口,Try 锁资源,CC 确保业务上一定能成功;
  • 控制幂等、并发处理;
  • 禁止空提交,允许空回滚;
  • 避免事务悬挂,并做好监控告警;
  • 做好数据可见性和隔离性;
  • 二阶段处理中不允许作为发起者发起分布式事务。

说明:

  • 参与者必须在 Try 阶段就将所有的资源占用,否则 TC 在推进 Confirm 的时候,就无法成功;
  • TC 通知参与者二阶段,无法保证 Exactly Once,只能做到 At Least Once,因此需要幂等。由于存在重试以及网络延时等情况,也会存在并发情况;
  • 不会存在参与者 Try 未执行,TC 通知 Confirm。允许存在 Try 未执行,Cancel 先到的情况;
  • 发生空回滚后,如果 Try 才到,如果未做特殊处理,则发生事务悬挂,资源无法释放。seadt-SDK 中统一处理;
  • 数据可见性和隔离性需要业务自身处理,例如余额增加冻结额度。

2.5.3 TC 约束与规范

  • TC 不可确定分布式事务状态;
  • 二阶段状态不可变,在 TC 落地的二阶段状态就是终态,无论什么情况都不允许改变;
  • 确保分支事务二阶段成功;
  • 数据清理及归档;
  • 超时失败的事务或者长期悬挂在一阶段的事务,需要告警。

说明:

  • TC 在长时间未收到 TM 通知,也不允许决策事务状态,会反查 TM 拿到事务结果;
  • TC 推进参与者执行二阶段,即便多次重试依然报错,也不允许调整事务状态。而是报错告警,人工干预;
  • TC 通过广播形式保证参与者二阶段执行成功;
  • 分布式事务结束后,及时数据清理,避免数据堆积;Rollback 状态事务长期保留,Commit 状态事务保留一定时间,定时批量清除;
  • TC 拥有全局事务及分支事务数据和状态,因此可以监控长时间悬挂事务。

2.6 seadt 难点分析

  • Golang 如何实现 AOP 切面功能,使得参与者的分支事务注册、事务结果上报、幂等控制、空回滚、事务悬挂处理等可以在 seadt-SDK 中统一处理;
  • TC 调用 RM 的二阶段,如何解决 TC 不依赖 RM 的 pb,以及如何能够反射调到参与者真正的业务二阶段方法;
  • TC 的高可用设计。

这些难点的解决方案,会在接下来的文章中介绍。

目前 seadt 组件已经在部分核心业务流程中得到使用,大大减少了原有业务自实现中的开发内容。未来 seadt 还将支持 Saga 模式,让业务团队在事务处理中更加轻松自如。

后续我们将针对 seadt 的应用、新功能、新规划以及难点设计等输出文档说明,大家敬请期待。

本文作者

Marshal、Ansen、Yongchang,来自 Shopee Financial Products 团队。

展开阅读全文
加载中
点击引领话题📣 发布并加入讨论🔥
打赏
0 评论
6 收藏
1
分享
返回顶部
顶部