Spring Boot/Transactions — @Transactional, propagation, rollback rules
~26 phútSpring Data JPAMiễn phí

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:

  1. Begin tx tại method entry.
  2. Commit tại method exit (success).
  3. 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 Order

Spring 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:

TypeBehavior
REQUIRED (default)Join existing tx, hoặc new
REQUIRES_NEWAlways new tx, suspend existing
SUPPORTSJoin if exists, else no-tx
NOT_SUPPORTEDSuspend existing, run no-tx
MANDATORYMust have existing, else throw
NEVERThrow if existing
NESTEDSavepoint 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:

LevelDirty readNon-repeatable readPhantom read
READ_UNCOMMITTED
READ_COMMITTED (Postgres default)
REPEATABLE_READ (MySQL InnoDB default)
SERIALIZABLE
DEFAULTDB 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_connections Postgres 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:#fef3c7

10.2 Monitoring — 3 layer metric phải watch

Layer 1 — Hikari metrics (Micrometer auto-publish khi có Actuator):

MetricÝ nghĩaAlert threshold
hikaricp.connections.activeConnection đang dùngVượt 80% pool sustained → warning
hikaricp.connections.idleIdle connectionDưới minimum-idle lâu → check workload
hikaricp.connections.pendingThread chờ connectionVượt 0 sustained 1 phút → pool exhausted
hikaricp.connections.timeoutAcquire timeout countTăng → pool quá nhỏ hoặc tx quá dài
hikaricp.connections.usageHistogram hold timeP99 vượt 1s → long tx
hikaricp.connections.creationTime tạo connection mớiVượ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.commitsTx commit count (label by class.method)
spring.tx.rollbacksTx rollback count (split by reason)
spring.tx.durationHistogram 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:

  1. Check hikaricp.connections.usage P99 → long tx?
  2. Bật leak-detection-threshold: 5000 → log "Connection leak detected" với stack trace caller.
  3. Postgres: SELECT * FROM pg_stat_activity WHERE state = 'active' → query nào hold lâu?
  4. 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 users table trước accounts).
  • 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:

  1. Flyway tx chạy (DDL — thường nhanh 1-5s).
  2. App bean init.
  3. 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:

StepActionRisk
1Add column nullable, default nullZero
2Deploy code write both old + new columnLow
3Backfill data (separate batch job)Medium — lock row
4Deploy code read new onlyLow
5Drop 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

  • @Transactional declarative — 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 = true hint Hibernate skip dirty checking — performance optimization.
  • Pattern recommend: class @Transactional(readOnly = true), write method override.
  • @TransactionalEventListener với phase AFTER_COMMIT — chỉ trigger sau tx success.
  • Test class @Transactional để rollback isolation. @Commit keep changes.
  • Long tx anti-pattern — hold connection, block concurrency. Keep tx short.

14. Tự kiểm tra

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
  }
}

Bug: tx mark "rollback only" khi InventoryException throw — outer tx **không thể commit** dù catch exception.

Cơ chế:

  1. placeOrder begin tx (REQUIRED) — tx T1.
  2. orderRepo.save success — INSERT pending T1.
  3. inventoryService.reserve propagation REQUIRED → join T1.
  4. Throw InventoryException (RuntimeException) → Spring transaction interceptor mark T1 "rollback-only".
  5. Catch trong placeOrder — eat exception, continue.
  6. paymentService.charge chạy — Hibernate flush, INSERT payment.
  7. Method return — Spring try commit T1.
  8. T1 đã marked rollback-only → throw UnexpectedRollbackException.
  9. 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.

Q2
Spring 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 inconsistent

3 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 BACK

Recommend 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:

  1. Business exception extend RuntimeException. Không checked.
  2. Domain layer pure (không Spring annotation), exception POJO.
  3. Web layer (@RestControllerAdvice) map exception → HTTP status.
  4. Tránh rollbackFor per-method — repetitive, error-prone.
Q3
So sánh REQUIRED vs REQUIRES_NEW vs NESTED. Use case mỗi cái?
TypeOuter no txOuter has txInner fail behavior
REQUIRED (default)New txJoin existingOuter rolls back too
REQUIRES_NEWNew txSuspend outer + new txOnly inner rolls back, outer continues
NESTEDNew txSavepoint within outerInner 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).
Q4
readOnly = 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ạyNgay khi publishEvent call (synchronous, same thread)Sau tx commit/rollback theo phase
Tx activeYes — same tx as publisherNo — outside tx (after commit)
Listener failThrow → publisher tx rolls backThrow → no impact (tx đã commit)
Use caseSynchronous workflow within txSide 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:

PhaseWhenUse case
BEFORE_COMMITTrước commitLast validation, sync entity
AFTER_COMMIT (default)Sau commit successSend email, publish to message broker, audit log
AFTER_ROLLBACKSau rollbackCompensating action, alerting
AFTER_COMPLETIONSau commit OR rollbackCleanup, 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.

Q6
App 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: 20

20 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 @Transactional 30s 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:

  1. 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
      }
    }
  2. External API call OUTSIDE tx: network call ms-seconds, không hold DB connection.
  3. Pagination: avoid load 10k rows in one tx.
  4. Async cho long-running:
    @Async
    public CompletableFuture<Result> longTask() {
      // Run on separate thread pool, not blocking main
    }
  5. Tx timeout enforce:
    @Transactional(timeout = 5)        // 5s — fail nếu vượt
    public void doWork() { ... }
    App fail-fast thay hold connection ngầm.

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 leak

Bà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...