随着微服务架构的流行,随之而来就必然遇到跨服务的分布式事务这个难题。分布式事务之所以难,主要是因为分布式系统中的各个节点都可能发生各种非预期的情况。本文先介绍分布式系统中的异常问题,然后介绍这些问题带给分布式事务的挑战,接下来指出现有各种常见用法的问题,最后给出正确的方案。
NPC 的挑战
分布式系统最大的敌人可能就是 NPC 了,在这里它是 Network Delay, Process Pause, Clock Drift 的首字母缩写。我们先看看具体的 NPC 问题是什么:
- Network Delay ,网络延迟。虽然网络在多数情况下工作的还可以,虽然 TCP 保证传输顺序和不会丢失,但它无法消除网络延迟问题。
- Process Pause ,进程暂停。有很多种原因可以导致进程暂停:比如编程语言中的 GC (垃圾回收机制)会暂停所有正在运行的线程;再比如,我们有时会暂停云服务器,从而可以在不重启的情况下将云服务器从一台主机迁移到另一台主机。我们无法确定性预测进程暂停的时长,你以为持续几百毫秒已经很长了,但实际上持续数分钟之久进程暂停并不罕见。
- Clock Drift ,时钟漂移。现实生活中我们通常认为时间是平稳流逝,单调递增的,但在计算机中不是。计算机使用时钟硬件计时,通常是石英钟,计时精度有限,同时受机器温度影响。为了在一定程度上同步网络上多个机器之间的时间,通常使用 NTP 协议将本地设备的时间与专门的时间服务器对齐,这样做的一个直接结果是设备的本地时间可能会突然向前或向后跳跃。
分布式事务既然是分布式的系统,自然也有 NPC 问题。因为没有涉及时间戳,带来的困扰主要是 NP 。
TCC 的空补偿与悬挂
我们以分布式事务中的 TCC (如果是对 TCC 还不了解的同学,可以参考这篇文章,分布式事务最经典的七种解决方案,了解分布式事务相关的基础知识)作为例子,看看 NP 带来的影响。
一般情况下,一个 TCC 回滚时的执行顺序是,先执行完 Try ,再执行 Cancel ,但是由于 N ,则有可能 Try 的网络延迟大,导致先执行 Cancel ,再执行 Try 。
这种情况就引入了分布式事务中的两个难题:
- 空补偿:Cancel 执行时,Try 未执行,事务分支的 Cancel 操作需要判断出 Try 未执行,这时需要忽略 Cancel 中的业务数据更新,直接返回
- 悬挂:Try 执行时,Cancel 已执行完成,事务分支的 Try 操作需要判断出 Cancel 一致性,这时需要忽略 Try 中的业务数据更新,直接返回
分布式事务还有一类需要处理的常见问题,就是重复请求,业务需要做幂等处理。因为空补偿、悬挂、重复请求都跟 NP 有关,我们把他们统称为子事务乱序问题。在业务处理中,需要小心处理好这三种问题,否则会出现错误数据。
现有方案的问题
我们看到开源项目https://github.com/yedf/dtm之外,包括各云厂商,各开源项目,他们给出的业务实现建议大多类似如下:
- 空补偿:“针对该问题,在服务设计时,需要允许空补偿,即在没有找到要补偿的业务主键时,返回补偿成功,并将原业务主键记录下来,标记该业务流水已补偿成功。”
- 防悬挂:“需要检查当前业务主键是否已经在空补偿记录下来的业务主键中存在,如果存在则要拒绝执行该笔服务,以免造成数据不一致。”
上述的这种实现,能够在大部分情况下正常运行,但是上述做法中的“先查后改”在并发情况下是容易掉坑里的,我们分析一下如下场景:
- 正常执行顺序下,Try 执行时,在查完没有空补偿记录的业务主键之后,事务提交之前,如果发生了进程暂停 P ,或者事务内部进行网络请求出现了拥塞,导致本地事务等待较久
- 全局事务超时后,Cancel 执行,因为没有查到要补偿的业务主键,因此判断是空补偿,直接返回
- Try 的进程暂停结束,最后提交本地事务
- 全局事务回滚完成后,Try 分支的业务操作没有被回滚,产生了悬挂
事实上,NPC 里的 P 和 C ,以及 P 和 C 的组合,有很多种的场景,都可以导致上述竞态情况,就不一一赘述了。
虽然这种情况发生的概率不高,但是在金融领域,一旦涉及金钱账目,那么带来的影响可能是巨大的。
PS:幂等控制如果也采用“先查再改”,也是一样很容易出现类似的问题。解决这一类问题的关键点是要利用唯一索引,“以改代查”来避免竞态条件。
正确姿势
下面我们来详解 yedf/dtm 是如何解决这个问题的。
dtm 首创了子事务屏障技术,用于同时解决空补偿、防悬挂、幂等这三个问题,对于 TCC 事务,他的详细工作过程如下:
- 在本地数据库中创建好子事务屏障表 dtm_barrier.barrier ,唯一索引为 gid-branchid-branchop
- 对于 Try 、Confirm 、Cancel 操作,insert ignore 一条记录 gid-branchid-try|confirm|cancel ,如果影响行数为 0 (重复请求、悬挂),直接提交返回
- 对于 Cancel 操作额外再 insert ingore 一条记录 gid-branchid-try ,如果影响行数为 1 (空补偿),直接提交返回
- 执行业务逻辑并提交返回,如果业务发生错误则回滚
假如 Try 和 Cancel 的执行时间没有重叠,那么读者容易分析出上述过程能够解决空补偿和悬挂问题。如果出现了 Try 和 Cancel 执行时间重叠的情况,我们看看会发生什么。
假设 Try 和 Cancel 并发执行,Cancel 和 Try 都会插入同一条记录 gid-branchid-try ,由于唯一索引冲突,那么两个操作中只有一个能够成功,而另一个则会等持有锁的事务完成后返回。
- 情况 1 ,Try 插入 gid-branchid-try 失败,Cancel 操作插入 gid-branchid-try 成功,此时就是典型的空补偿和悬挂场景,按照子事务屏障算法,Try 和 Cancel 都会直接返回
- 情况 2 ,Try 插入 gid-branchid-try 成功,Cancel 操作插入 gid-branchid-try 失败,按照上述子事务屏障算法,会正常执行业务,而且业务执行的顺序是 Try 在 Cancel 前
- 情况 3 ,Try 和 Cancel 的操作在重叠期间又遇见宕机等情况,那么至少 Cancel 会被 dtm 重试,那么最终会走到情况 1 或 2 。
综上各种情况的详细论述,子事务屏障能够在各种 NP 情况下,保证最终结果的正确性。
事实上,子事务屏障有大量优点,包括:
- 两个 insert 判断解决空补偿、防悬挂、幂等这三个问题,比其他方案的三种情况分别判断,逻辑复杂度大幅降低
- dtm 的子事务屏障是 SDK 层解决这三个问题,业务完全不需要关心
- 性能高,对于正常完成的事务(一般失败的事务不超过 1%),子事务屏障的额外开销是每个分支操作一个 SQL ,比其他方案代价更小。
上述的理论与分析过程也同样适用于 SAGA 分布式事务。dtm 里面的子事务屏障同时支持了 TCC 和 SAGA 两种事务模式。
完整的解决方案
DTM 是一款 golang 开发的分布式事务管理器,解决了跨数据库、跨服务、跨语言栈更新数据的一致性问题。
下面是 dtm 和阿里开源的 seata 的主要特性对比:
| 特性 | DTM | SEATA | 备注 |
|---|---|---|---|
| 支持语言 | Go 、Java 、python 、php 、c#... | Java | dtm 可轻松接入一门新语言 |
| 异常处理 | 子事务屏障自动处理 | 手动处理 | dtm 解决了幂等、悬挂、空补偿 |
| TCC 事务 | ✓ | ✓ | |
| XA 事务 | ✓ | ✓ | |
| AT 事务 | 建议使用 XA | ✓ | AT 与 XA 类似,性能更好,但有脏回滚 |
| SAGA 事务 | 支持并发 | 状态机模式 | |
| 事务消息 | ✓ | ✗ | dtm 提供类似 rocketmq 的事务消息 |
| 单服务多数据源 | ✓ | ✗ | |
| 通信协议 | HTTP 、gRPC | dubbo 等协议 | dtm 对云原生更加友好 |
如果您的语言栈包含了 Java 之外的语言,那么 dtm 是您的首选。如果您的语言栈是 Java ,您也可以选择接入 dtm ,使用子事务屏障技术,简化您的业务编写,可以参考用 Java 轻松完成一个 TCC 分布式事务,自动处理空补偿、悬挂、幂等。
如果您想要学习分布式事务相关的知识,dtm 的文档备受好评,能够让读者快速入门分布式事务,理论结合实践,让读者逐步深入。
欢迎大家访问https://github.com/yedf/dtm,欢迎 Issue 、PR 、Star