6 Ways to Overcome the Limits of @Transactional in Spring
The article examines five scenarios where Spring's @Transactional annotation falls short—stock shortage, MQ messaging, batch processing, logging, and isolation/timeout settings—and demonstrates six practical techniques, including programmatic transactions, parameter tuning, transaction synchronizers, event listeners, manual transaction control, and propagation strategies, all backed by runnable demo code.
Why @Transactional Is Not Enough
In a Mall order service a single method touched six tables and performed fourteen steps inside one transaction without failing. The following practical problems were identified:
Stock shortage – need to keep the order record as “awaiting restock” without rolling back.
MQ messaging – a message sent inside the transaction may be delivered even when the transaction rolls back.
Batch operations – when processing 100 orders a single failure rolls back the whole batch, but successful orders should persist.
Logging – business failures should still be logged, yet a rollback removes the log.
Isolation/timeout – unclear how to configure these parameters correctly.
The article demonstrates six concrete solutions.
1. Programmatic Transaction (TransactionTemplate)
Using TransactionTemplate allows dynamic control over rollback based on business logic versus system exceptions.
public OrderResult createOrder(OrderParam param) {
return transactionTemplate.execute(status -> {
try {
// 1. create order
Order order = buildOrder(param);
orderMapper.insert(order);
// 2. create order items
List<OrderItem> items = buildOrderItems(order);
orderItemMapper.batchInsert(items);
// 3. lock stock
lockStock(param.getItems());
// 4. risk check
RiskCheckResult riskResult = riskService.check(order);
if (!riskResult.isPass()) {
// business failure – keep order, do NOT roll back
order.setStatus(OrderStatus.WAIT_AUDIT);
order.setNote("Risk check failed: " + riskResult.getReason());
orderMapper.updateById(order);
return OrderResult.fail("Order needs manual review");
}
// risk passed – normal commit
return OrderResult.success(order.getId());
} catch (RiskServiceException e) {
// system exception – must roll back
log.error("Risk service error", e);
status.setRollbackOnly();
return OrderResult.error("System error, please retry");
} catch (Exception e) {
status.setRollbackOnly();
return OrderResult.error(e.getMessage());
}
});
}Key difference: the code can distinguish a business failure (e.g., risk check not passed) from a system exception and decide whether to roll back.
2. @Transactional Parameters (isolation, timeout, rollbackFor)
@Transactional(
isolation = Isolation.REPEATABLE_READ,
propagation = Propagation.REQUIRED,
timeout = 30,
rollbackFor = Exception.class)
public int createProduct(ProductParam param) {
// insert into 8 tables …
}Important notes:
MySQL default isolation is REPEATABLE_READ; PostgreSQL default is READ_COMMITTED. Not specifying isolation can cause phantom reads after a DB switch.
Explicitly set isolation to avoid environment‑dependent behavior. timeout prevents long‑running transactions from locking tables.
Spring rolls back only on RuntimeException by default; adding rollbackFor = Exception.class ensures checked exceptions also trigger rollback.
3. Transaction Synchronizer (afterCommit)
Sending an MQ message after the transaction commits guarantees consistency.
@Transactional
public void createOrder() {
orderMapper.insert(order);
TransactionSynchronizationManager.registerSynchronization(new TransactionSynchronization() {
@Override
public void afterCommit() {
mqSender.send("order.cancel.delay", order.getId());
log.info("MQ message sent");
}
@Override
public void afterCompletion(int status) {
if (status == STATUS_ROLLED_BACK) {
log.info("Transaction rolled back, MQ not sent");
}
}
// other callbacks (beforeCommit, beforeCompletion) omitted for brevity
});
}The four lifecycle callbacks are beforeCommit, beforeCompletion, afterCommit, and afterCompletion. Only afterCommit should contain side‑effects such as MQ, cache eviction, or external service calls.
4. Transaction Event Listener
@Getter @AllArgsConstructor
public class OrderCreatedEvent {
private String orderSn;
private Long memberId;
private BigDecimal amount;
}
@Service
public class OrderService {
@Autowired
private ApplicationEventPublisher eventPublisher;
@Transactional
public void createOrder(OrderParam param) {
orderMapper.insert(order);
OrderCreatedEvent event = new OrderCreatedEvent(order.getOrderSn(), order.getMemberId(), order.getTotalAmount());
eventPublisher.publishEvent(event);
log.info("Event published");
}
}
@Component
public class OrderEventListener {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderCreated(OrderCreatedEvent event) {
mqSender.send("order.cancel", event.getOrderSn());
logMapper.insert(log);
notifyService.send(event.getMemberId());
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailed(OrderCreatedEvent event) {
log.info("Order creation failed: {}", event.getOrderSn());
}
}Listeners annotated with @EventListener run immediately and cannot be rolled back, while @TransactionalEventListener(phase = AFTER_COMMIT) runs only when the transaction succeeds. The AFTER_ROLLBACK phase runs only on rollback.
5. Manual Transaction Control (PlatformTransactionManager)
@Service
public class OrderBatchService {
@Autowired
private PlatformTransactionManager transactionManager;
public BatchResult batchDelivery(List<Long> orderIds) {
List<Long> success = new ArrayList<>();
List<String> failed = new ArrayList<>();
for (Long orderId : orderIds) {
DefaultTransactionDefinition def = new DefaultTransactionDefinition();
TransactionStatus status = transactionManager.getTransaction(def);
try {
Order order = orderMapper.selectById(orderId);
order.setStatus(2); // shipped
orderMapper.updateById(order);
reduceStock(order);
transactionManager.commit(status);
success.add(orderId);
} catch (Exception e) {
transactionManager.rollback(status);
failed.add("Order" + orderId + ": " + e.getMessage());
}
}
return new BatchResult(success, failed);
}
}Custom transaction definitions can also set propagation = REQUIRES_NEW, a lower isolation level, and a timeout to suit high‑concurrency batch jobs.
6. Transaction Propagation (REQUIRED, REQUIRES_NEW, NESTED)
Different propagation behaviors solve distinct consistency requirements:
REQUIRED (default) – parent and child share the same transaction; any exception rolls back the whole batch. Suitable for “all‑or‑nothing” scenarios such as order + order items.
REQUIRES_NEW – child runs in an independent transaction; failures in the child do not affect the parent. Ideal for audit logs, message tables, or notifications that must persist even if the main flow fails.
NESTED – uses a savepoint inside the same physical transaction; the child can roll back to the savepoint while the parent continues. Works for optional features like gift creation or coupon issuance, provided the underlying DB supports savepoints (e.g., InnoDB).
Each propagation mode was verified with simple REST calls and console logs showing the exact order of callbacks.
Key Takeaways
Programmatic transaction control distinguishes business failures from system exceptions.
Explicit @Transactional parameters avoid hidden bugs when switching databases.
Use TransactionSynchronization or @TransactionalEventListener(AFTER_COMMIT) for side‑effects that must occur only after a successful commit.
Manual transaction management with PlatformTransactionManager enables per‑item commit/rollback in batch processing.
Select the appropriate propagation behavior ( REQUIRED, REQUIRES_NEW, NESTED) based on whether side‑effects need to be independent, optional, or tightly coupled with the main transaction.
Reference Implementation
The full source code, integration tests, and database initialization script are available at https://gitee.com/sh_wangwanbao/simple-transactional. The project can be cloned and run to observe the behavior of all six patterns.
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.
