最近组内同学遇到了一个场景(先不说这算不算分布式事务),逻辑精简如下:
- 本地事务1(数据源1)
- 本地事务2(数据源2)
- RPC(非事务操作)
有几个细节点:
- 本地事务 1、2 和 RPC 调用之间没有数据依赖 ;
- 本地事务 1 和 2 是两个不同的数据源;
- RPC 调用失败了需要让本地事务 1 和本地事务 2 回滚;
- 要保证本地事务 1、2 的事务一致性(要么都成功要么都失败)
在解决这个问题之前,先看下几个问题。
这个场景算分布式事务吗?
那么什么是分布式事务呢,在网上找到这么一个概念;
分布式事务是指事务的参与者、支持事务的服务器、资源服务器以及事务管理器分别位于不同的分布式系统的不同节点之上。
这么看的话现在这个场景不属于分布式事务。但换句话说,是否属于分布式事务这个重要吗,其实一点都不重要,我们只需要关注现在的问题是啥,现在的问题说白了就是本地事务 1 和本地事务 2 没办法用 @Transactional
这个注解直接保证原子性。
看一个例子:
/**
* @author Dongguabai
* @description
* @date 2021-09-26 02:51
*/
@Service
public class TestService {
@Autowired
private Demo1T1Mapper demo1T1Mapper;
@Autowired
private Spv1T1Mapper spv1T1Mapper;
/**
* 验证一个@Transactional下,两个数据源操作
*/
@Transactional
public Object test1(String i) {
spv1T1Mapper.insertId2(Integer.valueOf(i));
demo1T1Mapper.insertId(Integer.valueOf(i));
int a = 1/0;
return "OK";
}
}
这里 spv1T1Mapper
和 demo1T1Mapper
分别对应不同的数据源,整个被包在了一个 @Transactional
里面。接下来执行一下,看是 spvi 回滚还是 demo1 回滚。
最终执行结果是 spvi 正常提交,demo1 回滚了。
在《一次群聊“事件”引发的对 @Transactional 和 MyBatis 的思考》中有过这么一个结论:
加了 @Transactional
注解,就只有一次连接,共用一个 SqlSession
;不加事务,多次连接,生成多个 SqlSession
。
但是现在这个场景比较特殊,这里的 spv1T1Mapper
和 demo1T1Mapper
对应的是不同的数据源,连接肯定是不同的,即 SqlSessionFactory
是不同的,所以 SqlSession
是无法共用的,这就引发了数据事务问题。
我的解决思路
首先这个问题是传统的“单机事务”无法解决的,先不纠结是否是分布式事务,但是分布式事务中的一些常用的解决思路我们也是可以学习的。
- RPC 调用放在最前面
要注意这个场景有一个特点,最后会 RPC 非事务调用一下,如果 RPC 失败了,两个事务都回滚。因为 RPC 是非事务调用,那么是不是可以把 RPC 调用放在整个事务操作的最前面,RPC 成功了,再走后面的本地事务(即使本地事务执行失败了,也不需要 RPC 那边回滚)
- XA
使用 XA 这个就很简单了,它就是专门解决单机多数据源场景的。但是根据一些经验,XA 由于强依赖数据库资源,容易有一些难以预知的 BUG 出现。
- 基于 Spring 编程式事务实现类 2PC
通过 Spring 编程式事务来实现,先分别执行事务 1、事务 2,但是不提交,事务 2 执行成功,那么事务 1、事务 2 都提交,否则都会滚。伪代码如下:
method(){
RPC;
执行事务1不提交;
try{
执行事务2不提交;
}catch(){
事务1回滚;
事务2回滚;
}
事务1提交;
事务2提交;
}
实现细节类似《关于多个事务并发执行的一个问题》中的实现,这里就不过多赘述了。
当然这个方案也有弊端,比如如果事务 2 执行的过慢,会造成数据库连接卡住;再比如事务 1 提交之后突然系统宕机了,这时候也会出现事务不一致的情况。
- 类 TCC
伪代码如下:
method(){
RPC;
执行事务1提交;
try{
执行事务2提交;
}catch(){
事务1反冲;
}
}
可能有同学会说你事务 1 都执行提交了,哪里还算 TCC 中的 Try(尝试) 啊。其实还是我个人的一个观点,不要形成思维定式,再者根据之前的对几个基于 TCC 实现的开源框架来看,Try 是必须的,但是 Confirm 和 Cancel 是非必要的,Try 的具体实现完全我们可以根据自己的方案来定。
当然这个方案也有缺点,比如在执行事务 2 的过程中,查询了事务 1 相关的数据,由于事务 1 已经提交了,所以这时候查询到的数据可能会有问题(因为此时事务 2 还未提交,且也有可能失败),同上面那个思路一样,也有宕机的问题,比如事务 1 刚提交完整个系统就宕机了,此时还是会出现数据不一致。
再或者可以这样:
method(){
RPC;
事务1预处理提交;
try{
事务2预处理提交;
}catch(){
事务1预处理反冲;
}
}
因为是预处理提交,那么就必须要增加一个 check 操作,比如防止预处理占用资源过长。这里就引出了一个“事务活动日志记录管理”的问题。它主要就是用来解决上面说的“宕机”导致的数据不一致的问题。
但是这个实现在这里我觉得是很复杂的,首先所谓的“事务活动日志记录管理”说白了就是把事务执行的结果等一下信息也存一份在数据库中,复杂点就在于现在本身是单机多数据源(spvi 和 demo1),我现在又要把事务活动信息存在表里面,那我存在哪个数据源里呢?比如存在 spvi,那可以保证对 spvi 数据源的事务操作可以和事务活动信息记录放在一个 MySQL 事务中,但是却无法保证事务活动信息记录(数据源 spvi)和对 demo1 数据源的事务操作的事务一致性。
之前一些 TCC 框架基本都是解决各个微服务都是单数据源的分布式事务场景,因为微服务是单数据源至少可以保证事务活动记录和本地事务操作是在一个 MySQL 事务中的。
综合来看,这个方案如果说不需要考虑宕机的情况,且其他缺陷可以接收的情况下,还是可以做的。
- 类 3PC + 本地消息表
这个方案是我综合了 3PC 和本地消息表的一个解决思路。有时候我们总是担心“本地事务执行失败了怎么办”,那就干脆执行本地事务前先 check 下,那就基本上可以保证后面事务执行是没问题的。伪代码如下:
method(){
RPC;
事务1检查(如果失败直接return);
事务2检查(如果失败直接return);
事务1执行提交;
try{
事务2执行提交;
}catch(){
事务1反冲;
}
}
这个就是反正一开始先把各个本地子事务检查一下,看到底行不行,都行的话,直接执行就行了,当然为了避免意外,事务 2 执行的时候还是会 catch
一下。
再变种一下,可以这样来:
method(){
RPC;
事务1检查(如果失败直接return);
事务2检查(如果失败直接return);
事务1执行+记录事务2将要执行事务日志 提交;
}
定时任务:
连续重试执行记录的“事务2将要执行”的操作;
这个特点是事务日志的记录和事务 1 的执行放在一个数据源中,执行事务 1 的同时也记录一下接下来的“派生”子事务,也就是这里的事务 2 要怎么执行,记录在一张表里面,然后定时任务去触发执行。
最后
上文就是我这边的四个思路,当然其实远不止这四种方案,因为方案可以不停的变种结合,只要我们清楚各个方案的优缺点并且业务场景可以接受就 OK。
可能我的方案和思路也有各种各样的问题,欢迎大家一起留言探讨。还是那个观点,没有银弹,集百家之长融于一身才可以应对各种变化。
References
- https://wiki.mbalib.com/wiki/%E5%88%86%E5%B8%83%E5%BC%8F%E4%BA%8B%E5%8A%A1
- https://mp.weixin.qq.com/s?__biz=MzU1OTgyMDc3Mg==&mid=2247483776&idx=1&sn=4b280542a47d8a232e446662356a918a&chksm=fc103b0bcb67b21db36323426c11b579576fa286c14a4721573246d3311e99e29f61fddb16d6&token=1869831303&lang=zh_CN#rd
- https://mp.weixin.qq.com/s?__biz=MzU1OTgyMDc3Mg==&mid=2247484569&idx=1&sn=8d6db2061a7420f0b70abf0b9ccac34a&chksm=fc103e12cb67b704ee313f4dad34c2ad88c7fd6a71cf5523f31c74bff93b7a11ea31e4cac5c5&token=217003045&lang=zh_CN#rd