Transactions — @Transactional, propagation, rollback rules
@Transactional là 1 trong annotation quan trọng nhất Spring. Bài này bóc 7 propagation, 5 isolation level, rollback rules (RuntimeException vs checked), readOnly optimization, AOP proxy mechanism, self-invocation pitfall, transaction events, và testing transactional behavior.
Module 03 đã dùng @Transactional ở vài chỗ. Module 04 này bóc cụ thể: cơ chế AOP proxy, 7 propagation, 5 isolation, khi nào rollback, pitfall self-invocation. Sau bài này, bạn không bao giờ confuse "tại sao tx không rollback" hoặc "tại sao @Transactional không có effect".
Transaction là boundary của data consistency. Sai 1 chỗ → corrupted state production. Hiểu rõ.
1. ACID + transaction boundary
Transaction = đơn vị work atomic:
- Atomic: all or nothing.
- Consistent: state hợp lệ trước/sau.
- Isolated: concurrent tx không thấy intermediate state.
- Durable: committed → persist disk.
Spring @Transactional annotation declarative:
@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = new Order(req.customer(), req.total());
orderRepo.save(order); // INSERT order
for (OrderItem item : req.items()) {
inventoryService.reserve(item); // UPDATE inventory
}
paymentService.charge(order); // INSERT payment
notificationService.send(order); // INSERT notification
return order; // tx commit at method end
}
}
@Transactional wrap method với:
- Begin tx tại method entry.
- Commit tại method exit (success).
- Rollback nếu RuntimeException.
3 INSERT + 1 UPDATE atomic. Nếu paymentService.charge fail → rollback all 3.
2. AOP proxy mechanism
Module 01 bài 04 đã giới thiệu. Recap:
sequenceDiagram
participant Caller
participant Proxy as OrderService Proxy
participant TM as TransactionManager
participant Real as OrderService (real)
participant DB as PostgreSQL
Caller->>Proxy: placeOrder(req)
Proxy->>TM: getTransaction()
TM->>DB: BEGIN
Proxy->>Real: placeOrder(req)
Real->>DB: INSERT, UPDATE, ...
Real-->>Proxy: return Order
Proxy->>TM: commit()
TM->>DB: COMMIT
Proxy-->>Caller: return OrderSpring tạo CGLIB proxy của OrderService. Mọi method call qua proxy → proxy chèn tx logic. Self-call bypass proxy (Module 01 bài 04 pitfall).
3. Rollback rules
@Transactional
public void doWork() {
repo.save(...);
throw new RuntimeException("oops"); // tx rolls back
}
@Transactional
public void doWork2() throws IOException {
repo.save(...);
throw new IOException("oops"); // tx COMMIT (checked exception)
}
Default: rollback chỉ với RuntimeException + Error. Checked exception (IOException, SQLException) → tx commit.
Lý do: spec EJB legacy. Spring giữ default cho consistency.
3.1 Custom rollback
// Rollback cũng cho checked exception
@Transactional(rollbackFor = IOException.class)
// Don't rollback cho specific exception
@Transactional(noRollbackFor = NotificationFailedException.class)
// Combine
@Transactional(rollbackFor = Exception.class, noRollbackFor = WarningException.class)
Recommend: dùng RuntimeException cho mọi business exception. rollbackFor chỉ khi xử lý lib third-party throw checked.
public class OrderNotFoundException extends RuntimeException { } // OK
public class PaymentDeclinedException extends RuntimeException { } // OK
// Tránh:
public class BusinessException extends Exception { } // checked → tx commit (bug subtle)
4. Propagation — 7 type
propagation quyết định behavior khi tx đã active:
| Type | Behavior |
|---|---|
REQUIRED (default) | Join existing tx, hoặc new |
REQUIRES_NEW | Always new tx, suspend existing |
SUPPORTS | Join if exists, else no-tx |
NOT_SUPPORTED | Suspend existing, run no-tx |
MANDATORY | Must have existing, else throw |
NEVER | Throw if existing |
NESTED | Savepoint within existing |
4.1 REQUIRED — default
@Transactional // implicitly REQUIRED
public void serviceA() {
repo.saveA(...);
serviceB.doB(); // join same tx
}
@Transactional
public void doB() {
repo.saveB(...);
}
ServiceA invoke ServiceB → ServiceB join existing tx của A. Cả 2 commit/rollback together.
99% case dùng REQUIRED. Default suy đoán đúng.
4.2 REQUIRES_NEW — independent tx
@Service
public class OrderService {
@Transactional
public void place(...) {
repo.save(order);
try {
auditService.log(...); // separate tx
} catch (Exception e) {
log.error("Audit failed, but order proceeds", e);
// outer tx vẫn commit
}
}
}
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(AuditEntry entry) {
auditRepo.save(entry);
}
}
auditService.log chạy trong tx riêng. Fail audit → rollback chỉ audit, order tx tiếp tục.
Use case:
- Audit log không nên block business.
- Notification, analytics — best-effort.
- Idempotent retry — partial success OK.
4.3 NESTED — savepoint
@Transactional
public void doWork() {
repo.save(parentRecord);
try {
nestedWork();
} catch (Exception e) {
// Only nestedWork rolls back, parentRecord vẫn ở
}
}
@Transactional(propagation = Propagation.NESTED)
public void nestedWork() {
repo.save(childRecord);
if (someCondition) throw new RuntimeException();
}
NESTED dùng SQL savepoint. Postgres + MySQL support. Hibernate tự handle.
Use case rare — REQUIRES_NEW thường đủ.
5. Isolation level — 5 type
@Transactional(isolation = Isolation.REPEATABLE_READ)
5 level từ thấp đến cao:
| Level | Dirty read | Non-repeatable read | Phantom read |
|---|---|---|---|
READ_UNCOMMITTED | ✗ | ✗ | ✗ |
READ_COMMITTED (Postgres default) | ✓ | ✗ | ✗ |
REPEATABLE_READ (MySQL InnoDB default) | ✓ | ✓ | ✗ |
SERIALIZABLE | ✓ | ✓ | ✓ |
DEFAULT | DB default |
Postgres default = READ_COMMITTED. 99% case OK.
Higher isolation = more lock = lower concurrency. Cân nhắc trade-off.
5.1 Anomaly examples
Non-repeatable read:
-- Tx A
SELECT balance FROM accounts WHERE id = 1; -- 100
-- Tx B commits: UPDATE balance = 200
SELECT balance FROM accounts WHERE id = 1; -- 200 ← changed
REPEATABLE_READ prevent — Tx A thấy snapshot consistent.
Phantom read:
-- Tx A
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 10
-- Tx B commits: INSERT new pending order
SELECT COUNT(*) FROM orders WHERE status = 'PENDING'; -- 11 ← phantom
SERIALIZABLE prevent. Postgres REPEATABLE_READ ngăn dirty + non-repeatable nhưng không phantom (thực tế).
5.2 Recommend
// 99% case
@Transactional // default DB level
// Specific cases
@Transactional(isolation = Isolation.REPEATABLE_READ) // financial ledger
@Transactional(isolation = Isolation.SERIALIZABLE) // critical money transfer
Higher isolation rarely needed. Performance hit lớn — measure before adopt.
6. readOnly optimization
@Transactional(readOnly = true)
public ProjectDto findById(Long id) {
return repo.findById(id).map(ProjectDto::from).orElseThrow();
}
readOnly = true hint Hibernate:
- Skip dirty checking → faster.
- Set
FlushMode.MANUAL— flush không trigger. - Some DB optimize (Postgres set read-only mode, not enforce).
Class-level + method override:
@Service
@Transactional(readOnly = true) // class default: read-only
public class ProjectService {
public Project findById(Long id) { ... } // inherit readOnly = true
@Transactional // override: write tx
public Project create(...) { ... }
}
Pattern recommend: class readOnly = true, write method override.
7. Self-invocation pitfall (recap)
@Service
public class OrderService {
public void method1() {
method2(); // self-call, BYPASS proxy
}
@Transactional
public void method2() {
// tx KHONG active!
}
}
Self-call go through this, không qua proxy. @Transactional annotation không trigger.
Fix:
@Service
public class OrderService {
@Autowired
private OrderService self; // inject proxy of self
public void method1() {
self.method2(); // qua proxy
}
}
Hoặc tách 2 service.
8. Transaction events
Spring 4.2+ có @TransactionalEventListener listen lifecycle events:
@Service
public class OrderService {
@Transactional
public Order placeOrder(...) {
Order order = repo.save(...);
publisher.publishEvent(new OrderPlacedEvent(order));
return order;
}
}
@Component
public class OrderEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void handleOrderPlaced(OrderPlacedEvent event) {
emailService.sendConfirmation(event.order());
// Chi chay neu tx commit thanh cong
}
@TransactionalEventListener(phase = TransactionPhase.AFTER_ROLLBACK)
public void handleOrderFailed(OrderPlacedEvent event) {
log.warn("Order rolled back: {}", event.order().id());
}
}
4 phase: BEFORE_COMMIT, AFTER_COMMIT, AFTER_ROLLBACK, AFTER_COMPLETION.
Use case:
- Send email only after order success commit.
- Audit log lazy.
- Outbox pattern (Module 12).
9. Testing transactional
@Transactional trong test: rollback default sau mỗi test.
@SpringBootTest
@Transactional // rollback per test
class OrderServiceTest {
@Autowired OrderService service;
@Autowired EntityManager em;
@Test
void placeOrder_savesOrder() {
Order order = service.placeOrder(req);
em.flush(); // force SQL execute
em.clear(); // detach all entities
Order reloaded = repo.findById(order.id()).orElseThrow();
assertEquals(req.total(), reloaded.total());
}
// After test: tx rolls back, DB clean
}
@Transactional test default rollbackFor = Exception.class — every test isolated.
@Commit annotation override — keep changes:
@Test
@Commit
void seedTestData() {
repo.saveAll(testProjects);
}
Hữu ích cho @Sql setup file (Module 06 đào sâu).
10. Vận hành production — pool sizing, monitoring, failure runbook
Mỗi @Transactional method tại runtime hold 1 connection từ Hikari pool. Pool config sai → pod chết. Section này cover phần "operate": số liệu cụ thể, metric phải watch, runbook khi production lệch.
10.1 Connection pool sizing — công thức và multi-pod math
Hikari config quyết định concurrency tx ceiling per pod:
spring:
datasource:
hikari:
maximum-pool-size: 20 # max active connection per pod
minimum-idle: 5
connection-timeout: 30000 # 30s wait before throw
idle-timeout: 600000 # 10 min idle release
max-lifetime: 1800000 # 30 min max (must be less than DB wait_timeout)
leak-detection-threshold: 60000 # 60s log warning if connection held
validation-timeout: 5000
Công thức tham khảo PgTune (giới hạn upper):
pool_size = (core_count * 2) + effective_spindle_count
Production reality 2026:
- Microservice nhỏ (1-2 vCPU): pool 10.
- App standard (4 vCPU): pool 15-20.
- App lớn (8 vCPU): pool 20-30.
- Vượt 50 = anti-pattern. Bottleneck đã ở DB, không ở app.
Multi-pod math:
total_db_connections = pool_size * pod_count
Postgres default max_connections = 100. App 100 pod nhân pool 20 = 2000 connection → exceeds DB capacity (refuse new connection, lỗi "too many clients already").
Fix scaling:
- Tăng
max_connectionsPostgres lên 500-1000 (RAM cost khoảng 10MB per conn → 10GB cho 1000 conn). Vẫn giới hạn cứng. - PgBouncer (transaction-mode pooling) — proxy giữa app và Postgres. 100 pod nhân pool 20 connection client → PgBouncer multiplex xuống 50 server connection. Standard enterprise 2026.
- Read replica routing — read tx route sang replica qua AWS RDS Proxy / pgpool / Spring
AbstractRoutingDataSource.
flowchart LR
P1[Pod 1] -->|"pool 20"| PB[PgBouncer]
P2[Pod 2] -->|"pool 20"| PB
P3["Pod ...100"] -->|"pool 20"| PB
PB -->|"50 server conn"| DB[(Postgres primary)]
PB -->|"read tx"| RR[(Read replica)]
style PB fill:#fef3c710.2 Monitoring — 3 layer metric phải watch
Layer 1 — Hikari metrics (Micrometer auto-publish khi có Actuator):
| Metric | Ý nghĩa | Alert threshold |
|---|---|---|
hikaricp.connections.active | Connection đang dùng | Vượt 80% pool sustained → warning |
hikaricp.connections.idle | Idle connection | Dưới minimum-idle lâu → check workload |
hikaricp.connections.pending | Thread chờ connection | Vượt 0 sustained 1 phút → pool exhausted |
hikaricp.connections.timeout | Acquire timeout count | Tăng → pool quá nhỏ hoặc tx quá dài |
hikaricp.connections.usage | Histogram hold time | P99 vượt 1s → long tx |
hikaricp.connections.creation | Time tạo connection mới | Vượt 100ms → DB slow |
Layer 2 — Spring transaction metrics (Boot 3 + Micrometer Observation API):
management:
endpoints:
web:
exposure:
include: health, metrics, prometheus
metrics:
tags:
application: ${spring.application.name}
env: ${ENV:dev}
observations:
annotations:
enabled: true
| Metric | Ý nghĩa |
|---|---|
spring.tx.commits | Tx commit count (label by class.method) |
spring.tx.rollbacks | Tx rollback count (split by reason) |
spring.tx.duration | Histogram tx duration |
Layer 3 — Postgres metrics (qua pg_stat_* view):
-- Long-running tx chay vuot 5s
SELECT pid, usename, now() - xact_start AS tx_duration, state, query
FROM pg_stat_activity
WHERE state = 'active' AND now() - xact_start > interval '5 seconds'
ORDER BY tx_duration DESC;
-- Lock wait — query nao block query nao
SELECT
blocked.pid AS blocked_pid,
blocked.query AS blocked_query,
blocking.pid AS blocking_pid,
blocking.query AS blocking_query,
now() - blocked.query_start AS waited
FROM pg_stat_activity blocked
JOIN pg_locks bl ON bl.pid = blocked.pid AND NOT bl.granted
JOIN pg_locks bg ON bg.locktype = bl.locktype AND bg.granted
JOIN pg_stat_activity blocking ON blocking.pid = bg.pid
WHERE blocked.pid != blocking.pid;
Export qua postgres_exporter → Prometheus → Grafana dashboard.
Alerting rule chuẩn (Prometheus):
- alert: HikariPoolExhausted
expr: hikaricp_connections_pending > 0
for: 1m
labels:
severity: critical
annotations:
summary: "Hikari pool exhausted on {{ $labels.application }}"
runbook: "https://wiki.olhub.org/runbook/hikari-pool"
- alert: LongRunningTransaction
expr: histogram_quantile(0.99, rate(hikaricp_connections_usage_seconds_bucket[5m])) > 5
for: 5m
labels:
severity: warning
- alert: HighRollbackRate
expr: rate(spring_tx_rollbacks_total[5m]) / rate(spring_tx_commits_total[5m]) > 0.1
for: 5m
labels:
severity: warning
10.3 Failure runbook — 5 mode thường gặp production
Mode 1 — Pool exhaustion (hikaricp.connections.pending vượt 0 sustained):
Triệu chứng: request mới timeout sau 30s, app trả 503. Log "HikariPool — Connection is not available, request timed out".
Diagnose:
- Check
hikaricp.connections.usageP99 → long tx? - Bật
leak-detection-threshold: 5000→ log "Connection leak detected" với stack trace caller. - Postgres:
SELECT * FROM pg_stat_activity WHERE state = 'active'→ query nào hold lâu? - Kill stuck connection:
SELECT pg_terminate_backend(pid)cho pid vượt 60s.
Remediate:
- Short-term: kill stuck connection, rolling restart pod.
- Long-term: refactor long tx (move external call outside tx), tăng pool size theo workload, hoặc add PgBouncer.
Mode 2 — Deadlock (DeadlockLoserDataAccessException spike):
Triệu chứng: tx fail random, log Postgres "deadlock detected".
Diagnose: Postgres log có 2 query cùng deadlock — identify lock order ngược (tx A lock X rồi Y, tx B lock Y rồi X).
Remediate:
- App: chuẩn hoá thứ tự lock (vd luôn lock
userstable trướcaccounts). - Auto-retry với backoff exponential:
@Retryable(retryFor = CannotAcquireLockException.class,
maxAttempts = 3,
backoff = @Backoff(delay = 100, multiplier = 2))
@Transactional
public void transferMoney(Long fromId, Long toId, BigDecimal amount) {
// ...
}
Mode 3 — Long tx hold connection (hikaricp.connections.usage P99 vượt 5s):
Diagnose: trace span Micrometer → tx có external API call / file IO / sleep bên trong.
Remediate:
- Move external call ngoài tx (load data trước, tx ngắn để ghi).
- Pagination cho batch operation (đừng load 10k rows trong 1 tx).
- Set timeout enforce:
@Transactional(timeout = 5) // 5s — fail neu vuot
public void doWork() { ... }
Mode 4 — Connection leak (active count cao bất thường nhưng tx ít):
Diagnose: code dùng JdbcTemplate.execute(...) raw hoặc native JDBC, quên close Connection/ResultSet. Hiếm với Spring abstraction nhưng có khi mix native code.
Remediate: bật leak-detection-threshold: 5000 → fix caller theo stack trace log.
Mode 5 — UnexpectedRollbackException (tx marked rollback-only):
Diagnose: nested REQUIRED tx — inner throw RuntimeException, outer catch nuốt exception → outer commit fail vì tx đã marked rollback-only.
Remediate: dùng Propagation.REQUIRES_NEW cho inner. Hoặc move inner ra event-driven (@TransactionalEventListener AFTER_COMMIT). Self-check question 1 cover case này chi tiết.
10.4 Deployment patterns — rolling restart và schema migration
Pattern 1 — Schema migration không wrap chung tx với app code:
spring:
flyway:
enabled: true
baseline-on-migrate: true
out-of-order: false
Order startup:
- Flyway tx chạy (DDL — thường nhanh 1-5s).
- App bean init.
- Receive request.
Long migration (backfill 10M row) → tách job riêng, không trong startup. Bài 06 (Flyway) đào sâu pattern.
Pattern 2 — Graceful shutdown với in-flight tx:
server:
shutdown: graceful
spring:
lifecycle:
timeout-per-shutdown-phase: 30s
Behavior K8s rolling update:
- SIGTERM nhận → readiness probe fail → K8s stop route traffic mới.
- 30s grace: in-flight tx complete (commit hoặc rollback).
- Pod terminate.
Cảnh báo: tx vượt 30s grace → JVM force kill mid-tx → Postgres tự rollback connection. Test grace timeout match P99 tx duration thực tế của app — đo trước, set sau.
Pattern 3 — Blue/green với DB schema thay đổi:
Schema change phải backwards compatible giữa blue/green coexist:
| Step | Action | Risk |
|---|---|---|
| 1 | Add column nullable, default null | Zero |
| 2 | Deploy code write both old + new column | Low |
| 3 | Backfill data (separate batch job) | Medium — lock row |
| 4 | Deploy code read new only | Low |
| 5 | Drop old column (separate migration) | Zero |
Pattern này tránh downtime — old + new code coexist trong rolling deploy 5-10 phút. Skip 1 step → schema mismatch giữa pod blue/green → 500 random.
10.5 Hot-path optimization — bypass tx khi không cần
App vượt 1k QPS → tx overhead matter. AOP proxy chi phí khoảng 50µs per @Transactional invocation:
// Default — proxy AOP wrap moi method
@Transactional(readOnly = true)
public Project find(Long id) { ... }
// Optimization 1 — class-level default + method override
@Service
@Transactional(readOnly = true) // class-level
public class ProjectService {
public Project find(Long id) { ... } // inherit readOnly
@Transactional public Project save(Project p) { ... } // override write
}
// Optimization 2 — cache hit bypass DB + tx
public Project findCached(Long id) {
return cache.get(id, () -> findFromDb(id)); // cache hit -> no DB -> no tx
}
// Optimization 3 — JdbcClient (Boot 3.2+) cho read-only nhanh
public List<ProjectSummary> listSummary() {
return jdbcClient.sql("SELECT id, name FROM projects WHERE active = true")
.query(ProjectSummary.class)
.list();
// Auto-commit single query, no tx wrapper overhead
}
Benchmark: skip tx cho cache-hit path → reduce P99 latency 5-10ms cho cache-heavy endpoint. Dùng @Cacheable (Module 09) wrap method để bypass tx khi cache hit.
11. Pitfall tổng hợp
❌ Nhầm 1: Self-call @Transactional method.
✅ Inject self bean hoặc tách class.
❌ Nhầm 2: Checked exception expect rollback.
✅ Default rollback chỉ RuntimeException. Add rollbackFor = ... hoặc dùng RuntimeException.
❌ Nhầm 3: @Transactional trên private method.
✅ Spring AOP proxy chỉ work trên public method. Private invisible.
❌ Nhầm 4: Nested REQUIRED expect rollback inner only.
@Transactional public void outer() { inner(); } // nested join same tx
@Transactional public void inner() { throw ... } // outer cũng rollback
✅ Use REQUIRES_NEW cho inner nếu cần independent.
❌ Nhầm 5: Long-running tx hold connection.
@Transactional
public void slow() {
Thread.sleep(60000); // hold connection 60s!
}
✅ Tx ngắn. Long-running operation tách ngoài tx.
❌ Nhầm 6: readOnly = true cho method modify.
@Transactional(readOnly = true)
public void update(...) { // sync KHONG flush!
entity.setName(...);
}
✅ Match readOnly với actual behavior.
❌ Nhầm 7: Test không @Transactional → DB pollution between test.
✅ Test class @Transactional để isolate.
12. 📚 Deep Dive Spring Reference
13. Tóm tắt
@Transactionaldeclarative — wrap method với begin/commit/rollback qua AOP proxy.- Self-invocation bypass proxy — pitfall classic. Inject self hoặc tách class.
- Default rollback: RuntimeException + Error. Checked exception commit. Use
rollbackForđể override. - 7 propagation: REQUIRED (default), REQUIRES_NEW (independent), NESTED (savepoint), MANDATORY/NEVER/SUPPORTS/NOT_SUPPORTED (rare).
- 5 isolation: READ_UNCOMMITTED → READ_COMMITTED (Postgres default) → REPEATABLE_READ → SERIALIZABLE → DEFAULT.
- Higher isolation = more lock = less concurrency. Default DB level đủ 99% case.
readOnly = truehint Hibernate skip dirty checking — performance optimization.- Pattern recommend: class
@Transactional(readOnly = true), write method override. @TransactionalEventListenervới phase AFTER_COMMIT — chỉ trigger sau tx success.- Test class
@Transactionalđể rollback isolation.@Commitkeep changes. - Long tx anti-pattern — hold connection, block concurrency. Keep tx short.
14. Tự kiểm tra
Q1Đoạn sau code service. Bug nào xảy ra? Tx behavior thế nào?@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
try {
inventoryService.reserve(req.items());
} catch (InventoryException e) {
log.warn("Inventory failed: {}", e.getMessage());
// Continue — order placed even without inventory
}
paymentService.charge(order);
return order;
}
}
@Service
public class InventoryService {
@Transactional
public void reserve(List<Item> items) {
// throws InventoryException nếu out of stock
}
}
▸
@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
try {
inventoryService.reserve(req.items());
} catch (InventoryException e) {
log.warn("Inventory failed: {}", e.getMessage());
// Continue — order placed even without inventory
}
paymentService.charge(order);
return order;
}
}
@Service
public class InventoryService {
@Transactional
public void reserve(List<Item> items) {
// throws InventoryException nếu out of stock
}
}Bug: tx mark "rollback only" khi InventoryException throw — outer tx **không thể commit** dù catch exception.
Cơ chế:
placeOrderbegin tx (REQUIRED) — tx T1.orderRepo.savesuccess — INSERT pending T1.inventoryService.reservepropagation REQUIRED → join T1.- Throw
InventoryException(RuntimeException) → Spring transaction interceptor mark T1 "rollback-only". - Catch trong
placeOrder— eat exception, continue. paymentService.chargechạy — Hibernate flush, INSERT payment.- Method return — Spring try commit T1.
- T1 đã marked rollback-only → throw
UnexpectedRollbackException. - Caller nhận exception. Order, payment cả 2 ROLL BACK.
Output: caller nhận UnexpectedRollbackException "Transaction silently rolled back because it has been marked as rollback-only".
Fix — REQUIRES_NEW cho inventory:
@Service
public class InventoryService {
@Transactional(propagation = Propagation.REQUIRES_NEW) // independent tx
public void reserve(List<Item> items) {
// ...
}
}Inventory chạy tx T2 riêng. Fail T2 → rollback only T2. T1 (order) tiếp tục, payment success, commit.
Alternative — best-effort outside tx:
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
paymentService.charge(order);
return order;
}
// Sau placeOrder return + tx commit
// Trigger inventory async
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent event) {
try {
inventoryService.reserve(event.order().items());
} catch (Exception e) {
log.error("Reserve failed", e);
// Compensation: notify, retry, or saga
}
}Inventory ngoài tx hoàn toàn. Fail không ảnh hưởng order. Pattern eventual consistency — Module 12 đào sâu Saga.
Bài học: nested REQUIRED → exception ở inner mark whole outer tx rollback. Inner exception cần catch + recover business logic → use REQUIRES_NEW hoặc move outside tx.
Q2Spring default rollback chỉ với RuntimeException. Vì sao? Cách thay đổi?▸
Vì sao default RuntimeException only:
- Lịch sử EJB spec: EJB define checked exception là "business exception" (commit), runtime là "system exception" (rollback). Spring follow convention.
- Java semantic: checked exception means "predictable failure" — caller xử lý, business continue. Runtime means "bug/system" — abort.
- Backwards compat: code dùng pattern này từ 2003+. Change default = break legacy.
Issue thực tế:
public class BusinessException extends Exception { } // checked
@Transactional
public void doWork() throws BusinessException {
repo.save(...);
if (someCondition) throw new BusinessException("oops");
repo.save(...);
}
// Tx COMMIT - first save persisted!
// Caller throw caught, but DB state inconsistent3 cách fix:
Cách 1 — RuntimeException luôn:
public class BusinessException extends RuntimeException { } // unchecked
@Transactional
public void doWork() { // không cần throws
if (someCondition) throw new BusinessException("oops");
}
// Tx ROLLS BACKRecommend nhất. Modern Java avoid checked exception in business layer. Spring, Hibernate, Jackson đều dùng RuntimeException only.
Cách 2 — rollbackFor explicit:
@Transactional(rollbackFor = BusinessException.class)
public void doWork() throws BusinessException { ... }Verbose nhưng explicit. Use khi không thể đổi exception class (third-party).
Cách 3 — rollbackFor = Exception.class globally:
@Configuration
public class TxConfig {
@Bean
public TransactionAttributeSource transactionAttributeSource() {
AnnotationTransactionAttributeSource source = new AnnotationTransactionAttributeSource();
// Custom config
return source;
}
}
// Hoac dat default cho meta-annotation
@Target(ElementType.METHOD)
@Retention(RetentionPolicy.RUNTIME)
@Transactional(rollbackFor = Exception.class)
public @interface BusinessTransactional { }Class-wide override. Pattern enterprise.
Quy tắc 2026:
- Business exception extend
RuntimeException. Không checked. - Domain layer pure (không Spring annotation), exception POJO.
- Web layer (
@RestControllerAdvice) map exception → HTTP status. - Tránh
rollbackForper-method — repetitive, error-prone.
Q3So sánh REQUIRED vs REQUIRES_NEW vs NESTED. Use case mỗi cái?▸
| Type | Outer no tx | Outer has tx | Inner fail behavior |
|---|---|---|---|
| REQUIRED (default) | New tx | Join existing | Outer rolls back too |
| REQUIRES_NEW | New tx | Suspend outer + new tx | Only inner rolls back, outer continues |
| NESTED | New tx | Savepoint within outer | Inner rolls back to savepoint, outer continues |
REQUIRED — 99% case:
@Transactional
public void placeOrder() {
orderService.save(); // join my tx
inventoryService.reserve(); // join my tx — all-or-nothing
}Atomic operation across services. Default behavior.
REQUIRES_NEW — independent tx:
@Service
public class AuditService {
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void log(String action) {
auditRepo.save(new AuditLog(action));
}
}
@Service
public class OrderService {
@Transactional
public Order place(...) {
Order order = repo.save(...);
try {
auditService.log("order placed: " + order.id());
} catch (Exception e) {
log.error("Audit failed", e);
// Order tx vẫn commit
}
return order;
}
}Use case:
- Audit log: không nên block business.
- Notification: best-effort.
- Idempotent retry: partial success OK.
- Cross-tenant operation: isolation guarantee.
Performance: REQUIRES_NEW expensive — Hibernate flush + suspend connection + acquire new connection. Slow.
NESTED — savepoint:
@Transactional
public void importBatch(List<Record> records) {
for (Record r : records) {
try {
importOne(r);
} catch (Exception e) {
log.warn("Skip bad record: {}", r.id(), e);
// Continue, savepoint rolled back
}
}
}
@Transactional(propagation = Propagation.NESTED)
public void importOne(Record r) {
repo.save(...); // savepoint, commit nếu ok
if (invalid) throw new RuntimeException(); // rollback to savepoint, OUTER vẫn ok
}Use case:
- Batch import: skip bad record, continue.
- Multi-step wizard: rollback step, redo.
Caveat NESTED:
- Yêu cầu DB support savepoint (Postgres + MySQL InnoDB OK).
- Hibernate phải flush trước savepoint — performance impact.
- Hiếm dùng — REQUIRES_NEW thường đủ, đơn giản hơn.
Recommend 2026:
- Default: REQUIRED (no override needed).
- Audit / notification / cross-tenant: REQUIRES_NEW.
- Batch import skip-on-error: NESTED hoặc tách event-driven (preferred).
Q4readOnly = true có thật sự cải thiện performance? Cho 3 lợi ích cụ thể.▸
Có — đo được, mặc dù minor cho 1 query.
3 lợi ích:
1. Skip dirty checking (lớn nhất):
Persistence context default track mọi entity loaded — snapshot field. Tại commit, compare current vs snapshot → SQL UPDATE cho field changed.
Read-only:
- Hibernate skip snapshot (no copy field initial).
- No comparison tại commit.
- Save memory (~30% per entity) + CPU.
@Transactional(readOnly = true)
public List<Project> findAll() {
return repo.findAll(); // 1000 entities, no snapshot, no dirty check
}Benchmark 1000 entity load: read-only ~30% faster.
2. FlushMode = MANUAL:
Hibernate default flush before query — sync changes to DB. Slow nếu nhiều entity managed.
Read-only set FlushMode.MANUAL → no auto flush. Query result purely from DB.
@Transactional
public void writeMethod() {
project1.setName("A");
project2.setName("B");
repo.findByStatus(ACTIVE); // Hibernate FLUSH p1, p2 first → SQL UPDATE
}
@Transactional(readOnly = true)
public List<Project> readMethod() {
repo.findByStatus(ACTIVE); // No flush, direct SELECT
}3. DB hint — set tx read-only mode:
Hibernate set connection.setReadOnly(true). JDBC driver pass to DB:
- Postgres: set
SET TRANSACTION READ ONLY— DB optimize execution plan, không acquire write lock. - MySQL: hint cho replica routing — proxy có thể route read-only tx → replica.
Lợi ích thực tế: read-replica setup tự động — no application code change.
Pattern recommend:
@Service
@Transactional(readOnly = true) // class default
public class ProjectService {
public Project findById(Long id) { ... } // inherit readOnly
public List<Project> findAll() { ... }
public Page<Project> page(...) { ... }
@Transactional // override write
public Project create(...) { ... }
@Transactional
public void delete(Long id) { ... }
}Anti-pattern:
@Transactional(readOnly = true)
public void update(Long id, String newName) {
Project p = repo.findById(id).orElseThrow();
p.setName(newName); // dirty checking SKIP — UPDATE KHONG CHAY!
}Bug subtle — code "looks ok" nhưng change không persist. Compile pass, runtime no-op.
Quy tắc: match readOnly với actual behavior. Class-level + method override pattern recommend.
Q5@TransactionalEventListener vs @EventListener. Khác nhau ở đâu? Use case AFTER_COMMIT?▸
| Aspect | @EventListener | @TransactionalEventListener |
|---|---|---|
| Khi listener chạy | Ngay khi publishEvent call (synchronous, same thread) | Sau tx commit/rollback theo phase |
| Tx active | Yes — same tx as publisher | No — outside tx (after commit) |
| Listener fail | Throw → publisher tx rolls back | Throw → no impact (tx đã commit) |
| Use case | Synchronous workflow within tx | Side effect after tx success |
Code so sánh:
@Service
public class OrderService {
@Transactional
public Order placeOrder(...) {
Order order = repo.save(...);
publisher.publishEvent(new OrderPlacedEvent(order));
return order;
}
}
// Cach 1: @EventListener — chay sync trong tx
@Component
public class SyncHandler {
@EventListener
public void onOrderPlaced(OrderPlacedEvent e) {
emailService.send(...); // Throw → tx rolls back, order KHONG saved
}
}
// Cach 2: @TransactionalEventListener AFTER_COMMIT
@Component
public class AsyncHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
public void onOrderPlaced(OrderPlacedEvent e) {
emailService.send(...); // Chi chay neu tx commit OK
// Throw → log error, no impact on tx
}
}4 phase của @TransactionalEventListener:
| Phase | When | Use case |
|---|---|---|
BEFORE_COMMIT | Trước commit | Last validation, sync entity |
AFTER_COMMIT (default) | Sau commit success | Send email, publish to message broker, audit log |
AFTER_ROLLBACK | Sau rollback | Compensating action, alerting |
AFTER_COMPLETION | Sau commit OR rollback | Cleanup, metric |
Use case AFTER_COMMIT — gửi email order confirmation:
@Service
public class OrderService {
@Transactional
public Order placeOrder(OrderRequest req) {
Order order = orderRepo.save(new Order(req));
publisher.publishEvent(new OrderPlacedEvent(order));
return order;
}
}
@Component
public class OrderEventHandler {
@TransactionalEventListener(phase = TransactionPhase.AFTER_COMMIT)
@Async // Run on thread pool
public void sendConfirmation(OrderPlacedEvent event) {
try {
emailService.send(event.order().customer().email(),
"Order confirmation",
buildBody(event.order()));
} catch (Exception e) {
log.error("Email failed for order {}", event.order().id(), e);
// No retry here — message broker pattern preferred
}
}
}Vì sao AFTER_COMMIT cho email:
- Tx success guaranteed: không gửi email cho order chưa commit (avoid "order created but not in DB").
- Email failure không rollback: SMTP slow / down → order vẫn placed.
- Async với @Async: không block response.
Pattern enterprise — Outbox:
@Transactional
public Order placeOrder(...) {
Order order = orderRepo.save(...);
// Save to outbox table — same tx
outboxRepo.save(new OutboxMessage("OrderPlaced", json(order)));
return order;
}
// Background job poll outbox → publish to Kafka
@Scheduled(fixedDelay = 1000)
public void publishOutbox() {
List<OutboxMessage> msgs = outboxRepo.findUnpublished(100);
for (OutboxMessage m : msgs) {
kafkaTemplate.send(m.topic(), m.payload());
m.markPublished();
outboxRepo.save(m);
}
}Outbox pattern: atomic write order + outbox entry, async publish to broker. Module 12 đào sâu.
Q6App có 100 instance pod K8s. 1 method @Transactional chạy 30 giây hold connection. Tác động? Quy tắc?▸
Tác động — connection pool exhaustion:
Pool config typical:
spring.datasource.hikari.maximum-pool-size: 2020 connection / pod × 100 pod = 2000 connection / DB. Đa phần DB Postgres default max_connections = 100. App đã exceeds DB capacity → connection refuse mới.
Trong 1 pod:
- 20 thread đang chạy
@Transactional30s methods → 20 connection hold. - Request thứ 21 đến → wait connection — block thread.
- Tomcat thread pool 200 → 21st request không có connection, eventually timeout.
- Cascade: thread block → request queue lớn → ratelimit → 503.
Quy tắc tránh long tx:
- Tx ngắn (
<1 giây): business logic only. CPU/network heavy bên ngoài.// SAI — long tx hold connection @Transactional public void processBatch() { List<Record> records = repo.findAll(); // load 10k rows for (Record r : records) { externalApi.call(r); // 100ms each repo.save(r); } } // 10k × 100ms = 1000 giây hold connection!// DUNG — tx per record public void processBatch() { List<Record> records; @Transactional(readOnly = true) public List<Record> loadAll() { return repo.findAll(); } @Transactional public void processOne(Record r) { repo.save(r); // chi 1 record, ms tx } records = self.loadAll(); for (Record r : records) { externalApi.call(r); // OUTSIDE tx self.processOne(r); // small tx per record } } - External API call OUTSIDE tx: network call ms-seconds, không hold DB connection.
- Pagination: avoid load 10k rows in one tx.
- Async cho long-running:
@Async public CompletableFuture<Result> longTask() { // Run on separate thread pool, not blocking main } - Tx timeout enforce:App fail-fast thay hold connection ngầm.
@Transactional(timeout = 5) // 5s — fail nếu vượt public void doWork() { ... }
Monitoring:
- Hikari metrics:
hikaricp.connections.usage,hikaricp.pending. - Postgres:
pg_stat_activity— query running >5s. - Alert: tx P99 latency >1s → investigate.
Pool sizing pattern:
# Connection pool size formula
# pool_size = (cores × 2) + effective_spindle_count
# Default Hikari = 10. App typical: 10-20.
spring.datasource.hikari.maximum-pool-size: 20
spring.datasource.hikari.connection-timeout: 30000 # 30s wait connection
spring.datasource.hikari.idle-timeout: 600000 # 10 min idle
spring.datasource.hikari.max-lifetime: 1800000 # 30 min max
spring.datasource.hikari.leak-detection-threshold: 5000 # 5s — log if leakBài học: tx là contention point. Keep short. Module 09 (Performance) đào sâu connection pool tuning.
Bài tiếp theo: Flyway migration — DB schema versioning
Bài này có giúp bạn hiểu bản chất không?
Bình luận (0)
Đang tải...