12 Spring Transaction Failure Scenarios: Why 90% of Developers Fall for #8
This article systematically breaks down 12 common scenarios where Spring @Transactional fails, from non-public methods and self-invocation to exception swallowing, wrong propagation, controller-layer annotations, long transactions, and misconfigured transaction managers. Each scenario includes code examples, root-cause analysis, and practical fixes.
I. How Spring Transactions Work Under the Hood
Spring transactions are fundamentally AOP proxies. The call chain is:
Business method → TransactionInterceptor.invoke() → getTransaction() → PlatformTransactionManager.getTransaction() → Create Connection → Set autoCommit=false → Execute SQL → commit/rollbackTransaction failure means this chain breaks at some link. The article dissects 12 such break points.
II. 12 Failure Scenarios
Scenario 1: Method Is Not public
@Transactional
private void transfer(Long from, Long to, BigDecimal amount) {
// ❌ private/protected/package-private all fail
// Spring AOP uses JDK dynamic proxy by default, only proxies public methods
// Even with CGLIB (proxyTargetClass=true), private methods cannot be intercepted
}Fix: Make the method public, or enable CGLIB with @EnableTransactionManagement(proxyTargetClass=true).
Scenario 2: Self-Invocation (Calling Another Method in the Same Class)
@Service
public class OrderService {
public void createOrder() {
// ✅ Transaction works here
doCreateOrder(); // ❌ Self-call bypasses proxy, transaction fails
}
@Transactional
public void doCreateOrder() { ... }
}Why: The call goes directly to the target object, not through the proxy.
Fixes:
Split into two separate services
Inject self ( @Autowired OrderService self) and call via self.doCreateOrder() Use AopContext.currentProxy() to get the proxy object
@Service
public class OrderService {
public void createOrder() {
((OrderService) AopContext.currentProxy()).doCreateOrder();
}
@Transactional
public void doCreateOrder() { ... }
}Scenario 3: Exception Caught and Swallowed
@Transactional
public void transfer(Long from, Long to, BigDecimal amount) {
try {
accountMapper.debit(from, amount);
accountMapper.credit(to, amount);
} catch (Exception e) {
log.error("Transfer failed", e);
// ❌ Swallowing exception prevents transaction manager from seeing it → no rollback
}
}Fix: Either don't catch (let exception propagate) or manually trigger rollback:
@Transactional(rollbackFor = Exception.class)
public void transfer(...) {
try {
...
} catch (Exception e) {
log.error("Transfer failed", e);
TransactionAspectSupport.currentTransactionStatus().setRollbackOnly();
throw e;
}
}Scenario 4: Wrong Exception Type (Default Rolls Back Only RuntimeException)
@Transactional // Default rollbackFor = {RuntimeException, Error}
public void transfer(...) throws IOException {
// IOException is checked → won't trigger rollback
throw new IOException("Connection failed");
}Fix: Explicitly specify:
@Transactional(rollbackFor = Exception.class)
// or
@Transactional(rollbackFor = {IOException.class, SQLException.class})Scenario 5: Database Engine Doesn't Support Transactions
-- Table uses MyISAM engine, no transaction support
CREATE TABLE t_account (
id BIGINT PRIMARY KEY,
balance DECIMAL(10,2)
) ENGINE=MyISAM;MySQL 5.5+ defaults to InnoDB, but some ops teams switch to MyISAM for "performance" or legacy systems remain.
Fix:
ALTER TABLE t_account ENGINE=InnoDB;Scenario 6: Transaction Lost in Multi-Threading
@Transactional
public void process() {
new Thread(() -> {
// ❌ New thread cannot access main thread's Connection
// Spring transactions are bound to ThreadLocal
userMapper.updateStatus(...);
}).start();
}Fix: Move transactional logic to a separate service method with its own transaction:
@Transactional
public void process() {
CompletableFuture.runAsync(() -> asyncService.updateStatus(...));
}
@Service
public class AsyncService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void updateStatus(...) { ... }
}Scenario 7: Wrong Propagation Configuration
@Transactional(propagation = Propagation.SUPPORTS)
public void query() {
// SUPPORTS: join if exists, otherwise run non-transactionally
// If no outer transaction, no new transaction is created
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void doSomething() {
// Suspends current transaction, runs non-transactionally
// If exception thrown inside, nothing rolls back
}The article provides a full list of all 7 propagation behaviors:
REQUIRED (default): join existing or create new
REQUIRES_NEW : suspend current, create independent transaction
SUPPORTS : join if exists, otherwise non-transactional
NOT_SUPPORTED : suspend current, run non-transactionally
MANDATORY : must have existing transaction, else throw exception
NEVER : must run without transaction, else throw exception
NESTED : nested transaction, child can roll back independently
Fix: Understand each behavior and choose deliberately.
Scenario 8: @Transactional on Controller Instead of Service (90% Trap)
@RestController
public class OrderController {
@Autowired OrderService orderService;
@Transactional // ❌ On Controller
@PostMapping("/order")
public Result createOrder(@RequestBody OrderRequest req) {
orderService.create(req); // Service has its own @Transactional → two independent transactions
return Result.success();
}
}
@Service
public class OrderService {
@Transactional // ✅ Own transaction
public void create(OrderRequest req) { ... }
}Why this is dangerous:
Controller transaction only wraps the service call. Service's DAO operations run in Service's own transaction.
Two separate Connections → two independent transactions.
If Controller commits but Service later rolls back, you get "API returns success but data not persisted" — a silent data inconsistency.
Fix: Keep transactions strictly in Service layer:
@Service
public class OrderService {
@Transactional(rollbackFor = Exception.class)
public Result createOrder(OrderRequest req) {
orderMapper.insert(...);
itemMapper.insert(...);
logMapper.insert(...);
return Result.success();
}
}
@RestController
public class OrderController {
@PostMapping("/order")
public Result createOrder(@RequestBody OrderRequest req) {
// No @Transactional here
return orderService.createOrder(req);
}
}Scenario 9: Non-DB Operations in Transaction Cause Timeout Rollback
@Transactional(timeout = 5) // 5-second timeout
public void process() {
httpClient.callExternalApi(); // ❌ External call takes 10 seconds
orderMapper.insert(...);
}Transaction rolls back after timeout, but external system already processed the request — its side effects cannot be undone.
Fix: Move external calls outside the transaction, or use Saga/compensation:
public void process() {
ExternalResult result = httpClient.callExternalApi(); // Outside transaction
doWithTransaction(result);
}
@Transactional
public void doWithTransaction(ExternalResult result) {
orderMapper.insert(...);
}Scenario 10: Long Transaction Exhausts Connection Pool
@Transactional
public void batchProcess(List<Order> orders) {
for (Order order : orders) {
complexBusinessLogic(order); // 5 seconds each
}
// 1000 orders × 5s = 5000s → one connection held for ~1.5 hours
}Default pool size 8-20; one long transaction blocks a connection, all other requests queue.
Fix: Batch commit using TransactionTemplate:
public void batchProcess(List<Order> orders) {
Lists.partition(orders, 100).forEach(batch -> {
transactionTemplate.execute(status -> {
batch.forEach(order -> complexBusinessLogic(order));
return null;
});
});
}Scenario 11: Misusing readOnly = true
@Transactional(readOnly = true) // Hint for read-only optimization
public void createOrder(OrderRequest req) {
orderMapper.insert(req); // ❌ Write in read-only transaction → MySQL may reject or ignore
}Some ORMs still flush dirty data in readOnly mode, but the connection is read-only so the database rejects writes.
Fix: Never use readOnly = true for write operations.
Scenario 12: Wrong TransactionManager in Multi-DataSource Setup
// Multiple data sources, no explicit TransactionManager specified
@Transactional // ❌ Defaults to first TransactionManager, may be wrong
public void createOrder(...) { ... }
// Correct
@Transactional(transactionManager = "orderTransactionManager")
public void createOrder(...) { ... }Verification: Inject the specific manager:
@Autowired
@Qualifier("orderTransactionManager")
private PlatformTransactionManager orderTxManager;III. Quick Debugging Techniques
3.1 Enable Spring Transaction Debug Logs
logging:
level:
org.springframework.transaction: DEBUG
org.springframework.jdbc.datasource.DataSourceTransactionManager: DEBUGLook for:
Creating new transaction with name [com.example.OrderService.create]
Opened new Connection
...
Committing JDBC transaction on ConnectionIf "Creating new transaction" is absent, the AOP proxy never intercepted — transaction never started.
3.2 Manual Verification with TransactionTemplate
@Autowired
private TransactionTemplate transactionTemplate;
public void test() {
transactionTemplate.execute(status -> {
// Code here is guaranteed inside a transaction
orderMapper.insert(...);
return null;
});
}If TransactionTemplate works but @Transactional doesn't, the issue is in the AOP proxy layer.
IV. Visual Summary of All Failure Points
@Transactional call
│
├─ Method non-public → Proxy fails
├─ Self-invocation → Bypasses proxy
├─ Exception swallowed → No rollback
├─ Wrong exception type → No rollback
├─ Engine unsupported → Physically no transaction
├─ Multi-thread → ThreadLocal broken
├─ Propagation misconfigured → Transaction isolation
├─ On Controller → Dual transaction conflict ← 90% trap
├─ External call timeout → Side effects irreversible
├─ Long transaction → Connection pool exhausted
├─ readOnly misuse → Write rejected
└─ Wrong TransactionManager → Wrong connection usedV. Frequency & Severity Summary
@Transactional on Controller — Frequency: ★★★★★, Severity: High (data inconsistency + connection leak)
Self-invocation — Frequency: ★★★★, Severity: High (silent failure, no error)
Exception swallowed — Frequency: ★★★★, Severity: High (data corruption)
Wrong propagation — Frequency: ★★★, Severity: Medium
Long transaction — Frequency: ★★★, Severity: Medium (performance)
Multi-threading — Frequency: ★★, Severity: Medium
readOnly misuse — Frequency: ★★, Severity: Medium
Engine unsupported — Frequency: ★, Severity: Low (rare)
Signed-in readers can open the original source through BestHub's protected redirect.
This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactand we will review it promptly.
Programmer1970
Formerly called 'Code to 35'. Add our main WeChat ID to access a wealth of shared resources (algorithms, interview prep, tech stacks: Java, Python, Go, big data). We mainly share serious development techniques, focusing on output-driven input. Occasionally we post life snippets and gossip. Our aim is to attract precise traffic and test advertising opportunities.
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.
