Ensuring Transaction Consistency in Multi‑Threaded Spring Applications Using CompletableFuture and Programmatic Transactions

This article explains how to parallelize business steps with CompletableFuture, why Spring's @Async and @Transactional annotations cannot guarantee transaction consistency across threads, and presents a programmatic transaction approach—including copying Spring's transaction resources between threads—to achieve reliable multi‑threaded commits or rollbacks.

Architect
Architect
Architect
Ensuring Transaction Consistency in Multi‑Threaded Spring Applications Using CompletableFuture and Programmatic Transactions

The author starts by describing a real‑world scenario where two business steps—deleting authorities of a module and deleting its sub‑modules—must run in parallel and, after both succeed, the module itself should be removed and the whole transaction committed.

Using CompletableFuture.runAsync, the parallel execution is implemented as follows:

public void removeAuthorityModuleSeq(Integer authorityModuleId, IAuthorityService iAuthorityService, IRoleAuthorityService iRoleAuthorityService) {
    CompletableFuture.runAsync(() -> {
        CompletableFuture<Void> future1 = CompletableFuture.runAsync(() ->
            deleteAuthoritiesOfCurrentAuthorityModule(authorityModuleId, iAuthorityService, iRoleAuthorityService), executor);
        CompletableFuture<Void> future2 = CompletableFuture.runAsync(() ->
            deleteSonAuthorityModuleUnderCurrentAuthorityModule(authorityModuleId, iAuthorityService, iRoleAuthorityService), executor);
        CompletableFuture.allOf(future1, future2).thenRun(() -> removeById(authorityModuleId));
    }, executor);
}

While the asynchronous logic works, the author discovers that Spring's declarative transaction management (@Transactional) cannot handle the commit because the transaction resources are bound to the worker thread, not the main thread that later invokes commit. This results in the exception "No value for key [HikariDataSource (HikariPool-1)] bound to thread [main]".

The article then reviews Spring's transaction infrastructure: TransactionDefinition, PlatformTransactionManager, TransactionStatus, and the six ThreadLocal fields managed by TransactionSynchronizationManager. It explains that transaction creation binds resources to the current thread, and after the async task finishes, the main thread cannot locate those resources.

To solve the problem, the author proposes copying the transaction resources from the async thread back to the main thread before committing or rolling back. The utility class TransactionResource captures the resource map, synchronizations, transaction name, read‑only flag, isolation level, and active flag:

public static TransactionResource copyTransactionResource() {
    return TransactionResource.builder()
        .resources(TransactionSynchronizationManager.getResourceMap())
        .synchronizations(new LinkedHashSet<>())
        .currentTransactionName(TransactionSynchronizationManager.getCurrentTransactionName())
        .currentTransactionReadOnly(TransactionSynchronizationManager.isCurrentTransactionReadOnly())
        .currentTransactionIsolationLevel(TransactionSynchronizationManager.getCurrentTransactionIsolationLevel())
        .actualTransactionActive(TransactionSynchronizationManager.isActualTransactionActive())
        .build();
}

public void autoWiredTransactionResource() {
    resources.forEach(TransactionSynchronizationManager::bindResource);
    TransactionSynchronizationManager.initSynchronization();
    TransactionSynchronizationManager.setActualTransactionActive(actualTransactionActive);
    TransactionSynchronizationManager.setCurrentTransactionName(currentTransactionName);
    TransactionSynchronizationManager.setCurrentTransactionIsolationLevel(currentTransactionIsolationLevel);
    TransactionSynchronizationManager.setCurrentTransactionReadOnly(currentTransactionReadOnly);
}

The revised runAsyncButWaitUntilAllDown method creates a new transaction for each task, copies the transaction resources, runs the task, and after all futures complete it either commits or rolls back each transaction after re‑binding the resources to the main thread.

public void runAsyncButWaitUntilAllDown(List<Runnable> tasks, Executor executor) {
    DataSourceTransactionManager transactionManager = getTransactionManager();
    AtomicBoolean ex = new AtomicBoolean();
    List<CompletableFuture<?>> taskFutureList = new ArrayList<>(tasks.size());
    List<TransactionStatus> transactionStatusList = new ArrayList<>(tasks.size());
    List<TransactionResource> transactionResources = new ArrayList<>(tasks.size());

    tasks.forEach(task -> {
        taskFutureList.add(CompletableFuture.runAsync(() -> {
            try {
                transactionStatusList.add(openNewTransaction(transactionManager));
                transactionResources.add(TransactionResource.copyTransactionResource());
                task.run();
            } catch (Throwable t) {
                t.printStackTrace();
                ex.set(true);
                taskFutureList.forEach(f -> f.cancel(true));
            }
        }, executor));
    });

    try { CompletableFuture.allOf(taskFutureList.toArray(new CompletableFuture[0])).get(); } catch (Exception e) { e.printStackTrace(); }

    if (ex.get()) {
        System.out.println("发生异常,全部事务回滚");
        for (int i = 0; i < tasks.size(); i++) {
            transactionResources.get(i).autoWiredTransactionResource();
            transactionManager.rollback(transactionStatusList.get(i));
            transactionResources.get(i).removeTransactionResource();
        }
    } else {
        System.out.println("全部事务正常提交");
        for (int i = 0; i < tasks.size(); i++) {
            transactionResources.get(i).autoWiredTransactionResource();
            transactionManager.commit(transactionStatusList.get(i));
            transactionResources.get(i).removeTransactionResource();
        }
    }
}

Tests using SpringBootTest confirm that when one task throws an exception, all transactions are rolled back, leaving the database unchanged; when no exception occurs, all changes are committed.

Finally, the author notes that this is only one possible solution; alternatives include using plain JDBC transaction control or distributed transaction frameworks, and emphasizes the importance of understanding Spring's low‑level transaction mechanics beyond the convenience of @Transactional.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

JavatransactionconcurrencySpringCompletableFutureProgrammaticTransaction
Architect
Written by

Architect

Professional architect sharing high‑quality architecture insights. Topics include high‑availability, high‑performance, high‑stability architectures, big data, machine learning, Java, system and distributed architecture, AI, and practical large‑scale architecture case studies. Open to ideas‑driven architects who enjoy sharing and learning.

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.