Bài 30 của loạt Basics đã đi hết một method @Transactional: transaction mang lại gì, proxy mở và đóng nó ra sao, rollback rules cho unchecked và checked exception, readOnly, self-invocation và bẫy rollback-only. Bài này nói về những gì bắt đầu khi có hai method transactional, hoặc hai transaction chạy cùng lúc: inner method làm gì với transaction nó được gọi bên trong (propagation), một transaction nhìn thấy gì từ transaction khác đang chạy song song (isolation level), và readOnly, rollback rules, timeout hành xử ra sao khi đi qua những ranh giới đó.
Lời khuyên về transaction đặc biệt dễ sai, vì phần lớn nó từng đúng với một database và một transaction manager nào đó. Bài này dùng Spring Boot 4.1.1, Java 21 và PostgreSQL 18, app web chạy ở port 8206. Các con số thời gian lấy từ một lần chạy, kèm load average 1 phút tại thời điểm đo, chỉ để tham khảo.
![]()
Phần đầu dựng một lab nhỏ; tiếp theo là bảy propagation, lỗi connection pool mà REQUIRES_NEW gây ra khi có tải, isolation level trên PostgreSQL, và những phần của readOnly, rollback rules, timeout chỉ lộ ra khi đi qua ranh giới transaction.
Lab: một outer service, một inner service và một probe
Account, bảng audit và cấu hình log
Hai bảng, do Flyway tạo và Hibernate validate:
create table accounts (
id bigint generated by default as identity primary key,
owner varchar(100) not null,
kind varchar(20) not null,
balance numeric(12, 2) not null
);
create table audit_entries (
id bigint generated by default as identity primary key,
message varchar(200) not null
);
insert into accounts (owner, kind, balance) values
('an', 'CHECKING', 100.00),
('an', 'SAVINGS', 100.00),
('binh', 'CHECKING', 100.00);spring.application.name=demo
spring.datasource.url=jdbc:postgresql://localhost:5506/demo
spring.datasource.username=demo
spring.datasource.password=demo
spring.jpa.open-in-view=false
spring.jpa.hibernate.ddl-auto=validate
logging.level.org.springframework.orm.jpa.JpaTransactionManager=DEBUG
logging.level.org.hibernate.SQL=DEBUG
logging.level.org.springframework.jdbc.support.JdbcTransactionManager=DEBUGdocker run -d --name sba-a6-pg -e POSTGRES_USER=demo -e POSTGRES_PASSWORD=demo -e POSTGRES_DB=demo -p 5506:5432 postgres:18Account là một entity bình thường trong package account, có method withdraw(BigDecimal) và deposit(BigDecimal). Repository của nó thêm ba query mà các phần sau dùng tới:
public interface AccountRepository extends JpaRepository<Account, Long> {
@Query("select sum(a.balance) from Account a where a.owner = :owner")
BigDecimal totalBalance(String owner);
@Query("select a.balance from Account a where a.id = :id")
BigDecimal balanceOf(Long id);
// a stand-in for a slow report query
@Query(value = "select count(*) from pg_sleep(:seconds)", nativeQuery = true)
long slowReport(int seconds);
}Probe cho biết transaction và connection
Một dòng log nói "đang có transaction" chưa đủ để phân biệt các propagation. TxProbe còn hỏi PostgreSQL xem backend process nào đang phục vụ connection hiện tại, pg_backend_pid(), và hỏi HikariCP có bao nhiêu connection đang bị lấy ra khỏi pool:
@Component
public class TxProbe {
private final JdbcClient jdbc;
private final HikariDataSource dataSource;
TxProbe(JdbcClient jdbc, HikariDataSource dataSource) {
this.jdbc = jdbc;
this.dataSource = dataSource;
}
public String describe() {
boolean active = TransactionSynchronizationManager.isActualTransactionActive();
String name = shortName(TransactionSynchronizationManager.getCurrentTransactionName());
Integer pid = jdbc.sql("select pg_backend_pid()").query(Integer.class).single();
int busy = dataSource.getHikariPoolMXBean().getActiveConnections();
return "actualTx=%s name=%s pid=%d hikariActive=%d".formatted(active, name, pid, busy);
}
// com.example.demo.audit.AuditService.requiresNew -> AuditService.requiresNew
private static String shortName(String name) {
return name == null ? null : name.substring(name.lastIndexOf('.', name.lastIndexOf('.') - 1) + 1);
}
}Cùng pid nghĩa là cùng một connection vật lý; khác pid nghĩa là connection thứ hai.
Inner service: mỗi propagation một method
AuditService ghi một audit row mỗi lần gọi. Các method của nó giống hệt nhau, chỉ khác propagation, và method nào cũng throw khi fail là true:
@Service
public class AuditService {
private static final Logger log = LoggerFactory.getLogger(AuditService.class);
private final JdbcClient jdbc;
private final TxProbe probe;
AuditService(JdbcClient jdbc, TxProbe probe) {
this.jdbc = jdbc;
this.probe = probe;
}
@Transactional(propagation = Propagation.REQUIRED)
public void required(String message, boolean fail) {
record("REQUIRED", message, fail);
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void requiresNew(String message, boolean fail) {
record("REQUIRES_NEW", message, fail);
}
@Transactional(propagation = Propagation.SUPPORTS)
public void supports(String message, boolean fail) {
record("SUPPORTS", message, fail);
}
@Transactional(propagation = Propagation.MANDATORY)
public void mandatory(String message, boolean fail) {
record("MANDATORY", message, fail);
}
@Transactional(propagation = Propagation.NOT_SUPPORTED)
public void notSupported(String message, boolean fail) {
record("NOT_SUPPORTED", message, fail);
}
@Transactional(propagation = Propagation.NEVER)
public void never(String message, boolean fail) {
record("NEVER", message, fail);
}
@Transactional(propagation = Propagation.NESTED)
public void nested(String message, boolean fail) {
record("NESTED", message, fail);
}
@Transactional(noRollbackFor = AuditFailedException.class)
public void requiredNoRollback(String message, boolean fail) {
record("REQUIRED, noRollbackFor", message, fail);
}
private void record(String propagation, String message, boolean fail) {
log.info("{}: {}", propagation, probe.describe());
jdbc.sql("insert into audit_entries (message) values (?)").param(message).update();
if (fail) {
throw new AuditFailedException(message);
}
}
}AuditFailedException extends RuntimeException. Audit row được ghi qua JdbcClient, vốn tham gia vào JPA transaction trên cùng connection như bài 30 của Basics đã cho thấy; nhờ vậy log không bị lẫn transaction của repository và việc so sánh pid là trực tiếp.
Outer service và runner
@Component
@Profile("lab")
public class OuterService {
private static final Logger log = LoggerFactory.getLogger(OuterService.class);
private final JdbcClient jdbc;
private final TxProbe probe;
OuterService(JdbcClient jdbc, TxProbe probe) {
this.jdbc = jdbc;
this.probe = probe;
}
@Transactional
public void inTransaction(String label, Runnable inner) {
jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
log.info("outer: {}", probe.describe());
inner.run();
log.info("outer, after inner: {}", probe.describe());
}
// catching(...) and failingAfter(...) are shown in the rollback section
}Một runner trong profile lab gọi mỗi propagation hai lần, một lần bên trong inTransaction và một lần đứng riêng. Trước mỗi bước nó xoá sạch audit_entries; sau mỗi bước nó in ra các row đã được commit:
step("REQUIRES_NEW, inside a transaction", () -> outer.inTransaction("requires-new", () -> audit.requiresNew("requires-new", false)));
step("REQUIRES_NEW, no transaction", () -> audit.requiresNew("requires-new", false)); void step(String name, Runnable body) {
jdbc.sql("delete from audit_entries").update();
log.info("=== {} ===", name);
try {
body.run();
} catch (RuntimeException e) {
chain(e);
}
List<String> rows = jdbc.sql("select message from audit_entries order by id").query(String.class).list();
log.info("committed audit rows: {}", rows);
}./gradlew -q bootJarjava -jar build/libs/demo-0.0.1-SNAPSHOT.jar --spring.profiles.active=lab --spring.main.web-application-type=none --lab=propagation '--logging.pattern.console=%logger{0}: %msg%n'Các lab chạy một thread log với pattern %logger{0}: %msg%n; các lab chạy song song ở phần sau thêm [%thread].
Propagation: inner method làm gì với transaction bên ngoài
Propagation là attribute mà transaction manager đọc mỗi khi đi vào một method transactional: thread này đã có transaction chưa, và nếu có thì tham gia, suspend, từ chối hay lồng vào trong nó? JpaTransactionManager log nhánh nó đã chọn, còn probe cho biết nhánh đó có ý nghĩa gì với connection.
REQUIRED: tham gia trên cùng một connection
Đây là mặc định. Mọi đoạn log trong bài này đều bỏ dòng Found thread-bound EntityManager [...] for JPA transaction mà JpaTransactionManager in ra trước mỗi quyết định về transaction đang có; những chỗ cắt khác đều được ghi rõ. Bước đầu tiên:
Lab: === REQUIRED, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(659416252<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@5066a895]
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Participating in existing transaction
AuditService: REQUIRED: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(659416252<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Lab: committed audit rows: [outer required, required]Inner method chạy trong outer transaction, mang tên của outer method, trên backend pid 437 với một connection đang được dùng, và cả hai row được commit cùng nhau. Gọi riêng, cùng method đó tự mở transaction của nó: Creating new transaction with name [com.example.demo.audit.AuditService.required]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT, vẫn trên pid 437.
Dùng chung một transaction cũng là dùng chung số phận. Một RuntimeException thoát ra khỏi inner method sẽ đánh dấu transaction chung là rollback-only kể cả khi outer method bắt nó, đó chính là bẫy UnexpectedRollbackException của bài 30 Basics; phần rollback rules bên dưới cho thấy attribute duy nhất tránh được dấu đó.
REQUIRES_NEW: suspend, rồi lấy connection thứ hai
Lab: === REQUIRES_NEW, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Opened new EntityManager [SessionImpl(662238161<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@30a34f07]
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
JpaTransactionManager: Opened new EntityManager [SessionImpl(1378219538<open>)] for JPA transaction
JpaTransactionManager: Exposing JPA transaction as JDBC [org.springframework.orm.jpa.vendor.HibernateJpaDialect$HibernateConnectionHandle@1d3aefd]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1378219538<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(662238161<open>)]
JpaTransactionManager: Closing JPA EntityManager after transaction
Lab: committed audit rows: [outer requires-new, requires-new]Một EntityManager thứ hai, một backend thứ hai (pid 438) và hikariActive=2 trong lúc inner transaction chạy; inner commit trước, rồi outer chạy tiếp trên pid 437. Suspend chỉ gỡ resource của outer transaction khỏi thread, nó không trả connection về pool: trong suốt inner transaction, connection của outer vẫn bị giữ và đứng yên. Phần tiếp theo biến điều đó thành một lỗi production. Khi gọi mà không có transaction nào, requiresNew đơn giản là mở một transaction, PROPAGATION_REQUIRES_NEW,ISOLATION_DEFAULT, trên pid 437 với hikariActive=1.
Suspend không chỉ áp dụng cho connection. Các callback TransactionSynchronization của outer transaction, cơ chế nằm dưới @TransactionalEventListener trong bài 4 của khoá Advanced, cũng bị suspend theo. OuterService.withSynchronization đăng ký một synchronization log lại các callback suspend, resume và afterCommit của nó, rồi gọi requiresNew; đã cắt bớt:
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
OuterService: outer synchronization: suspend
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1903221272<open>)]
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer synchronization: resume
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(499755936<open>)]
OuterService: outer synchronization: afterCommitInner commit không kích hoạt afterCommit của outer; chỉ outer commit mới làm vậy.
SUPPORTS, MANDATORY và NEVER: điều kiện đặt lên caller
Ba propagation này không bao giờ tạo transaction. Bên trong inTransaction, SUPPORTS và MANDATORY log Participating in existing transaction và chạy trên pid 437 y như REQUIRED. Khác biệt nằm hết ở cột còn lại.
SUPPORTS khi không có transaction thì không sinh ra dòng JpaTransactionManager nào:
Lab: === SUPPORTS, no transaction ===
AuditService: SUPPORTS: actualTx=false name=AuditService.supports pid=437 hikariActive=1
Lab: committed audit rows: [supports]actualTx=false: lệnh insert chạy ở chế độ auto-commit. Tên transaction vẫn được đặt vì Spring mở một synchronization scope cho method ngay cả khi không có transaction, và hikariActive=1 cho thấy connection mà query của probe mượn vẫn chưa được trả sau khi query xong: khi synchronization đang bật, Spring giữ nó gắn với scope của method.
MANDATORY khi không có transaction, và NEVER khi đang ở trong một transaction, đã cắt bớt:
Lab: === MANDATORY, no transaction ===
Lab: threw org.springframework.transaction.IllegalTransactionStateException: No existing transaction found for transaction marked with propagation 'mandatory'
Lab: committed audit rows: []
Lab: === NEVER, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(413705230<open>)]
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Existing transaction found for transaction marked with propagation 'never'
Lab: committed audit rows: []Cả hai đều từ chối trước khi thân method chạy. Với NEVER, exception là một RuntimeException thoát khỏi inTransaction, nên outer transaction cũng rollback luôn row của chính nó. NEVER khi không có transaction thì chạy như SUPPORTS không có transaction: actualTx=false, pid 437.
NOT_SUPPORTED: suspend, nhưng vẫn lấy connection thứ hai
Đoạn giữa của bước chạy bên trong inTransaction:
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Suspending current transaction
AuditService: NOT_SUPPORTED: actualTx=false name=AuditService.notSupported pid=438 hikariActive=2
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer, after inner: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1Inner method chạy không có transaction, đúng như ý định, nhưng trên pid 438 với hai connection đang bị giữ. Outer transaction bị suspend vẫn giữ pid 437, nên mọi truy cập database trong một method NOT_SUPPORTED tốn thêm một connection y như REQUIRES_NEW. Khi không có outer transaction, nó hành xử như SUPPORTS: actualTx=false, pid 437.
NESTED: savepoint, và vì sao JpaTransactionManager từ chối
NESTED nhằm chạy inner method bên trong một savepoint của outer transaction, để một lỗi chỉ rollback phần bên trong. Với JpaTransactionManager mà Spring Boot cấu hình cho JPA:
Lab: === NESTED, inside a transaction ===
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.inTransaction]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
OuterService: outer: actualTx=true name=OuterService.inTransaction pid=437 hikariActive=1
JpaTransactionManager: Initiating transaction rollback
Lab: threw org.springframework.transaction.NestedTransactionNotSupportedException: Transaction manager does not allow nested transactions by default - specify 'nestedTransactionAllowed' property with value 'true'
Lab: committed audit rows: []Đã cắt còn các dòng transaction và kết quả. Cờ này có thể bật qua một TransactionManagerCustomizer, hook của Boot 4 mà JpaBaseConfiguration áp dụng lên JpaTransactionManager nó tạo ra. Lab bật nó bằng --lab.nested-allowed=true:
@Configuration
class LabTransactionConfig {
@Bean
@ConditionalOnBooleanProperty("lab.nested-allowed")
TransactionManagerCustomizer<AbstractPlatformTransactionManager> allowNested() {
return transactionManager -> transactionManager.setNestedTransactionAllowed(true);
}
@Bean
@ConditionalOnBooleanProperty("lab.validate-existing")
TransactionManagerCustomizer<AbstractPlatformTransactionManager> validateExisting() {
return transactionManager -> transactionManager.setValidateExistingTransaction(true);
}
}Cùng lời gọi đó khi đã bật cờ, lần này từ OuterService.catching, method bắt AuditFailedException (xem ở phần rollback), đã cắt bớt:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.catching]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Creating nested transaction with name [com.example.demo.audit.AuditService.nested]
JpaTransactionManager: Initiating transaction rollback
Lab: threw org.springframework.transaction.NestedTransactionNotSupportedException: JpaDialect does not support savepoints - check your JPA provider's capabilities
Lab: committed audit rows: []Transaction manager đi thêm được một bước rồi hỏng ở savepoint. JpaTransactionManager tạo savepoint thông qua một SavepointManager mà JpaDialect phải cung cấp, và HibernateJpaDialect trong spring-orm 7.0.9 không cung cấp. Javadoc của JpaTransactionManager nêu lý do: một savepoint chỉ rollback được JDBC connection, không rollback được EntityManager cùng các entity nó đã load và sửa. NestedTransactionNotSupportedException không phải AuditFailedException, nên catching không bắt nó và cả outer transaction bị rollback. Khi không có outer transaction, NESTED hành xử như REQUIRED: Creating new transaction with name [com.example.demo.audit.AuditService.nested]: PROPAGATION_NESTED,ISOLATION_DEFAULT.
Một transaction manager JDBC thuần làm được điều NESTED hứa. DataSourceTransactionManager, và subclass mặc định của Boot là JdbcTransactionManager, cho phép nested transaction ngay từ đầu. Lab tự tạo một cái trên cùng DataSource, để không thay thế JPA transaction manager của application:
public void jdbcNested() {
JdbcTransactionManager tm = new JdbcTransactionManager(dataSource);
TransactionTemplate outer = new TransactionTemplate(tm);
TransactionTemplate nested = new TransactionTemplate(tm);
nested.setPropagationBehavior(TransactionDefinition.PROPAGATION_NESTED);
outer.executeWithoutResult(status -> {
jdbc.sql("insert into audit_entries (message) values ('outer jdbc')").update();
try {
nested.executeWithoutResult(inner -> {
jdbc.sql("insert into audit_entries (message) values ('nested jdbc')").update();
throw new AuditFailedException("nested jdbc");
});
} catch (AuditFailedException e) {
log.info("outer caught: {}", e.getMessage());
}
});
}JdbcTransactionManager: Creating new transaction with name [null]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JdbcTransactionManager: Acquired Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] for JDBC transaction
JdbcTransactionManager: Switching JDBC Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] to manual commit
JdbcTransactionManager: Creating nested transaction with name [null]
JdbcTransactionManager: Rolling back transaction to savepoint
NestedLab: outer caught: audit failed: nested jdbc
JdbcTransactionManager: Initiating transaction commit
JdbcTransactionManager: Committing JDBC transaction on Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657]
JdbcTransactionManager: Releasing JDBC Connection [HikariProxyConnection@310140704 wrapping org.postgresql.jdbc.PgConnection@2db1b657] after transaction
Lab: committed audit rows: [outer jdbc]Row của outer được commit còn row nested thì không. Những gì thực sự tới PostgreSQL được đọc từ statement log của nó, bật cho lần chạy này và đọc bằng docker logs:
docker exec sba-a6-pg psql -U demo -d demo -c "alter system set log_statement = 'all'" -c "select pg_reload_conf()"[418] LOG: execute <unnamed>: insert into audit_entries (message) values ('outer jdbc')
[418] LOG: execute <unnamed>: SAVEPOINT "SAVEPOINT_1"
[418] LOG: execute <unnamed>: insert into audit_entries (message) values ('nested jdbc')
[418] LOG: execute <unnamed>: ROLLBACK TO SAVEPOINT "SAVEPOINT_1"
[418] LOG: execute <unnamed>: RELEASE SAVEPOINT "SAVEPOINT_1"
[418] LOG: execute S_1: COMMITTimestamp đã được bỏ khỏi các dòng log PostgreSQL ở đây và các phần dưới. Trong một application JPA, phần nào phải được phép hỏng độc lập thì hoặc có transaction riêng bằng REQUIRES_NEW, với cái giá connection vừa đo ở trên, hoặc được kiểm tra trước khi thực hiện, như bài 30 Basics đã khuyên.
Bảy propagation đặt cạnh nhau
Mỗi ô là một trong các lần chạy ở trên, dùng JpaTransactionManager trừ khi ghi khác.
| Propagation | Gọi bên trong một transaction | Gọi khi không có transaction | Dùng điển hình |
|---|---|---|---|
REQUIRED (mặc định) | tham gia: Participating in existing transaction, cùng pid | tạo mới | gần như mọi service method |
REQUIRES_NEW | suspend nó và tạo transaction mới trên connection thứ hai | tạo mới | một thao tác ghi phải commit hoặc rollback độc lập, như một audit row |
SUPPORTS | tham gia | chạy không có transaction, actualTx=false | các thao tác đọc chạy kiểu nào cũng được |
MANDATORY | tham gia | IllegalTransactionStateException | code không bao giờ được làm ranh giới transaction |
NOT_SUPPORTED | suspend nó, chạy không có transaction, trên connection thứ hai nếu đụng tới database | chạy không có transaction | code không được tham gia transaction của caller |
NEVER | IllegalTransactionStateException | chạy không có transaction | chốt chặn cho code không bao giờ được chạy trong transaction |
NESTED | NestedTransactionNotSupportedException; một savepoint với JdbcTransactionManager | tạo mới | rollback một phần trong code JDBC |

REQUIRES_NEW gây deadlock connection pool
Lệnh rút tiền ghi audit trong transaction riêng
Lý do kinh điển để dùng REQUIRES_NEW là một audit row phải còn lại kể cả khi thay đổi nghiệp vụ bị rollback. Đặt sau một endpoint:
@Service
public class AccountService {
private final AccountRepository accounts;
private final AuditService audit;
AccountService(AccountRepository accounts, AuditService audit) {
this.accounts = accounts;
this.audit = audit;
}
@Transactional
public BigDecimal withdraw(Long id, BigDecimal amount) {
Account account = accounts.findById(id).orElseThrow();
account.withdraw(amount);
audit.requiresNew("withdraw " + amount + " from account " + id, false);
return account.getBalance();
}
} @PostMapping("/api/accounts/{id}/withdrawals")
BigDecimal withdraw(@PathVariable Long id, @RequestParam BigDecimal amount) {
return service.withdraw(id, amount);
}Mỗi request giữ một connection cho withdraw và cần thêm connection thứ hai cho requiresNew trước khi có thể trả connection đầu tiên. Application chạy với cấu hình mặc định của HikariCP, được log DEBUG của nó xác nhận là maximumPoolSize.................10 và connectionTimeout...............30000, còn log SQL và log transaction được giảm bớt cho bài test tải. Apache Bench gửi 2.000 lệnh rút tiền với một mức concurrency cố định (empty.txt là request body rỗng):
java -jar build/libs/demo-0.0.1-SNAPSHOT.jar --server.port=8206 --logging.level.com.zaxxer.hikari.HikariConfig=DEBUG --logging.level.org.springframework.orm.jpa.JpaTransactionManager=INFO --logging.level.org.hibernate.SQL=INFOab -n 2000 -c 10 -s 120 -p empty.txt -T application/x-www-form-urlencoded 'http://localhost:8206/api/accounts/3/withdrawals?amount=0.01'Với 9 client song song:
Concurrency Level: 9
Time taken for tests: 1.602 seconds
Complete requests: 2000
Failed requests: 0Với 10:
Concurrency Level: 10
Time taken for tests: 32.814 seconds
Complete requests: 2000
Failed requests: 10
(Connect: 0, Receive: 0, Length: 10, Exceptions: 0)
Non-2xx responses: 10Thêm đúng một client biến 1,6 giây thành 32,8 giây (load average 4,18 và 4,00), và 10 request thất bại; request lâu nhất mất 30.075 ms. Pool kẹt bao nhiêu lần trong một lần chạy là chuyện thời điểm: chạy lại đúng lệnh đó sau này, ở load average 1,58, pool deadlock ba lần, 26 request thất bại trong 91,1 giây, còn -c 9 vẫn không hỏng request nào. Mỗi request thất bại log ra timeout của HikariCP và exception tới servlet, ở đây đã bỏ phần tiền tố của log:
HikariPool-1 - Connection is not available, request timed out after 30006ms (total=10, active=10, idle=0, waiting=1)
Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed: org.springframework.transaction.CannotCreateTransactionException: Could not open JPA EntityManager for transaction] with root cause
java.sql.SQLTransientConnectionException: HikariPool-1 - Connection is not available, request timed out after 30006ms (total=10, active=10, idle=0, waiting=1)Hai request, hai connection, không ai xong
Cùng endpoint đó với --spring.datasource.hikari.maximum-pool-size=2 --spring.datasource.hikari.connection-timeout=5000 và ab -n 20 -c 2 làm hỏng 9 trên 20 request và mất 25,2 giây cho 20 lệnh rút tiền (load average 2,99). Log của lần deadlock đầu tiên, dùng pattern [%thread] %logger{0}: %msg%n và cắt còn các dòng transaction cùng lỗi của pool:
[http-nio-8206-exec-2] JpaTransactionManager: Creating new transaction with name [com.example.demo.account.AccountService.withdraw]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[http-nio-8206-exec-2] JpaTransactionManager: Opened new EntityManager [SessionImpl(1827638077<open>)] for JPA transaction
[http-nio-8206-exec-3] JpaTransactionManager: Creating new transaction with name [com.example.demo.account.AccountService.withdraw]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[http-nio-8206-exec-3] JpaTransactionManager: Opened new EntityManager [SessionImpl(230918494<open>)] for JPA transaction
[http-nio-8206-exec-2] JpaTransactionManager: Participating in existing transaction
[http-nio-8206-exec-3] JpaTransactionManager: Participating in existing transaction
[http-nio-8206-exec-3] JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
[http-nio-8206-exec-2] JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
[http-nio-8206-exec-3] JpaTransactionManager: Opened new EntityManager [SessionImpl(1111063725<open>)] for JPA transaction
[http-nio-8206-exec-2] JpaTransactionManager: Opened new EntityManager [SessionImpl(588604139<open>)] for JPA transaction
[http-nio-8206-exec-2] error: HikariPool-1 - Connection is not available, request timed out after 5006ms (total=2, active=2, idle=0, waiting=0)
[http-nio-8206-exec-3] error: HikariPool-1 - Connection is not available, request timed out after 5006ms (total=2, active=2, idle=0, waiting=0)
[http-nio-8206-exec-2] JpaTransactionManager: Initiating transaction rollback
[http-nio-8206-exec-3] JpaTransactionManager: Initiating transaction rollback
[http-nio-8206-exec-2] JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1827638077<open>)]
[http-nio-8206-exec-3] JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(230918494<open>)]Cả hai thread bắt đầu withdraw và mỗi thread lấy một connection: pool hai connection hết sạch. Cả hai rồi suspend transaction của mình để gọi requiresNew và chờ connection thứ hai, thứ chỉ có thể đến từ thread kia, vốn cũng đang chờ. Không có gì nhúc nhích trong 5 giây, rồi cả hai inner transaction không mở được, và CannotCreateTransactionException rollback cả hai lệnh rút tiền. Các số SessionImpl trong dòng rollback là của outer transaction. Pool một connection là trường hợp suy biến: một request duy nhất, chạy một mình, nhận HTTP/1.1 500 sau 2,12 giây với connection-timeout=2000 (load average 2,78; Flyway được tắt cho lần chạy đó, vì với một connection nó không lấy được connection thứ hai cần lúc khởi động và application không start được).

Phép tính, và cách sửa
Một request có transaction lồng nhau sâu d tầng giữ tối đa d connection cùng lúc. Với n request đang chạy và pool m connection, pool chắc chắn không bao giờ deadlock khi m ≥ n × (d − 1) + 1, đúng công thức trang "About Pool Sizing" của HikariCP đưa ra cho tình huống này: khi đó luôn có ít nhất một request lấy đủ connection nó cần, chạy xong và trả lại. Ở đây d = 2, nên 10 request song song cần 11 connection. Đo với maximum-pool-size=11 và -c 10, rồi maximum-pool-size=21 và -c 20:
| Pool | Request song song | Lỗi trên 2.000 | Thời gian | Load average |
|---|---|---|---|---|
| 10 (mặc định) | 9 | 0 | 1,602 s | 4,18 |
| 10 (mặc định) | 10 | 10 | 32,814 s | 4,00 |
| 11 | 10 | 0 | 1,672 s | 5,52 |
| 11 | 11 | 0 | 1,481 s | 5,52 |
| 21 | 20 | 0 | 1,674 s | 2,94 |
Hàng thứ tư là lý do lỗi này lọt tới production: khi n = m, deadlock cần mọi connection bị outer transaction chiếm đúng cùng một lúc, nên một lần chạy có thể qua, như lần này, và lần sau lại hỏng. Tomcat của Spring Boot xử lý tới 200 request cùng lúc (server.tomcat.threads.max, mặc định 200), nhiều hơn hẳn 10 connection.
- Đừng giữ một connection trong lúc chờ connection khác. Gọi phần việc
REQUIRES_NEWtrước khi outer transaction bắt đầu hoặc sau khi nó đã trả về, từ một caller không transactional. Một listenerAFTER_COMMITkhông phải chỗ đó:OuterService.writeAfterCommitđăng ký một synchronization màafterCommitcủa nó gọirequiresNew, và probe log raREQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=584 hikariActive=2sauCommitting JPA transactionvà trước dòngClosing JPA EntityManager after transactioncủa outer. Outer transaction đã commit vẫn còn giữ connection của nó. - Nếu audit row được phép rollback cùng thay đổi nghiệp vụ, bỏ
REQUIRES_NEWvà để nó tham gia transaction; mỗi request một connection. - Tính kích thước pool theo công thức khi không tránh được việc lồng transaction, và nhớ rằng
NOT_SUPPORTEDcó truy cập database cũng tính là connection thứ hai. - Giảm
connection-timeoutđể deadlock hỏng sau một giây thay vì giữ mọi thread 30 giây. Nó không xoá deadlock, chỉ làm nó ngắn lại.
REQUIRES_NEW cho ra hai local transaction độc lập, không phải một đơn vị atomic: nếu outer hỏng sau khi inner đã commit, không có gì hoàn tác inner. Phối hợp công việc qua nhiều transaction hay nhiều service là chủ đề của Saga pattern ở Chương 11 của khoá này.
Isolation level trên PostgreSQL 18
@Transactional(isolation = …) đến PostgreSQL như thế nào
ISOLATION_DEFAULT nghĩa là "database làm gì thì làm vậy":
docker exec sba-a6-pg psql -U demo -d demo -c 'show default_transaction_isolation' default_transaction_isolation
-------------------------------
read committed
(1 row)Một method có @Transactional(isolation = Isolation.REPEATABLE_READ), với statement log của PostgreSQL đang bật:
[156] LOG: execute <unnamed>: SHOW TRANSACTION ISOLATION LEVEL
[156] LOG: execute <unnamed>: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL REPEATABLE READ
[156] LOG: statement: BEGIN
[156] LOG: execute <unnamed>: select current_setting('transaction_isolation')
[156] LOG: execute <unnamed>: select current_setting('transaction_read_only')
[156] LOG: execute S_1: COMMIT
[156] LOG: execute <unnamed>: SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL READ COMMITTEDHibernateJpaDialect gọi Connection.setTransactionIsolation trước khi transaction bắt đầu, và driver PostgreSQL biến lời gọi đó thành SET SESSION CHARACTERISTICS, sau khi đọc level hiện tại để Spring khôi phục lại được. Sau commit, connection được đặt về READ COMMITTED trước khi quay lại pool. Một isolation level không mặc định vì vậy tốn thêm ba round trip cho mỗi transaction, và chỉ có hiệu lực khi một transaction bắt đầu: phần "Inner method yêu cầu isolation level khác" bên dưới cho thấy chuyện gì xảy ra khi nó tham gia một transaction có sẵn.
READ COMMITTED: non-repeatable read, và entity che mất nó
Mọi anomaly bên dưới dùng hai thread, tx-A và tx-B, mỗi thread gọi một method transactional của IsolationLab, với các CountDownLatch truyền vào để ép thứ tự đan xen. tx-A đọc account 1 hai lần; giữa hai lần đọc, tx-B nạp 50 và commit:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void readTwiceReadCommitted(Long id, CountDownLatch firstReadDone, CountDownLatch otherCommitted) {
readTwice(id, firstReadDone, otherCommitted);
}
private void readTwice(Long id, CountDownLatch firstReadDone, CountDownLatch otherCommitted) {
Account account = accounts.findById(id).orElseThrow();
log.info("first read: entity {}, query {}", account.getBalance(), accounts.balanceOf(id));
firstReadDone.countDown();
await(otherCommitted);
Account again = accounts.findById(id).orElseThrow();
log.info("second read: entity {}, query {}", again.getBalance(), accounts.balanceOf(id));
}
@Transactional
public void deposit(Long id, BigDecimal amount) {
Account account = accounts.findById(id).orElseThrow();
account.deposit(amount);
log.info("deposit {} into account {}", amount, id);
}Log, cắt còn các transaction, SQL và kết quả:
[tx-A] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.readTwiceReadCommitted]: PROPAGATION_REQUIRED,ISOLATION_READ_COMMITTED
[tx-A] SQL: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=?
[tx-A] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-A] IsolationLab: first read: entity 100.00, query 100.00
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.deposit]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
[tx-B] SQL: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=?
[tx-B] IsolationLab: deposit 50.00 into account 1
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(19897183<open>)]
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-A] IsolationLab: second read: entity 100.00, query 150.00
[tx-A] JpaTransactionManager: Initiating transaction commitQuery thấy 100.00 rồi 150.00 trong cùng một transaction: một non-repeatable read, điều READ COMMITTED cho phép vì mỗi statement lấy một snapshot mới. Entity thì báo 100.00 cả hai lần, và lần findById thứ hai không gửi SQL nào: nó trả về instance đã có trong persistence context. Đó không phải isolation mà là first-level cache, và nó chỉ đúng với entity đã được load; một query trả về giá trị, như balanceOf, vẫn thấy thay đổi đã commit.
READ COMMITTED: lost update
Hai lệnh rút 30.00 từ cùng một account có 100.00. Cả hai thread đọc số dư, rồi tx-A ghi và commit, rồi tx-B ghi:
@Transactional(isolation = Isolation.READ_COMMITTED)
public void withdrawReadCommitted(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
withdraw(id, amount, bothRead, mayWrite);
}
private void withdraw(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
Account account = accounts.findById(id).orElseThrow();
log.info("read balance {}", account.getBalance());
arriveAndAwait(bothRead);
await(mayWrite);
account.withdraw(amount);
log.info("withdraw {}, balance now {} (flushed at commit)", amount, account.getBalance());
}bothRead là một CountDownLatch(2) mà mỗi thread đếm xuống rồi chờ; mayWrite đã mở sẵn cho tx-A, và là latch mà tx-A mở cho tx-B sau khi commit. Runner:
CountDownLatch bothRead = new CountDownLatch(2);
CountDownLatch aCommitted = new CountDownLatch(1);
CountDownLatch go = new CountDownLatch(0);
BigDecimal amount = new BigDecimal("30.00");
twoThreads(
() -> {
try {
if (rr) iso.withdrawRepeatableRead(1L, amount, bothRead, go);
else iso.withdrawReadCommitted(1L, amount, bothRead, go);
log.info("committed");
} finally {
aCommitted.countDown();
}
},
() -> {
if (rr) iso.withdrawRepeatableRead(1L, amount, bothRead, aCommitted);
else iso.withdrawReadCommitted(1L, amount, bothRead, aCommitted);
log.info("committed");
});[tx-A] IsolationLab: read balance 100.00
[tx-B] IsolationLab: read balance 100.00
[tx-A] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-A] JpaTransactionManager: Initiating transaction commit
[tx-A] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(1393197551<open>)]
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(44491633<open>)]
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] Lab: committed
[main] Lab: balances: [1=70.00, 2=100.00, 3=100.00]Hai lệnh rút 30.00 đều commit và số dư là 70.00: một lệnh đã bị mất, không có lỗi nào ở đâu cả. Câu UPDATE của Hibernate ghi giá trị tuyệt đối nó tính ra, balance=?, từ một lần đọc đã cũ vào lúc câu lệnh chạy. Optimistic locking với @Version và pessimistic lock là cách sửa thường gặp, và bài tiếp theo nói về cả hai; isolation level là cách còn lại, ngay bên dưới.
REPEATABLE READ: một snapshot, và SQLSTATE 40001
Hai kịch bản trên với @Transactional(isolation = Isolation.REPEATABLE_READ). Lần đọc thứ hai của tx-A:
[tx-A] IsolationLab: second read: entity 100.00, query 100.00REPEATABLE READ trên PostgreSQL lấy một snapshot ở statement đầu tiên và giữ nó, nên khoản nạp đã commit của tx-B vẫn vô hình với tx-A. Lost update, cắt còn phần cuối của tx-B:
[tx-B] IsolationLab: withdraw 30.00, balance now 70.00 (flushed at commit)
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] error: HHH000247: ErrorCode: 0, SQLState: 40001
[tx-B] error: ERROR: could not serialize access due to concurrent update
[tx-B] JpaTransactionManager: Initiating transaction rollback after commit exception
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: could not execute statement [ERROR: could not serialize access due to concurrent update] [update accounts set balance=?,kind=?,owner=? where id=?]; SQL [update accounts set balance=?,kind=?,owner=? where id=?]
[tx-B] Lab: caused by org.hibernate.exception.LockAcquisitionException: could not execute statement [ERROR: could not serialize access due to concurrent update] [update accounts set balance=?,kind=?,owner=? where id=?]
[tx-B] Lab: caused by org.postgresql.util.PSQLException: ERROR: could not serialize access due to concurrent update
[main] Lab: balances: [1=70.00, 2=100.00, 3=100.00]PostgreSQL không cho tx-B update một row mà transaction khác đã sửa sau snapshot của tx-B: SQLSTATE 40001, could not serialize access due to concurrent update. Số dư lại là 70.00, nhưng lần này là đúng: một lệnh rút commit, lệnh kia hỏng rõ ràng. Có hai chi tiết quan trọng với code xử lý nó:
- Lỗi đến lúc commit. Hibernate flush câu UPDATE trong
Committing JPA transaction, nên exception được proxy ném ra sau khi thân method đã trả về, không phải từaccount.withdraw. - Spring dịch nó thành
CannotAcquireLockException, thông quaLockAcquisitionExceptioncủa Hibernate. Trong spring-tx 7.0.9, cây kế thừa của nó làCannotAcquireLockException→PessimisticLockingFailureException→ConcurrencyFailureException→TransientDataAccessException. Cái tên gợi ý một lần chờ lock quá hạn, cònCannotSerializeTransactionExceptioncũ, vốn mô tả đúng hơn, đã deprecated từ 6.0.3 và không được dùng.
SERIALIZABLE: write skew bị từ chối
Ngân hàng cho phép một trong hai account của khách hàng âm, miễn tổng vẫn lớn hơn hoặc bằng không. an có hai account, mỗi account 100.00. tx-A rút 150.00 từ checking, tx-B rút 150.00 từ savings, mỗi bên đều kiểm tra tổng trước:
@Transactional(isolation = Isolation.SERIALIZABLE)
public void withdrawKeepingTotalSerializable(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
withdrawKeepingTotal(id, amount, bothRead, mayWrite);
}
private void withdrawKeepingTotal(Long id, BigDecimal amount, CountDownLatch bothRead, CountDownLatch mayWrite) {
Account account = accounts.findById(id).orElseThrow();
BigDecimal total = accounts.totalBalance(account.getOwner());
log.info("total balance of {} is {}, withdrawing {} from account {}", account.getOwner(), total, amount, id);
if (total.compareTo(amount) < 0) {
throw new InsufficientFundsException(account.getOwner(), total, amount);
}
arriveAndAwait(bothRead);
await(mayWrite);
account.withdraw(amount);
}withdrawKeepingTotalRepeatableRead giống hệt nhưng dùng REPEATABLE_READ. Dưới REPEATABLE READ, đã cắt bớt:
[tx-B] IsolationLab: total balance of an is 200.00, withdrawing 150.00 from account 2
[tx-A] IsolationLab: total balance of an is 200.00, withdrawing 150.00 from account 1
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] Lab: committed
[main] Lab: balances: [1=-50.00, 2=-50.00, 3=100.00]Cả hai lần kiểm tra đều qua trên cùng một snapshot, hai transaction update hai row khác nhau nên không có xung đột row nào, và tổng là −100.00. Đó là write skew: từng transaction đều nhất quán, nhưng gộp lại thì vi phạm quy tắc. Dưới SERIALIZABLE, đã cắt bớt:
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] Lab: committed
[tx-B] JpaTransactionManager: Initiating transaction commit
[tx-B] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-B] error: HHH000247: ErrorCode: 0, SQLState: 40001
[tx-B] error: ERROR: could not serialize access due to read/write dependencies among transactions
Detail: Reason code: Canceled on identification as a pivot, during write.
Hint: The transaction might succeed if retried.
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: could not execute statement [ERROR: could not serialize access due to read/write dependencies among transactions
Detail: Reason code: Canceled on identification as a pivot, during write.
Hint: The transaction might succeed if retried.] [update accounts set balance=?,kind=?,owner=? where id=?]; SQL [update accounts set balance=?,kind=?,owner=? where id=?]
[main] Lab: balances: [1=-50.00, 2=100.00, 3=100.00]PostgreSQL theo dõi những gì mỗi transaction serializable đã đọc: mỗi phép tính tổng đã đọc đúng row mà transaction kia sau đó ghi, một phụ thuộc đọc/ghi theo cả hai chiều, và PostgreSQL huỷ tx-B: lại là SQLSTATE 40001, với message khác và một Hint nói rõ phải làm gì. Biến thể thứ hai flush cả hai câu UPDATE trước khi transaction nào commit. Khi đó cả hai statement đều thành công, và lỗi chuyển sang chính lúc commit:
[tx-B] Lab: threw org.springframework.dao.CannotAcquireLockException: Hibernate transaction: Unable to commit against JDBC Connection; ERROR: could not serialize access due to read/write dependencies among transactions
Detail: Reason code: Canceled on identification as a pivot, during commit attempt.
Hint: The transaction might succeed if retried.Message khác, từ chỗ khác, connection.commit() thay vì một statement, và vẫn là CannotAcquireLockException, nên xử lý ConcurrencyFailureException là bao được cả hai. Hai kịch bản đầu chạy dưới SERIALIZABLE cho ra second read: entity 100.00, query 100.00 và lỗi could not serialize access due to concurrent update cho lệnh rút thứ hai, giống REPEATABLE READ.
READ UNCOMMITTED chạy như READ COMMITTED
tx-A rút 30.00, flush câu UPDATE và chờ, chưa commit; tx-B đọc với READ_UNCOMMITTED:
@Transactional(isolation = Isolation.READ_UNCOMMITTED)
public void readUncommitted(Long id) {
String level = jdbc.sql("select current_setting('transaction_isolation')").query(String.class).single();
log.info("transaction_isolation = {}, balance {}", level, accounts.balanceOf(id));
}Đã cắt bớt:
[tx-A] SQL: update accounts set balance=?,kind=?,owner=? where id=?
[tx-A] IsolationLab: flushed balance 70.00, not committed
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.readUncommitted]: PROPAGATION_REQUIRED,ISOLATION_READ_UNCOMMITTED
[tx-B] SQL: select a1_0.balance from accounts a1_0 where a1_0.id=?
[tx-B] IsolationLab: transaction_isolation = read uncommitted, balance 100.00
[tx-A] JpaTransactionManager: Initiating transaction rollback
[main] Lab: balances: [1=100.00, 2=100.00, 3=100.00]PostgreSQL chấp nhận level đó và báo lại đúng nó, read uncommitted, nhưng không cho tx-B thấy con số 70.00 chưa commit. Nó không có dirty read ở level nào; READ UNCOMMITTED chạy như READ COMMITTED.
Mỗi level cho phép gì trên PostgreSQL 18
| Level | Non-repeatable read (query) | Lost update | Write skew |
|---|---|---|---|
READ_COMMITTED (mặc định) | xảy ra: 100.00, rồi 150.00 | xảy ra: 70.00 sau hai lệnh rút 30.00 | không chạy ở level này |
REPEATABLE_READ | bị chặn: 100.00 hai lần | bị chặn: 40001, could not serialize access due to concurrent update | xảy ra: tổng −100.00 |
SERIALIZABLE | bị chặn: 100.00 hai lần | bị chặn: cùng lỗi 40001 | bị chặn: 40001, read/write dependencies among transactions |
Mọi lỗi 40001 tới Spring đều là CannotAcquireLockException. Các level mạnh hơn không chặn những anomaly này bằng cách bắt một transaction chờ: chúng khiến PostgreSQL làm hỏng một trong hai transaction, và transaction đó phải được retry.

Inner method yêu cầu isolation level khác
Isolation được áp dụng khi một transaction bắt đầu. Một method có @Transactional(isolation = Isolation.SERIALIZABLE) được gọi từ một transaction mặc định sẽ tham gia vào đó; đã cắt bớt:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.SettingsLab.defaultCallingSerializable]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
InnerOps: inner SERIALIZABLE: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false
JpaTransactionManager: Initiating transaction commitMethod yêu cầu SERIALIZABLE đã chạy dưới read committed, và không có gì báo điều đó. TxSettings là probe thứ hai, in ra transaction_isolation và transaction_read_only của PostgreSQL, cờ read-only của JDBC connection và flush mode của Hibernate session; phần readOnly dùng tới phần còn lại. AbstractPlatformTransactionManager có một công tắc cho trường hợp này, validateExistingTransaction, mặc định tắt và không có property nào của Spring Boot; customizer thứ hai của lab ở trên bật nó. Cùng lời gọi đó với --lab.validate-existing=true:
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Participating transaction with definition [PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE] specifies isolation level which is incompatible with existing transaction: (unknown)(unknown) là vì outer transaction dùng ISOLATION_DEFAULT, thứ Spring không ghi nhận như một level. Method nào phụ thuộc vào isolation level thì hoặc phải tự mở transaction (REQUIRES_NEW, kèm connection của nó), hoặc phải được gọi từ một transaction đã chạy ở level đó, và validateExistingTransaction biến trường hợp im lặng thành một lỗi.
Retry khi gặp serialization failure
Lỗi 40001 là PostgreSQL bảo "hãy retry", và retry phải lặp lại cả transaction: transaction mới, snapshot mới, đọc lại từ đầu. Vì vậy nó nằm bên ngoài method @Transactional, trong một caller không transactional:
@Component
public class SerializationRetry {
private static final Logger log = LoggerFactory.getLogger(SerializationRetry.class);
private static final int MAX_ATTEMPTS = 3;
// transaction must call a @Transactional method on another bean: each attempt is a new transaction
public void run(Runnable transaction) {
for (int attempt = 1; ; attempt++) {
try {
transaction.run();
return;
} catch (ConcurrencyFailureException e) {
if (attempt == MAX_ATTEMPTS) {
throw e;
}
log.info("attempt {} failed with {}, retrying", attempt, e.getClass().getSimpleName());
}
}
}
}Lại là write skew, cả hai thread gọi retry.run(() -> iso.withdrawKeepingTotalSerializable(...)); phần cuối của tx-B, đã cắt bớt:
[tx-B] error: ERROR: could not serialize access due to read/write dependencies among transactions
[tx-B] SerializationRetry: attempt 1 failed with CannotAcquireLockException, retrying
[tx-B] JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.IsolationLab.withdrawKeepingTotalSerializable]: PROPAGATION_REQUIRED,ISOLATION_SERIALIZABLE
[tx-B] SQL: select sum(a1_0.balance) from accounts a1_0 where a1_0.owner=?
[tx-B] IsolationLab: total balance of an is 50.00, withdrawing 150.00 from account 2
[tx-B] JpaTransactionManager: Initiating transaction rollback
[tx-B] Lab: threw com.example.demo.account.InsufficientFundsException: total balance of an is 50.00, cannot withdraw 150.00Lần thử thứ hai chạy trong transaction mới, thấy tổng đã commit là 50.00 và từ chối lệnh rút bằng exception nghiệp vụ, vốn không phải ConcurrencyFailureException nên không được retry. Đó là kết quả đúng. Cùng retry.run đó đặt bên trong một method @Transactional(isolation = Isolation.SERIALIZABLE), retryInsideTheTransaction, không bao giờ log ra attempt 1 failed: câu UPDATE được flush lúc commit, sau khi method và vòng lặp của nó đã trả về, nên CannotAcquireLockException đi thẳng tới caller.
Giữ số lần thử ít và transaction ngắn, và đừng retry những việc có side effect ngoài database, như gửi email, trừ khi nó idempotent. @Retryable của chính Spring Framework 7 trong org.springframework.resilience.annotation, có backoff và jitter, thay được một vòng lặp tự viết như thế này; nó được trình bày ở bài 19 của khoá này.
readOnly ngoài những gì Basics 30 đã nói
readOnly = true thay đổi gì ở từng tầng
Bài 30 Basics đã cho thấy hệ quả: entity bị sửa không được ghi, và PostgreSQL từ chối một câu INSERT. SettingsLab in ra các thiết lập đứng sau những hệ quả đó, trong một transaction read-write và một transaction read-only, sau khi load account 1:
@Transactional(readOnly = true)
public void readOnly() {
Account account = accounts.findById(1L).orElseThrow();
log.info("readOnly: {} | snapshot kept={}", settings.describe(), snapshotKept(account));
}
private boolean snapshotKept(Account account) {
SessionImplementor session = em.unwrap(SessionImplementor.class);
return session.getPersistenceContextInternal().getEntry(account).getLoadedState() != null;
}SettingsLab: read-write: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false | snapshot kept=true
SettingsLab: readOnly: postgres isolation=read committed read_only=on | jdbc readOnly=true | hibernate flushMode=MANUAL defaultReadOnly=true | snapshot kept=falseVà statement log của PostgreSQL cho hai transaction đó, cắt còn các câu lệnh mở và đóng transaction cùng query lấy entity:
[156] LOG: statement: BEGIN
[156] LOG: execute <unnamed>: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=$1
[156] LOG: execute S_1: COMMIT
[156] LOG: execute <unnamed>: delete from audit_entries
[156] LOG: statement: BEGIN READ ONLY
[156] LOG: execute <unnamed>: select a1_0.id,a1_0.balance,a1_0.kind,a1_0.owner from accounts a1_0 where a1_0.id=$1
[156] LOG: execute S_1: COMMIT| Tầng | Read-write | readOnly = true |
|---|---|---|
| JDBC connection | isReadOnly() false | setReadOnly(true): isReadOnly() true |
| PostgreSQL | BEGIN, transaction_read_only off | BEGIN READ ONLY, transaction_read_only on: thao tác ghi hỏng với SQLSTATE 25006 |
| Flush mode của Hibernate | AUTO | MANUAL: không tự động flush gì, kể cả lúc commit |
| Hibernate session | defaultReadOnly false | defaultReadOnly true: entity được load ở chế độ read-only |
| Snapshot trạng thái đã load của mỗi entity | được giữ, để dirty checking | không giữ: ít bộ nhớ hơn cho mỗi entity đã load |
Driver PostgreSQL tự gửi BEGIN READ ONLY, từ cờ read-only của connection; không có câu SET TRANSACTION READ ONLY riêng nào xuất hiện. Dòng snapshot là phần khiến readOnly đáng dùng cho các thao tác đọc load nhiều entity: Hibernate không giữ bản sao trạng thái đã load của chúng, vì nó sẽ không bao giờ so sánh với bản sao đó.
readOnly khi transaction tham gia vào nhau
Giống isolation, readOnly được áp dụng khi một transaction bắt đầu. Một method có @Transactional(readOnly = true) nạp 5.00, được gọi từ một transaction read-write (log bên dưới đã cắt bớt):
@Transactional(readOnly = true)
public void readOnlyDeposit(Long id) {
Account account = accounts.findById(id).orElseThrow();
account.deposit(new BigDecimal("5.00"));
log.info("inner readOnly: {}", settings.describe());
}JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.SettingsLab.readWriteCallingReadOnly]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
InnerOps: inner readOnly: postgres isolation=read committed read_only=off | jdbc readOnly=false | hibernate flushMode=AUTO defaultReadOnly=false
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Committing JPA transaction on EntityManager [SessionImpl(323302928<open>)]
SQL: update accounts set balance=?,kind=?,owner=? where id=?
Lab: committed audit rows: []
Lab: balances: [1=105.00, 2=100.00, 3=100.00]Cờ readOnly của method tham gia bị bỏ qua: flush mode AUTO, read_only=off, và thay đổi được ghi xuống. Chiều ngược lại là chiều mà bài 30 Basics gặp với save: một readWriteDeposit read-write được gọi từ transaction readOnly = true chạy với read_only=on và flushMode=MANUAL, và khoản nạp của nó bị bỏ im lặng, số dư vẫn là 105.00. Khi bật validateExistingTransaction, trường hợp thứ hai hỏng thay vì im lặng, còn trường hợp thứ nhất vẫn được phép:
Lab: threw org.springframework.transaction.IllegalTransactionStateException: Participating transaction with definition [PROPAGATION_REQUIRED,ISOLATION_DEFAULT] is not marked as read-only but existing transaction isRollback rules qua ranh giới transaction
Exception trong REQUIRES_NEW chỉ rollback transaction bên trong
OuterService có thêm hai method cho phần này: catching insert row của nó rồi gọi inner method trong một khối try bắt AuditFailedException, còn failingAfter insert row của nó, gọi inner method, rồi throw IllegalStateException:
@Transactional
public void catching(String label, Runnable inner) {
jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
try {
inner.run();
} catch (AuditFailedException e) {
log.info("outer caught: {}", e.getMessage());
}
}
@Transactional
public void failingAfter(String label, Runnable inner) {
jdbc.sql("insert into audit_entries (message) values (?)").param("outer " + label).update();
inner.run();
throw new IllegalStateException("outer failed after the inner call");
}Một requiresNew hỏng bên trong catching, rồi một requiresNew thành công bên trong failingAfter, đã cắt bớt:
Lab: === REQUIRES_NEW fails, outer catches ===
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(1337277302<open>)]
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
OuterService: outer caught: audit failed: requires-new
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [outer requires-new]
Lab: === outer fails after REQUIRES_NEW ===
JpaTransactionManager: Suspending current transaction, creating new transaction with name [com.example.demo.audit.AuditService.requiresNew]
AuditService: REQUIRES_NEW: actualTx=true name=AuditService.requiresNew pid=438 hikariActive=2
JpaTransactionManager: Initiating transaction commit
JpaTransactionManager: Resuming suspended transaction after completion of inner transaction
JpaTransactionManager: Initiating transaction rollback
JpaTransactionManager: Rolling back JPA transaction on EntityManager [SessionImpl(837508822<open>)]
Lab: threw java.lang.IllegalStateException: outer failed after the inner call
Lab: committed audit rows: [requires-new]Mỗi proxy áp rollback rules của method nó bọc lên transaction mà nó quản lý. Inner proxy rollback inner transaction, một transaction riêng, nên không có gì để đánh dấu ở outer, và exception đã bị bắt để outer tự do commit. Lỗi của outer rollback row của outer và không đụng được tới row của inner, vốn đã commit. Khác với trường hợp REQUIRED của bài 30 Basics, không có UnexpectedRollbackException nào.
noRollbackFor trên method tham gia giữ outer transaction sống
Dấu rollback-only đến từ việc inner proxy quyết định "rollback" cho một transaction mà nó chỉ tham gia. Khi rule của chính inner method nói "không rollback", sẽ không có gì để đánh dấu. requiredNoRollback mang @Transactional(noRollbackFor = AuditFailedException.class) và throw bên trong catching; đã cắt bớt:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.OuterService.catching]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Participating in existing transaction
AuditService: REQUIRED, noRollbackFor: actualTx=true name=OuterService.catching pid=437 hikariActive=1
OuterService: outer caught: audit failed: no-rollback
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [outer no-rollback, no-rollback]Không có dòng Participating transaction failed - marking existing transaction as rollback-only, commit bình thường, và cả hai row được ghi, gồm cả row của inner: noRollbackFor giữ lại phần việc inner method đã làm trước khi throw. Đây là công cụ đúng khi exception báo một kết quả, không phải một trạng thái hỏng.
rollbackOn = ALL_EXCEPTIONS: checked exception cũng rollback
Bài 30 Basics đã cho thấy một checked exception sẽ commit trừ khi rollbackFor chỉ đích danh nó. Spring Framework 6.2 thêm một công tắc toàn cục, có trong 7.0.9 dưới dạng EnableTransactionManagement.rollbackOn() với hai giá trị RUNTIME_EXCEPTIONS và ALL_EXCEPTIONS. Spring Boot 4.1.1 không có property nào cho nó: metadata spring.transaction.* trong spring-boot-transaction-4.1.1.jar chỉ liệt kê default-timeout và rollback-on-commit-failure. Muốn dùng phải tự khai báo annotation, và cấu hình @EnableTransactionManagement của chính Boot sẽ nhường chỗ, vì nó chỉ có hiệu lực khi chưa có bean AbstractTransactionManagementConfiguration nào:
@Configuration
@ConditionalOnBooleanProperty("lab.rollback-on-all")
@EnableTransactionManagement(rollbackOn = RollbackOn.ALL_EXCEPTIONS)
class RollbackOnConfig {
} @Transactional
public void importStatement() throws IOException {
jdbc.sql("insert into audit_entries (message) values ('import started')").update();
throw new IOException("statement file truncated");
}
@Transactional(noRollbackFor = IOException.class)
public void importStatementNoRollback() throws IOException {
jdbc.sql("insert into audit_entries (message) values ('import started')").update();
throw new IOException("statement file truncated");
}Không bật công tắc, rồi với --lab.rollback-on-all=true, đã cắt bớt:
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatement]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatementNoRollback]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+java.io.IOException
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatement]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,-java.lang.Exception
JpaTransactionManager: Initiating transaction rollback
Lab: committed audit rows: []
JpaTransactionManager: Creating new transaction with name [com.example.demo.lab.CheckedLab.importStatementNoRollback]: PROPAGATION_REQUIRED,ISOLATION_DEFAULT,+java.io.IOException,-java.lang.Exception
JpaTransactionManager: Initiating transaction commit
Lab: committed audit rows: [import started]Công tắc thêm rule -java.lang.Exception vào mọi transaction, và log in rule đó ra trong từng definition; checked IOException khi đó bị rollback. Một noRollbackFor = IOException.class khai báo rõ vẫn thắng, vì rule khớp gần nhất là rule quyết định. Các bean vẫn giữ proxy CGLIB khi có annotation tự khai báo này, kể cả một bean @Transactional implement một interface (CsvImporter$$SpringCGLIB$$0).
Transaction timeout: thứ gì thực sự áp dụng nó
@Transactional(timeout = 2) đặt một deadline hai giây sau khi transaction bắt đầu. TimeoutLab có năm method mang attribute đó; load average là 2,92 khi lần chạy bắt đầu:
@Transactional(timeout = 2)
public void slowQuery() {
accounts.slowReport(5);
}
@Transactional(timeout = 2)
public void slowJavaWithPendingUpdate() {
Account account = accounts.findById(1L).orElseThrow();
account.withdraw(new BigDecimal("10.00"));
sleep(3000);
}| Thân method | Trả về sau | Kết quả |
|---|---|---|
accounts.slowReport(5), pg_sleep(5) qua một repository | 2.024 ms | org.springframework.dao.QueryTimeoutException, SQLSTATE 57014 canceling statement due to user request, rollback |
select pg_sleep(5) qua JdbcClient | 2.018 ms | QueryTimeoutException: PreparedStatementCallback; SQL [select pg_sleep(5)]; ERROR: canceling statement due to user request |
findById, rồi Thread.sleep(3000), không có SQL nào sau đó | 3.008 ms | commit |
findById, Thread.sleep(3000), rồi balanceOf | 3.011 ms | TransactionTimedOutException: Transaction timed out: deadline was Fri Sep 18 11:56:47 ICT 2026, trước khi query được gửi đi |
findById, withdraw, Thread.sleep(3000), commit | 3.017 ms | JpaSystemException: Transaction timeout expired, do org.hibernate.TransactionException, rollback |
Timeout không phải một bộ hẹn giờ ngắt method giữa chừng. Nó là một deadline được kiểm tra ở ba chỗ khác nhau:
- Query timeout của JDBC trên mỗi statement. Hibernate và
JdbcClientđều đặt thời gian còn lại bằngStatement.setQueryTimeout, và PostgreSQL huỷpg_sleep(5)sau hai giây. Log cóSQLState: 57014vàERROR: canceling statement due to user request, và method trả về sau khoảng 2.020 ms thay vì 5.000. - Spring, trước khi một query chạy. Khi deadline đã qua, query tiếp theo của repository hỏng ngay với
TransactionTimedOutExceptionmà không tới database. - Hibernate, trước khi flush lúc commit. Câu UPDATE đang chờ được chuẩn bị rồi bị từ chối với
Transaction timeout expired, và lệnh rút tiền bị rollback: số dư vẫn là 100.00.
Một method tiêu thời gian bên ngoài database, như Thread.sleep ở đây, và không gửi statement nào sau deadline thì commit bình thường, ba giây vào một timeout hai giây. spring.transaction.default-timeout áp cùng deadline đó cho mọi transaction không có timeout riêng. Một method @Transactional thường gọi slowReport(5) trả về bình thường sau 5.037 ms (load 2,53); với --spring.transaction.default-timeout=2 nó hỏng sau 2.037 ms với cùng QueryTimeoutException (load 3,05). Definition trong log không hiện điều này, PROPAGATION_REQUIRED,ISOLATION_DEFAULT không có timeout_2, vì transaction manager chỉ áp giá trị mặc định của nó khi definition không có timeout.
FAQ
REQUIRED và REQUIRES_NEW trong Spring khác nhau thế nào?
REQUIRED tham gia transaction đang chạy trên thread, nên cả hai method dùng chung một connection, một lần commit và một số phận; một exception trong inner method đánh dấu cả transaction là rollback-only. REQUIRES_NEW suspend nó và mở một transaction độc lập trên connection thứ hai: đo được, inner method chạy trên backend pid 438 của PostgreSQL với 2 connection HikariCP bị giữ trong khi outer giữ pid 437, và mỗi transaction tự commit hoặc rollback.
Propagation NESTED có chạy với Spring Data JPA không?
Không, với JpaTransactionManager mà Spring Boot cấu hình. Trên Spring Boot 4.1.1 với Hibernate 7.4.5 nó ném NestedTransactionNotSupportedException: Transaction manager does not allow nested transactions by default, và sau setNestedTransactionAllowed(true) thì JpaDialect does not support savepoints - check your JPA provider's capabilities. JdbcTransactionManager hỗ trợ nó bằng savepoint thật: SAVEPOINT "SAVEPOINT_1" và ROLLBACK TO SAVEPOINT "SAVEPOINT_1" trong log của PostgreSQL.
Vì sao REQUIRES_NEW gây lỗi "Connection is not available, request timed out"?
Mỗi request giữ một connection của pool cho outer transaction trong lúc chờ connection thứ hai cho transaction REQUIRES_NEW. Khi số request bằng số connection của pool cùng lấy connection đầu tiên một lúc, không request nào lấy được connection thứ hai. Với pool mặc định 10 connection của HikariCP, 10 request song song làm hỏng 10 trên 2.000 lệnh rút tiền, mỗi lệnh sau khoảng 30 giây (request timed out after 30006ms), trong khi 9 request song song không hỏng lệnh nào. Pool n × (d − 1) + 1 connection, tức 11 cho 10 request lồng 2 tầng, không thể deadlock.
Isolation level mặc định của Spring Boot với PostgreSQL là gì?
Là của chính PostgreSQL: ISOLATION_DEFAULT không gửi gì, và show default_transaction_isolation trả về read committed trên PostgreSQL 18.6. Một isolation khác trên @Transactional khiến driver gửi SET SESSION CHARACTERISTICS AS TRANSACTION ISOLATION LEVEL … trước BEGIN và đặt lại level sau commit.
Spring ném exception nào khi PostgreSQL báo serialization failure?
org.springframework.dao.CannotAcquireLockException, cho cả could not serialize access due to concurrent update dưới REPEATABLE READ lẫn could not serialize access due to read/write dependencies among transactions dưới SERIALIZABLE, dù PostgreSQL báo lỗi ở câu UPDATE hay lúc commit. Nó là một ConcurrencyFailureException, đúng type cần bắt trong một vòng retry đặt quanh cả transaction.
@Transactional(timeout) có dừng một method chạy lâu không?
Không. Với JPA trên PostgreSQL, deadline được kiểm tra khi một statement chạy: một query chậm bị huỷ sau 2 giây với SQLSTATE 57014 và QueryTimeoutException, và một query sau deadline hỏng với TransactionTimedOutException. Một method sleep 3 giây mà không gửi SQL nào sau đó vẫn commit bình thường dù có timeout = 2.
readOnly = true có bị bỏ qua khi method tham gia transaction khác không?
Có. Một method @Transactional(readOnly = true) được gọi bên trong transaction read-write chạy với flush mode AUTO và transaction_read_only = off, và thay đổi entity của nó được ghi xuống. Ở chiều ngược lại, một method read-write bên trong transaction read-only bị mất thay đổi; setValidateExistingTransaction(true) biến trường hợp đó thành IllegalTransactionStateException.
Kết luận
Propagation quyết định inner method dùng transaction nào và connection nào: REQUIRED, SUPPORTS và MANDATORY tham gia trên cùng pid, REQUIRES_NEW và NOT_SUPPORTED suspend outer transaction và lấy connection thứ hai, còn NESTED hoàn toàn không chạy với JpaTransactionManager. Connection thứ hai là phần đắt: với pool mặc định 10 connection của HikariCP, mười lệnh rút tiền song song làm pool deadlock trong 30 giây, và 11 connection là đủ để điều đó không thể xảy ra.
Isolation level quyết định cái gì hỏng khi các transaction chồng lên nhau. READ COMMITTED mặc định của PostgreSQL cho phép non-repeatable read và lost update mà không có lỗi nào; REPEATABLE READ và SERIALIZABLE thay chúng bằng SQLSTATE 40001, thứ Spring báo là CannotAcquireLockException và chỉ một vòng retry quanh cả transaction mới giải quyết được. readOnly, isolation level và timeout đều có hiệu lực khi một transaction bắt đầu và bị bỏ qua bởi method tham gia vào transaction có sẵn, điều mà validateExistingTransaction có thể biến thành lỗi, còn timeout chỉ được kiểm tra khi một statement chạy.
Lost update là chỗ isolation level thôi là câu trả lời thực tế. Bài tiếp theo nói về locking và concurrency: optimistic lock với @Version, pessimistic lock, và cách xử lý race condition.