seadt:金融级分布式事务解决方案 —— 技术选型和实现
seadt 是 Shopee 金融产品团队使用 Golang,针对真实的业务场景提供的分布式事务解决方案。
Golang 目前没有成熟的中间件、组件支持分布式事务,如何快速支持业务需求且同时解决分布式事务问题,是业务团队面临的棘手问题。
这将会是一系列文章,分多个章节进行阐述。本文作为开篇文章,会概要介绍金融业务中遇到的分布式事务问题,详细介绍团队的选型和实现。
1. 分布式事务的挑战
金融产品(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 事务的关系型数据库: 1) 一阶段 prepare 行为:在本地事务中,一并提交业务数据更新和相应回滚日志记录 2) 二阶段 commit 行为:马上成功结束,自动异步批量清理回滚日志 3) 二阶段 rollback 行为:通过回滚日志,自动生成补偿操作,完成数据回滚 | TCC 模式,不依赖于底层数据资源的事务支持: 1) 一阶段 prepare 行为:调用自定义的 prepare 逻辑 2) 二阶段 commit 行为:调用自定义的 commit 逻辑 3) 二阶段 rollback 行为:调用自定义的 rollback 逻辑 所谓 TCC 模式,是指支持把自定义的分支事务纳入到全局事务的管理中 | 1) 业务流程长、业务流程多 2) 参与者包含其它公司或遗留系统服务,无法提供 TCC 模式要求的三个接口 | 简单的业务场景,只有本地和一次 RPC 写调用 |
优势 | 对业务无侵入 | 1) 一阶段提交本地事务,无锁,高性能 2) 扩展性好,增加一个参与者无额外开发成本 3) 易理解 4) 对外屏蔽复杂性 | 1)一阶段提交本地事务,无锁,高性能 2) 事件驱动架构,参与者可异步执行,高吞吐 3) 补偿服务易于实现 | 简单易懂 |
不足 | 1) 保证隔离性,但是锁的时间长 2) 实现较为复杂,严重依赖数据库,而且需要根据数据库事务类型做不同处理,容易出错 | 1) 对业务有侵入,需要提供中间状态 2) 对业务开发要求高 | 1) 不保证隔离性 2) 对业务存在一定入侵 | 1) 不易维护,扩展性差 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,来自 Financial Products Credit 团队。
加入我们
基于 Shopee 服务的市场,Financial Products 致力于打造信贷、保险和投资理财等金融服务。信贷为客户和商户提供消费贷和现金贷服务。在诸多消费场景,提供各种类型的保险购买服务;以及提供即时的基金购买和赎回服务,满足用户的投资理财需求。我们围绕零售金融持续打造资金、资产、核算、交易、风控、用户、承保、理赔等金融核心服务;另外,在金融方面的资金账务严要求情形下,我们的业务还呈现出场景多、金额小、交易并发高的特点,我们的团队需解决业务扩展性、数据一致性、高并发等多维度的挑战。
目前团队大量岗位持续招聘中,海量 HC 涵盖后端、前端、测试、大数据等,感兴趣的同学可将简历发送至:rachel.chen@shopee.com(亦可进行咨询,注明来自 Shopee 技术博客)。