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