In Spring Boot development, @Transactional is clearly added, but why is the data still submitted after an exception occurs? This article starts with the Spring transaction proxy mechanism and builds reproducible cases through Spring JDBC, H2 and JUnit. It systematically analyzes common problems such as similar method calls, exception rollback rules, exception capture, asynchronous threads, transaction propagation behavior, and multiple data sources. It also provides transaction log configuration and troubleshooting lists to help developers truly understand transaction boundaries and quickly locate the reasons why transactions do not take effect.
Technology stack: Java 17/Spring Boot 3.x/Spring Framework 6.x/Spring JDBC/H2
Suitable for readers: Already used in the Service layer@Transactionalbut Java developers who don’t know much about transaction interception, rollback rules, and propagation behavior
Note: H2 is used in this article to demonstrate the core behavior; version differences involving Spring Framework 6.2 and 7.x will be noted separately.
Preface: Even though a transaction has been added, why is the data still submitted?
Assume that creating an order requires two consecutive database operations:
- Write order.
- Deduct inventory.
When the second step fails, we want the first step to be undone as well. So it was natural to write:
@Transactional
public void createOrder() {
orderMapper.insert(order);
stockMapper.deduct(productId, quantity);
}
Most of the time, there’s no problem doing this. But when the code is refactored, exception handling is added, asynchronous tasks are split, or transaction propagation behavior is adjusted, confusing phenomena sometimes occur:
There is clearly a method @Transactionalexceptions also occurred, but some data in the database is still there.
If you just stare at the annotations at this time, it is easy to fall into trial and error. What really needs to be answered are three questions:
- Did this method call go through the Spring transaction proxy?
- Has the transaction manager received a signal that a rollback is required?
- Which transaction does the current database operation belong to?
Let’s first use a reproducible example to illustrate the most typical traps, and then check them one by one. This article discussesCommon proxy-based declarative transactionsnot AspectJ weaving mode, nor Reactor reactive transactions.
1. First understand one sentence: @Transactional does not mean that the method comes with its own transaction
In the default proxy mode, Spring does not automatically allow any call in the JVM to have a transaction just because annotations are written on the method.
The simplified call chain is:
Controller / 其他 Service
│
▼
Spring 管理的 Service 代理对象
│
├── 事务拦截器:判断传播行为,开始或加入事务
│
▼
目标方法执行
│
├── 正常返回:尝试提交
└── 抛出异常:按照回滚规则处理
The key isThe call goes through the proxy object. Annotations describe transaction rules, and it is Spring’s transaction infrastructure that actually executes the rules.
This also explains why “calling each other within the same class” deserves special vigilance.
2. Use H2 to reproduce: the same method, external call rollback, this call leaves data
In order to reduce the interference caused by MyBatis, database container and business code, Spring JDBC + H2 memory database is selected here.
Example starts with Spring Boot 3.x、Java 17 As the basis, the project can use the regular Spring Boot Maven parent project.
1. Add dependencies
org.springframework.boot
spring-boot-starter-jdbc
com.h2database
h2
runtime
org.springframework.boot
spring-boot-starter-test
test
spring-boot-starter-jdbc Provide JDBC and data source related capabilities. The example uses a single data source and Spring Boot’s regular transaction automatic configuration, without the need to manually declare a transaction manager.
2. Prepare data sheet
exist src/main/resources/schema.sql Write in:
CREATE TABLE tx_demo (
id BIGINT PRIMARY KEY,
note VARCHAR(100) NOT NULL
);
src/main/resources/application.yml:
spring:
datasource:
url: jdbc:h2:mem:txdemo;DB_CLOSE_DELAY=-1;DB_CLOSE_ON_EXIT=FALSE
driver-class-name: org.h2.Driver
username: sa
password:
sql:
init:
mode: always
No real business data is involved here.schema.sql Initialize the table structure when the application starts, and only clean up the records in the table between tests.
3. Write a Service that throws exceptions
package com.example.tx;
import org.springframework.jdbc.core.JdbcTemplate;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
/**
* 演示事务代理调用与同类内部调用的区别。
*/
@Service
public class TransactionDemoService {
private final JdbcTemplate jdbcTemplate;
public TransactionDemoService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void saveAndFail(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("模拟业务异常");
}
public void saveViaThis(long id) {
this.saveAndFail(id);
}
}
Note that there are two entrances here:
saveAndFail(): External direct call, through Spring proxy, the transaction will be started under normal circumstances.saveViaThis(): After external entry, passthisCalling similar methods will no longer go through the transaction proxy interception required for the internal call.
4. Write two tests and do not rely on guesswork to determine rollback.
package com.example.tx;
import org.junit.jupiter.api.BeforeEach;
import org.junit.jupiter.api.Test;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.boot.test.context.SpringBootTest;
import org.springframework.jdbc.core.JdbcTemplate;
import static org.junit.jupiter.api.Assertions.assertEquals;
import static org.junit.jupiter.api.Assertions.assertThrows;
/**
* 验证外部代理调用与内部自调用的事务行为。
*/
@SpringBootTest
class TransactionDemoTests {
@Autowired
private TransactionDemoService service;
@Autowired
private JdbcTemplate jdbcTemplate;
@BeforeEach
void clean() {
jdbcTemplate.update("DELETE FROM tx_demo");
}
@Test
void externalCallShouldRollBack() {
assertThrows(IllegalStateException.class,
() -> service.saveAndFail(1001L));
assertEquals(0L, countRows());
}
@Test
void selfInvocationShouldLeaveInsertedRow() {
assertThrows(IllegalStateException.class,
() -> service.saveViaThis(1002L));
assertEquals(1L, countRows());
}
private long countRows() {
return jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM tx_demo",
Long.class);
}
}
Test classDo not add extra @Transactionalotherwise the test itself may establish outer transactions and interfere with our observation of Service behavior.
Under the premise of the above single data source and normal automatic configuration, the expected results are:
| Calling method | Whether it has been intercepted by the transaction of the corresponding method | Record in table after throwing exception |
|---|---|---|
service.saveAndFail(1001L) |
yes | 0 items |
service.saveViaThis(1002L) |
internal this.saveAndFail() not passed |
1 item |
What is shown here is derived based on code and Spring proxy semanticsExpected test resultsdoes not claim to have been tested in the environment of this article. After copying to the project, please actually run the verification; if there is an outer transaction and the transaction configuration is manually adjusted, the results may be different.
This is the first important conclusion of our investigation:
A self-invocation will not revalidate transaction annotations on the called method; but if the external method has already started a transaction, internal database operations may still participate in that existing transaction.
In other words, we cannot roughly put “this Called” is interpreted as “definitely not any transaction”.
5. Recommended fix: Split into two Spring Beans
@Service
public class OrderEntryService {
private final OrderWriteService orderWriteService;
public OrderEntryService(OrderWriteService orderWriteService) {
this.orderWriteService = orderWriteService;
}
public void submit(long id) {
orderWriteService.saveAndFail(id);
}
}
@Service
public class OrderWriteService {
private final JdbcTemplate jdbcTemplate;
public OrderWriteService(JdbcTemplate jdbcTemplate) {
this.jdbcTemplate = jdbcTemplate;
}
@Transactional
public void saveAndFail(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("模拟业务异常");
}
}
at this time OrderEntryService The call is provided by the container OrderWriteServicetransaction boundaries are clear, and it is easier to write tests. Compared to injecting your own proxy or using AopContext.currentProxy(),Splitting responsibilities is often a higher priority。
3. The second pitfall: a checked exception is thrown, and the default rule may not be rolled back.
Look at the code below:
@Transactional
public void importOrders() throws IOException {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
2001L, "import");
throw new IOException("导入文件失败");
}
We may intuitively think that “since an exception is thrown, we should roll back.” But Spring’s declarative transactionsTraditional default rollback rulesyes:
RuntimeException:rollback.Error:rollback.- Checked exceptions, e.g.
IOException: No rollback by default.
Therefore, in the absence of other rollback rules,IOException Throwing from the proxy method, the transaction may still be committed.
A more clear way of writing it is:
@Transactional(rollbackFor = IOException.class)
public void importOrders() throws IOException {
// 数据库写入与文件导入流程
}
If the project business has a lot of abnormality, you can also consider rollbackFor = Exception.classbut do not globally modify the rollback conventions of historical projects without evaluation.
An easy-to-miss version change: Starting from Spring Framework 6.2, you can pass @EnableTransactionManagement(rollbackOn = ALL_EXCEPTIONS) Globally adjust default rollback rules. Therefore, when troubleshooting, you must also check whether the project has set global rules, instead of just looking at the annotations on a single method.
4. The third pit: the exception is swallowed inside the method, and the transaction interceptor sees “normal end”
Consider this processing:
@Transactional
public void saveWithCatch(long id) {
try {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
throw new IllegalStateException("业务校验失败");
} catch (IllegalStateException ex) {
// 记录错误后直接返回
System.err.println(ex.getMessage());
}
}
IllegalStateException It is captured inside the transaction method and is not propagated to the transaction interceptor.
If no other code marks the transaction as rollback-onlywhat Spring sees is that the method returns normally, and it will try to submit.
A more prudent approach:
@Transactional
public void saveWithRollback(long id) {
jdbcTemplate.update(
"INSERT INTO tx_demo (id, note) VALUES (?, ?)",
id, "demo");
if (id <= 0) {
throw new IllegalArgumentException("ID 不合法");
}
}
If you must catch an exception to supplement the context, you can log it and rethrow it, or convert it into a business exception that conforms to rollback rules. When it is indeed impossible to throw, you can also use it after confirming that the current Spring transaction exists. TransactionAspectSupport.currentTransactionStatus().setRollbackOnly(); But it will make the business code rely on the Spring transaction API, so it is not the first choice.
Another situation needs to be distinguished here:Another delegate method being called has marked the shared transaction for rollback. Even if the outer layer catches the exception, the final commit may still fail with UnexpectedRollbackException. This is not because the transaction annotation suddenly expires, but because the internal transaction has clearly requested rollback, but the outer layer is still trying to commit.
5. The fourth pitfall: private, final, and “not all public” method visibility
There is a sentence often seen on the Internet: “@Transactional must be added to public In terms of method, other methods will definitely not take effect. “
As a daily coding convention, it is preferred to use public There is nothing wrong with the method; but as a strict framework conclusion, this sentence is no longer accurate enough.
In the common Spring AOP proxy model:
| Method or calling method | need attention |
|---|---|
private method |
Cannot be intercepted directly by normal Spring AOP proxy |
under class agent final method |
Interception cannot be achieved through rewriting |
public method |
Recommended transaction entry, but still needs to go through proxy |
protected / Package visible methods |
Starting from Spring 6.0, transactions may also be intercepted under the corresponding conditions of the class proxy. |
| JDK interface proxy | The transaction entry needs to satisfy the method constraints of the interface agent |
Within the same category this call |
Even if the target method is publicalso bypassing that proxy interception |
So I recommend one moreProject agreementinstead of memorizing the visibility details of a version:
Put transaction boundaries in Spring-managed Services that are clear and callable from outside
publicMethodologically; do not rely on private methods, construction or initialization phase methods to establish transactions.
One more thing:new OrderService(...) What you get is the object you created, not the transaction proxy injected by the Spring container. Even if there is a class @Serviceself-propelled new Transaction enhancements are also not automatically obtained.
6. The fifth pitfall: turning on asynchronous threads, thinking that the transaction will follow.
For example:
@Transactional
public void createAndNotify() {
orderMapper.insert(order);
CompletableFuture.runAsync(() -> {
auditMapper.insert(auditRecord);
});
throw new IllegalStateException("订单流程失败");
}
There are two execution chains here:
请求线程:开启事务 → 写订单 → 抛异常 → 订单事务回滚
异步线程: → 写审计记录 → 使用异步线程自己的数据库访问上下文
For usually PlatformTransactionManager Managed imperative transactions, the transaction context is thread-bound,It will not be passed automatically just because a new thread is created.。
Therefore, if the asynchronous operation is submitted independently, the result of “the order is rolled back, but the audit record is still there” may occur; as for the specific execution, error reporting, or submission timing of the asynchronous operation, it also depends on thread scheduling and data access configuration.
What is more appropriate depends on the business semantics:
- Data that must be submitted atomically with the order should not be put into a new thread casually.
- Notifications and audits that can be completed independently must clearly design independent affairs and failure compensation.
- use
@AsyncThe method can be established in the asynchronous thread through its own proxy callown affairsbut that is not a continuation of the original thread transaction. - If you need to “trigger subsequent actions after the local transaction is submitted successfully”, you can consider
@TransactionalEventListener(phase = AFTER_COMMIT); If reliable message delivery is involved, mechanisms such as Outbox should also be evaluated, and the listener cannot be regarded as a reliable message queue.
7. The sixth pitfall: REQUIRES_NEW was written, but the expected “new transaction” did not occur.
For example:
@Service
public class OrderService {
@Transactional
public void create() {
saveOrder();
this.saveAudit();
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void saveAudit() {
// 保存审计记录
}
}
Developers may expect saveAudit() Use an independent transaction: After the audit is committed, even if the outer transaction is rolled back, it will not affect the audit record.
but here it is stillSimilar self-calling。REQUIRES_NEW The interception does not occur, and the audit operation usually remains in the outer layer create() within the scope of business.
To make the propagation settings take effect, the most direct way is to split it into another Spring-managed Service and call it through a proxy.
Also remember the difference between the two communication behaviors:
| communication behavior | transaction semantics |
|---|---|
REQUIRED(default) |
If there is a transaction, add it, if not, create a new one |
REQUIRES_NEW |
Establish an independent physical transaction, and the outer transaction is temporarily suspended |
REQUIRES_NEW It is not a universal “anti-rollback annotation”. The outer layer transaction may still occupy the connection, and the inner layer needs to obtain a new connection. If the amount of concurrency is high and the connection pool capacity is insufficient, it may cause connection waiting or even resource exhaustion.
If your actual intention is “orders and inventory either succeed or fail together”, in most cases, use a normal REQUIRED Affairs are simpler.
8. The seventh pit: The transaction manager manages data source A, but SQL operates data source B.
When it comes to a multi-data source system, the problem may not be with exceptions or agents.
for example:
- Order data is written to the MySQL order data source.
- Log or statistics data is written to another PostgreSQL data source.
- Method only uses a certain
DataSourceTransactionManager。
At this time, even if the method @Transactional It took effect, tooIt cannot be assumed that writes to the two databases will be rolled back together.。
The local JDBC transaction manager is responsible for transactions on the corresponding data source, rather than automatically helping you establish cross-database atomic commits.
Multiple transaction manager projects can be specified explicitly:
@Transactional(transactionManager = "orderTransactionManager")
public void saveOrder() {
// 使用订单数据源
}
But specifying a manager still doesn’t automatically include other data sources. When cross-system consistency is truly needed, message eventual consistency, Outbox, compensation process, or a suitable distributed transaction solution should be selected based on the business.
Also be wary of manual connections that bypass Spring data source management:
Connection connection =
DriverManager.getConnection(url, username, password);
This connection does not exist because the current method @Transactionalautomatically joins a transaction managed by another Spring data source. use JdbcTemplatecorrectly configured data sources and transaction managers are usually less likely to step into this trap.
In addition, the database or storage engine itself must support corresponding transactions. For example, when using MySQL, you should confirm that the relevant table uses an engine that supports transactions (usually InnoDB), rather than just judging the results based on Java code.
9. If a transaction does not take effect, I will check it in this order.
I don’t recommend modifying it in the first step rollbackForit is not recommended to change all communication behaviors to REQUIRES_NEW. Confirm the most basic link first.
Step 1: Confirm whether the transaction is actually activated
Temporarily print at the code location that needs to be observed:
boolean active =
TransactionSynchronizationManager
.isActualTransactionActive();
System.out.println("当前线程是否存在实际事务:" + active);
Note that this only meansCurrent thread at this momentThe presence or absence of actual transactions managed by Spring cannot alone prove that a particular annotation has taken effect as expected. If there is already a transaction in the outer layer, it may also be printed inside the self-calling true。
Step 2: Open the transaction log
application.yml:
logging:
level:
org.springframework.transaction.interceptor: TRACE
org.springframework.jdbc.datasource: DEBUG
Depending on the actual transaction manager implementation, the log class and output content may differ. Key observations:
- Have you entered the transaction interceptor?
- Created a new transaction or added to an existing transaction?
- Is the rollback flag set?
- Is it commit or rollback in the end?
After debugging is completed, close the detailed log to prevent the production environment from outputting a large number of transaction details for a long time.
Step 3: Check item by item
| Check items | key questions |
|---|---|
| Beans and proxies | Is it a bean injected by the container or itself? new Come out? |
| Call path | pass or not this Call another method annotated with a transaction? |
| abnormal | What is the exception type? Is it captured inside the method? |
| Rollback rules | Is there any rollbackFor、noRollbackFor Or global coverage? |
| communication behavior | yes REQUIRED still REQUIRES_NEW? Is it really through an agent? |
| thread | Yes or no @Asyncthread pool,CompletableFuture? |
| data source | Is the data source where the SQL is located owned by the current transaction manager? |
| database | Do tables and drivers support transactions? Are independent connections mixed in? |
Step 4: Write a test that actually checks the database results
Don’t just assert that an exception is thrown:
assertThrows(IllegalStateException.class,
() -> service.saveAndFail(1001L));
The final data should also be checked:
assertEquals(0L, jdbcTemplate.queryForObject(
"SELECT COUNT(*) FROM tx_demo", Long.class));
“An exception was thrown” and “the database was really rolled back” are two different things.
For transaction boundary testing, special attention should be paid to the transaction settings of the test framework itself. Let the test method automatically roll back, sometimes it will cover up the problem that the business service does not roll back.
10. Summary: Don’t just look at whether there are annotations, look at where the transaction boundaries are.
@Transactional The most common misunderstanding is that it will permanently bind a Java method with the ability to “cancel all operations if execution fails”.
In fact, in the common Spring proxy transaction model, whether it works as expected also depends on:
- Is there an agent at the entrance?
- Whether the exception or rollback flag is sensed by the transaction manager.
- Whether the database operations are in the same thread and the same managed transaction resource.
- Does the act of communication really establish the transaction boundaries you think it does?
If I could only remember one sentence, I hope it would be:
When troubleshooting transaction problems, first draw the link of “who called whom, who started the transaction, who submitted the rollback”, and then decide which annotation to modify.
This is usually better than repeatedly adding rollbackFor = Exception.class More efficient and less likely to expand a local problem into a new transaction consistency problem.