Spring declarative transactions rely on AOP proxies.
@Transactionalonly takes effect when a call passes through the proxy object. This article focuses on 8 of the most common transaction failure scenarios to help you quickly diagnose rollback issues, uncontrolled commits, and proxy bypass problems. Keywords: Spring transactions, AOP proxy, transaction rollback.
Technical specification snapshot
| Parameter | Description |
|---|---|
| Language | Java |
| Core Framework | Spring / Spring Boot |
| Transaction Mechanism | @Transactional + AOP proxy |
| Typical Database | MySQL |
| GitHub Stars | Not provided in the source content |
| Core Dependencies | spring-tx, spring-aop, database driver |
Spring transactions depend on proxy interception, not the annotation itself
Many interview questions ask, “Why does @Transactional not roll back even when it is present?” The core answer is simple: the annotation does not implement the transaction by itself. Spring wraps the method call with an AOP proxy at runtime and manages the transaction there.
When an external call enters the proxy object, Spring starts the transaction first and then executes the target method. If the method completes normally, Spring commits the transaction. If the method throws an exception that matches the rollback rules, Spring rolls it back. So if the call never enters the proxy chain, the transaction is effectively never started.
@Service
public class UserServiceImpl {
@Transactional
public void createUser(User user) {
userMapper.insert(user); // Core business logic: write to the database
}
}
This code looks annotation-driven on the surface, but in reality it depends on proxy logic generated by Spring around the method call.
You can think of the proxy as the unified entry point for transactions
public class UserServiceProxy {
private UserServiceImpl target;
public void createUser(User user) {
TransactionStatus status = transactionManager.begin(); // Start the transaction
try {
target.createUser(user); // Execute the business method
transactionManager.commit(status); // Commit on success
} catch (Exception e) {
transactionManager.rollback(status); // Roll back on exception
throw e;
}
}
}
This pseudocode shows that the transaction boundary is controlled by the proxy, not by the business class itself.
Non-public methods cause transaction interception to fail
Spring AOP transaction interception typically targets public methods. If a method is private, protected, or package-private, transaction advice usually does not apply.
@Service
public class UserService {
@Transactional
void updateUser(User user) {
userMapper.updateById(user); // Execute the update
throw new RuntimeException("Test rollback"); // Expect a rollback
}
}
Even though this code throws an exception, it may still fail to roll back as expected because the method is not properly intercepted by the proxy. The fix is straightforward: make the transactional method public.
this calls within the same class bypass the proxy object
This is one of the most common production issues. In the same class, when one regular method calls another transactional method via this.xxx(), the call goes directly through the current object instance and never passes through the Spring proxy.
@Service
public class OrderService {
public void placeOrder(Order order) {
this.createOrder(order); // Internal call that bypasses the proxy
}
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // Insert the order
throw new RuntimeException("Test rollback");
}
}
Although createOrder is annotated, its entry point is not the proxy object, so the transaction does not take effect.
The correct approach is to re-enter the transaction chain through the proxy
@Service
public class OrderService {
@Autowired
private OrderService self; // Inject the proxy object
public void placeOrder(Order order) {
self.createOrder(order); // Call the transactional method through the proxy
}
@Transactional
public void createOrder(Order order) {
orderMapper.insert(order); // Execute transactional logic
}
}
The key point here is to route the call back through the proxy object so the transaction interceptor can run again.
When exceptions are swallowed, Spring has no idea it should roll back
By default, @Transactional marks a transaction for rollback only when the method throws a RuntimeException or Error outward. If you catch the exception and do not rethrow it, Spring assumes the method completed successfully.
@Transactional
public void transfer() {
try {
accountMapper.debit(); // Debit the account
int i = 1 / 0; // Trigger an exception
accountMapper.credit(); // Credit the account
} catch (Exception e) {
log.error("transfer error", e); // The exception is swallowed
}
}
The usual result is that the first half of the SQL statements may already be committed, and the transaction does not roll back. The correct approach is to rethrow a runtime exception or manually mark the transaction for rollback.
} catch (Exception e) {
log.error("transfer error", e);
throw new RuntimeException(e); // Rethrow so Spring can detect the failure
}
This fix ensures that the exception passes through the proxy layer and triggers transaction rollback.
Checked exceptions do not trigger rollback by default
Spring’s default rollback rules do not cover every Exception. Checked exceptions such as IOException will cause the transaction to commit unless you explicitly configure otherwise.
@Transactional(rollbackFor = Exception.class)
public void createFile() throws IOException {
fileService.write(); // Execute the business write operation
throw new IOException("File write failed"); // Checked exception should also trigger rollback
}
If your service layer can throw checked exceptions, the best practice is to explicitly configure rollbackFor = Exception.class.
Infrastructure and threading models can also break transactions
The first category of issues comes from the database itself. For example, MySQL MyISAM does not support transactions, so rollback will never work no matter how correct your application code is.
ALTER TABLE `user` ENGINE = InnoDB;
This SQL statement switches the table engine to InnoDB, which supports transactions.
The second category comes from thread isolation. Spring usually binds transactions to the current thread through ThreadLocal, which means the transaction context is valid only within that thread. If a transactional method starts a new thread to run database operations, the new thread does not inherit the current transaction.
@Transactional
public void process() {
userMapper.update(); // Update within the main thread
new Thread(() -> {
orderMapper.insert(); // Operation in a new thread, not controlled by the current transaction
}).start();
throw new RuntimeException("Rollback"); // Only the main-thread transaction can roll back
}
This example shows that even if the main thread rolls back, database writes in the new thread may already have been committed.
Propagation behavior and Bean management boundaries must be configured correctly
Propagation settings determine how a method joins, suspends, or avoids a transaction. If you incorrectly use NOT_SUPPORTED or NEVER, the method will run outside a transactional context.
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void update() {
userMapper.update(); // Explicitly runs outside a transaction
}
This is not a transaction failure. The configuration itself explicitly says not to use a transaction. In most cases, the default REQUIRED setting is the right choice.
Another common mistake is that the object is not managed by the Spring container. If the class does not have @Service or @Component, or if you create it manually with new, Spring will never proxy it.
public class NotManagedService {
@Transactional
public void doSomething() {
// Spring does not manage this transaction annotation here
}
}
The problem in this code is not the annotation itself, but the fact that the class is not a Spring Bean.
You can use a checklist to quickly diagnose transaction failures
The best troubleshooting order is entry point first, then exception flow, then environment
- Is the method
public? - Does the call pass through a Spring proxy?
- Is the exception swallowed?
- Is the thrown exception a checked exception?
- Does the database engine support transactions?
- Are database operations executed across threads?
- Is
propagationconfigured incorrectly? - Is the current object managed by the Spring container?
Following this order gives you the highest troubleshooting efficiency because it covers the three major layers: proxy entry, exception propagation, and infrastructure.
FAQ
Why does @Transactional appear on the method but still not roll back?
The most common reason is that the call never passes through the Spring proxy. Typical examples include this calls within the same class, manually instantiated objects created with new, non-public methods, or exceptions that are caught and not rethrown.
Why does the transaction still commit after throwing an IOException?
Because IOException is a checked exception, and Spring rolls back only on RuntimeException and Error by default. You should explicitly configure @Transactional(rollbackFor = Exception.class).
Why does starting a new thread inside a transactional method lead to partial commits?
Because Spring usually binds the transaction context to the current thread. A new thread does not automatically inherit the current connection or transaction state, so the SQL it executes is typically committed independently.
[AI Readability Summary]
This article systematically breaks down 8 high-frequency causes of Spring declarative transaction failures, including non-public methods, self-invocation, swallowed exceptions, checked exceptions not rolling back, multithreading, incorrect propagation settings, database engines without transaction support, and objects not managed by Spring. It also provides practical fixes you can apply directly.