很多人在使用事务的时候,基本都是在方法上添加@Transactional(rollbackFor = Exception.class)注解就完事了。如果有的业务需要异步执行的话,也都是用的线程池来执行,但两者要是遇到一起了,那么遇到的问题可就没有那么简单了,而很多人都不知道其中的细节,生产上产生的问题也很多。
就比如 主线程异常了,子线程的数据却没有回滚。或者主线程和子线程中的数据都没有回滚,那到底什么时候会回滚,什么时候不会回滚呢?下面我们来详细的介绍
多线程操作数据库的问题
主线程中开启一个子线程,如果子线程出现异常的话,子线程会回滚吗?主线程会回滚吗?
案例:
运行代码
@Service
@Transactional
public class PayService implements IPayService {
@Autowired
private PayMapper payMapper;
@Autowired
private AccountMapper accountMapper;
private Executor executor = Executors.newSingleThreadExecutor();
@Override
public Integer testTransactionThread(Pay pay) {
int insert = payMapper.insert(pay);
Long id = pay.getId();
executor.execute(() -> {
Account account = new Account();
account.setId(id);
accountMapper.insert(account);
if (id == 2) {
throw new RuntimeException("模拟异常");
}
});
return insert;
}
}
执行结果结果:
Exception in thread "pool-2-thread-1" java.lang.RuntimeException: 模拟异常
at com.example.service.impl.PayService.lambda$testTransactionThread$0(PayService.java:45)
at java.util.concurrent.ThreadPoolExecutor.runWorker(ThreadPoolExecutor.java:1149)
at java.util.concurrent.ThreadPoolExecutor$Worker.run(ThreadPoolExecutor.java:624)
at java.lang.Thread.run(Thread.java:750)
结果发现主线程的添加pay和子线程的添加account都不会进行回滚
接下来详细介绍造成此问题主要是哪几方面造成
Spring的事务管理特点
TransactionSynchronizationManager
public abstract class TransactionSynchronizationManager {
// 线程私有事务资源
private static final ThreadLocal<Map<Object, Object>> resources =
new NamedThreadLocal<>("Transactional resources");
// 事务同步
private static final ThreadLocal<Set<TransactionSynchronization>> synchronizations =
new NamedThreadLocal<>("Transaction synchronizations");
// 当前事务的名称
private static final ThreadLocal<String> currentTransactionName =
new NamedThreadLocal<>("Current transaction name");
// 当前事务是否只读
private static final ThreadLocal<Boolean> currentTransactionReadOnly =
new NamedThreadLocal<>("Current transaction read-only status");
// 当前事务的隔离级别
private static final ThreadLocal<Integer> currentTransactionIsolationLevel =
new NamedThreadLocal<>("Current transaction isolation level");
// 实际事务是否激活
private static final ThreadLocal<Boolean> actualTransactionActive =
new NamedThreadLocal<>("Actual transaction active");
}
重要总结
● 可以看到TransactionSynchronizationManager中的关键属性其实都是用ThreadLocal来管理的,而ThreadLocal中的数据都是和线程绑定的
● 重点是resources这个变量,首先这是一个threadlocal的结果,枚举中的Map key类型为连接数据库的数据源dataSource,value类型为ConnectionHolder(可以理解为dataSource的一个connect连接)
● 当方法执行到spring的doBegin开启事务方法,会先从resources获取,以当前数据源dataSource为key,获取value也就是connect存不存在,如果不存在则从dataSource获取一个connect设置进去,如果存在则直接以当前的connect来使用
● 所以在spring事务管理的情况下,父子线程的数据源连接connect是不同的
也就是说父线程和子线程的数据库连接已经不是同一个了,子线程已经脱离了父线程的事务管理范围
Account account = new Account();
account.setId(id);
accountMapper.insert(account);
if (id == 2) {
throw new RuntimeException("模拟异常");
}
这段代码是子线程的执行逻辑,已经脱离了父线程事务的管理,而且直接执行mapper来添加数据,也就是子线程也没有自己的事务,所以即使抛出了异常,子线程也不会回滚
我们先来解决子线程回滚的问题,这里即使抛出异常还没有回滚就是因为子线程压根就没有事务,那怎么使用子线程又能有事务呢?其实很简单,直接用再用一个service对象来调用这个添加方法就可以了,因为事务本质还是切面,spirng在加载的时候,会扫描切面所在的类,接着对这些类增强也就是常说的代理类
在执行线程池的任务执行,service已经是代理增强类了,调用方法也是被事务增强的方法,所以就还是有事务的。既然我们知道思路,下面就来详细实现
解决思路
这里将添加account的逻辑都移动到accountService中,线程池直接调用accountService方法
@Service
@Transactional
public class PayService implements IPayService {
@Autowired
private PayMapper payMapper;
@Autowired
private AccountService accountService;
private Executor executor = Executors.newSingleThreadExecutor();
@Override
public Integer testTransactionThread(Pay pay) {
int insert = payMapper.insert(pay);
Long id = pay.getId();
executor.execute(() -> {
Account account = new Account();
account.setId(id);
accountService.insert(account);
});
return insert;
}
}
@Service
@Transactional
public class AccountService implements IAccountService {
@Autowired
private AccountMapper accountMapper;
@Override
public Integer insert(Account account) {
Integer insert = accountMapper.insert(account);
if (account.getId() == 2) {
throw new RuntimeException("模拟异常");
}
return insert;
}
}
这时accountService在执行insert方法时,就可以开启事务了。可以实现子线程中的account添加中出现异常是可以回滚的。但是我们还只是解决了子线程回滚的问题,父线程中的pay添加操作还是不能回滚的
原因是子线程抛出的异常后并不能被父线程所感知到,那么我们让父线程感应到异常不就可以了吗
解决思路
根据spring事务特点我们知道父子线程的数据源连接connect是不同的,但是我们可以通过线程池的特点来解决上述问题
解决子线程出现异常,让父线程回滚
线程池在执行submit时,会将异常放入futureTask中,可以利用这个特点来解决。
案例:
@Service
@Transactional
public class PayService implements IPayService {
@Autowired
private PayMapper payMapper;
@Autowired
private AccountService accountService;
private ExecutorService executor = Executors.newSingleThreadExecutor();
@Override
public Integer testTransactionThread(Pay pay) {
Long id = pay.getId();
Future<Integer> future = executor.submit(() -> {
Account account = new Account();
account.setId(id);
return accountService.insert(account);
});
int insert = payMapper.insert(pay);
try {
Integer integer = future.get();
} catch (InterruptedException e) {
throw new RuntimeException("子线程account执行中断异常");
} catch (ExecutionException e) {
throw new RuntimeException("子线程account执行异常");
}
return insert;
}
}
这样当子线程account操作出现异常时,父线程的异常是可以成功回滚的,但是future存在一个问题,就是当executor.submit中的任务没有执行完的话,future.get()是一直被阻塞住的,那这启不和同步执行没什么区别了吗?并且这还涉及到线程上下文的切换,效率还不如同步呢
所以说适合使用future的场景有两种
两个任务
存在两个逻辑A和B,这两个可以并行执行,不需要A执行完后再执行B,就可以使用future。比如A耗时1s,B耗时2s,执行的总耗时就是2s
当B执行出现异常后,A和B都可以回滚
多个任务
比如说存在4个任务逻辑 A B C D,常规执行的话 A -> B -> C -> D,如果进行优化的话,A是主线程, B C D 可以放在线程中异步执行。
统计耗时
假设 A任务的耗时 1s,B任务的耗时 2s,C任务的耗时 3s,D任务的耗时 4s
- 常规执行的耗时: A任务的耗时 + B任务的耗时 + C任务的耗时 + D任务的耗时 为 1 + 2 + 3 + 4 = 10s
- 使用future的耗时:A任务的耗时 + B/C/D任务的耗时 (取决于B C D哪个任务时间长) 为 1 + 4 = 5s
而B C D 的任务其中有一个异常的话,主线程都会回滚。但是要注意,并不能实现所有子线程回滚比如B出现异常,A和B可以回滚,C和D是不能回滚的
上文介绍了Spirng的事务管理器是ThreadLocal范围,也就是A B C D 本质都是使用各自自己的事务,所以不能一次都回滚