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.
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.
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.
In the successful scenario, the delete operation is undone and the batch insert succeeds, demonstrating that the manual sqlSession transaction control works as intended.
Architect's Tech Stack
Java backend, microservices, distributed systems, containerized programming, and more.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.
