线程池开启多线程带来的@Transactional失效

735 阅读4分钟

一、问题复现

  批量插入时,使用多线程对插入数据实现分批插入,在service层使用@Transactional注解,对应方法中线程池中开辟的子线程抛出异常时,没有回滚事务。

二、原因分析

  1. 事务管理范围不正确:@Transactional注解仅对当前方法有效,如果在方法内创建新的线程或使用线程池等异步操作,该方法之外的代码将无法受到事务的管理。因此,在使用多线程进行批量操作时,需要确保整个批量操作处于同一事务管理范围内。
  2. Spring事务和Java线程池机制的互动问题:在使用ThreadPoolExecutor进行批量操作时,线程池中的线程和Spring管理的事务并不是同一个线程,这可能会导致事务管理器感知不到线程中的异常,从而导致事务未能回滚。

三、解决办法

  弃用注解样事务,改为手动管理事务。

  复制代码

 1 SqlSession sqlSession = SpringContextUtils.getBean(SqlSessionTemplate.class).getSqlSessionFactory()
 2         .openSession();
 3 Connection connection = sqlSession.getConnection();
 4 OfflineExpressRecordExtMapper extMapper = sqlSession.getMapper(OfflineExpressRecordExtMapper.class);
 5 
 6 // 批量插入
 7 
 8 int taskCount = (int) Math.ceil((double) beanList.size() / THREAD_HANDLE);
 9 ThreadPoolExecutor executor = SpringContextUtils
10         .getBean("offlineExpressRecordThreadPoolExecutor", ThreadPoolExecutor.class);
11 try {
12     connection.setAutoCommit(false);
13     ArrayList<Future<?>> futures = new ArrayList<>();
14     for (int i = 0; i < taskCount; i++) {
15         int start = i * THREAD_HANDLE;
16         int end = (i + 1) * THREAD_HANDLE > beanList.size() ? beanList.size() : (i + 1) * THREAD_HANDLE;
17         List<OfflineExpressRecord> threadHandleList = beanList.subList(start, end);
18         Future<?> task = executor.submit(() -> extMapper.saveBatch(threadHandleList));
19         futures.add(task);
20     }
21     // 等待插入完成,检验异常
22     for (Future<?> future : futures) {
23         future.get();
24     }
25     connection.commit();
26 } catch (Exception e) {
27     log.error("批量导入存储数据过程中出现异常", e);
28     connection.rollback();
29     throw e;
30 } finally {
31     connection.close();
32 }

复制代码

四 、优化思路

  1. 优化导入时机

初时,是将所有数据导入到一个list中,然后使用线程池分批导入,存在内存泄漏以及效率相对较慢的风险。可以优化为异步导入,读取数据到list中时,去判断list中数据的数量,达到临界值后,在线程池中开启一个新的线程直接去执行插入(同样要使用手动管理事务)。即提高了插入的效率(读取数据时即可进行插入),也避免了内存泄漏的风险(list中数据最多只有临界值的数量)

2.存在潜在bug

(1). 内部状态SqlSession 实现中包含了许多与当前数据库连接和事务相关的内部状态。这些状态是特定于当前线程的,因此在多个线程之间共享同一个 SqlSession 实例会导致状态混乱和线程安全问题。

(2). 数据库连接SqlSession 通常持有一个数据库连接。如果多个线程共享同一个 SqlSession 实例,就会出现竞争条件,影响数据库操作的正确性和性能。

因此,正确的做法时是让每个线程独享一个SqlSession,并使用一个线程之间共享的变量去标记是否需要回滚,如使用AtomicBoolean(rollbackFlag)


使用AtomicBoolean的原因:AtomicBoolean类内部值存储变量使用了volatile做修饰,确保了变量修改在线程间的及时可见,变量操作使用了CAS,确保变量操作的原子性。

CAS是乐观锁的原理,通过对比操作前的旧值和即将操作时变量的值,如果值改变则表示有其他线程在操作变量,将再次进行操作尝试。CAS有ABA问题,不过对本次测试不影响。

执行过程中,当一个线程出现异常,设置rollbackFlag为true,提交事务时,根据rollbackFlag对子线程的事务判断commit or rollback,未执行完的线程直接在逻辑中直接return取消执行即可。

  1. 线程死锁风险

    当请求批量插入的风险比较高时,可能会出现数据库死锁的情况

    例如100万数据处理,每个线程处理1000数据量,则需要请求1000个线程。在耗尽线程池中有限的线程资源后,未请求到资源的线程请求进入等待队列,等待线程释放。考虑事务机制,需要在所有线程事务都结束后才结束事务等待,释放线程资源。此处造成死锁。

    可能的解决方案:

    (1).限制队列的等待数量,将线程池的等待队列数量设置到一个比较理想的值,减少出现死锁的可能性,引入超时机制,对每次批量插入设置插入实际,当获取不到数据库资源时,批量回滚所有事务释放资源

    (2)接口限流,根据服务器、数据库资源配置,结合nacos及滑动窗口对接口实现动态限流,避免不同请求之间的子线程出现死锁的情况