Ensuring Transaction Rollback in Multi‑Threaded Java Services

This article explains why @Transactional fails in multi‑threaded Spring services, provides a utility for splitting large data sets, shows thread‑pool configuration, demonstrates a failing transaction scenario, and presents a solution using manual MyBatis sqlSession commit to guarantee atomic rollback across all threads.

Architect's Tech Stack
Architect's Tech Stack
Architect's Tech Stack
Ensuring Transaction Rollback in Multi‑Threaded Java Services

When inserting a massive amount of data, developers often split the workload across multiple threads to improve performance, but if any thread fails the whole operation should roll back. In Spring, the @Transactional annotation does not work across threads, so changes made by the main thread (such as a delete) are not rolled back when a child thread throws an exception.

Common utility class

/**
 * Average split list method.
 * @param source the original list
 * @param n number of sub‑lists
 * @param <T> element type
 * @return list of sub‑lists
 */
public static <T> List<List<T>> averageAssign(List<T> source, int n) {
    List<List<T>> result = new ArrayList<>();
    int remainder = source.size() % n;
    int number = source.size() / n;
    int offset = 0; // offset
    for (int i = 0; i < n; i++) {
        List<T> value = null;
        if (remainder > 0) {
            value = source.subList(i * number + offset, (i + 1) * number + offset + 1);
            remainder--;
            offset++;
        } else {
            value = source.subList(i * number + offset, (i + 1) * number + offset);
        }
        result.add(value);
    }
    return result;
}

Thread‑pool configuration

/** Thread pool configuration */
public class ExecutorConfig {
    private static int maxPoolSize = Runtime.getRuntime().availableProcessors();
    private volatile static ExecutorService executorService;

    public static synchronized ExecutorService getThreadPool() {
        if (executorService == null) {
            executorService = newThreadPool();
        }
        return executorService;
    }

    private static ExecutorService newThreadPool() {
        int queueSize = 500;
        int corePool = Math.min(5, maxPoolSize);
        return new ThreadPoolExecutor(corePool, maxPoolSize, 10000L, TimeUnit.MILLISECONDS,
                new LinkedBlockingQueue<>(queueSize), new ThreadPoolExecutor.AbortPolicy());
    }
}

Failed transaction example

@Transactional(rollbackFor = Exception.class)
public void saveThread(List<EmployeeDO> employeeDOList) {
    // delete first – will not roll back if child thread fails
    this.getBaseMapper().delete(null);
    ExecutorService service = ExecutorConfig.getThreadPool();
    List<List<EmployeeDO>> lists = averageAssign(employeeDOList, 2);
    Thread[] threadArray = new Thread[lists.size()];
    CountDownLatch countDownLatch = new CountDownLatch(lists.size());
    AtomicBoolean atomicBoolean = new AtomicBoolean(false);
    for (int i = 0; i < lists.size(); i++) {
        List<EmployeeDO> list = lists.get(i);
        threadArray[i] = new Thread(() -> {
            try {
                if (!atomicBoolean.get()) {
                    throw new ServiceException("001", "出现异常");
                }
                this.saveBatch(list);
            } finally {
                countDownLatch.countDown();
            }
        });
    }
    for (int i = 0; i < lists.size(); i++) {
        service.execute(threadArray[i]);
    }
    countDownLatch.await();
    System.out.println("添加完毕");
}

Test results show that when a child thread throws an exception, the delete operation performed by the main thread is not rolled back, confirming that @Transactional does not propagate across threads.

Solution: manual commit with sqlSession

@Resource
SqlContext sqlContext;

@Override
public void saveThread(List<EmployeeDO> employeeDOList) throws SQLException {
    SqlSession sqlSession = sqlContext.getSqlSession();
    Connection connection = sqlSession.getConnection();
    try {
        connection.setAutoCommit(false);
        EmployeeMapper employeeMapper = sqlSession.getMapper(EmployeeMapper.class);
        employeeMapper.delete(null);
        ExecutorService service = ExecutorConfig.getThreadPool();
        List<Callable<Integer>> callableList = new ArrayList<>();
        List<List<EmployeeDO>> lists = averageAssign(employeeDOList, 5);
        AtomicBoolean atomicBoolean = new AtomicBoolean(true);
        for (int i = 0; i < lists.size(); i++) {
            if (i == lists.size() - 1) {
                atomicBoolean.set(false);
            }
            List<EmployeeDO> list = lists.get(i);
            Callable<Integer> callable = () -> {
                if (!atomicBoolean.get()) {
                    throw new ServiceException("001", "出现异常");
                }
                return employeeMapper.saveBatch(list);
            };
            callableList.add(callable);
        }
        List<Future<Integer>> futures = service.invokeAll(callableList);
        for (Future<Integer> future : futures) {
            if (future.get() <= 0) {
                connection.rollback();
                return;
            }
        }
        connection.commit();
        System.out.println("添加完毕");
    } catch (Exception e) {
        connection.rollback();
        log.info("error", e);
        throw new ServiceException("002", "出现异常");
    } finally {
        connection.close();
    }
}

In this approach, a MyBatis SqlSession is obtained, auto‑commit is disabled, and all database operations (delete and batch inserts) are executed within the same session. After all child threads finish, the code checks each Future; if any insert fails, connection.rollback() is called, otherwise connection.commit() finalizes the transaction. This guarantees that the delete operation is rolled back together with the inserts when an error occurs.

图片
图片
img_1
img_1
img_2
img_2
img_3
img_3

Test results after applying the manual commit method show that when an exception occurs, the delete operation is rolled back and no data remains in the database, confirming successful transaction management.

img_4
img_4
img_5
img_5
img_6
img_6
img_7
img_7
img_8
img_8

In the successful scenario, the delete operation is undone and the batch insert succeeds, demonstrating that the manual sqlSession transaction control works as intended.

JavatransactionSpringThreadPoolMyBatis
Architect's Tech Stack
Written by

Architect's Tech Stack

Java backend, microservices, distributed systems, containerized programming, and more.

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.