Spring Boot @Transactional Mastery: Propagation, Failure Modes & Distributed Transactions

This comprehensive guide dissects Spring Boot's @Transactional annotation, covering seven propagation behaviors, eight failure scenarios with root causes, core source code analysis, and distributed transaction patterns including local message tables and RocketMQ transaction messages, with practical order-inventory-points examples.

Architecture Pony
Architecture Pony
Architecture Pony
Spring Boot @Transactional Mastery: Propagation, Failure Modes & Distributed Transactions

1. Problem Definition: Four Failure Scenes

The article opens with a real-world symptom: an order created but inventory not deducted, despite both operations being in a method annotated with @Transactional and an exception being thrown. It categorizes transaction failures into four distinct scenes, each requiring a different fix:

Scene 1: Never entered transaction – caused by self-invocation ( this call bypassing proxy), non-public/final/static methods, class not managed by Spring, or transaction management not enabled (auto-enabled in Spring Boot).

Scene 2: Entered transaction but no rollback – exception type not in rollback rules (default only RuntimeException / Error), exception swallowed by try-catch, or caught and not rethrown.

Scene 3: Partial rollback – child method uses REQUIRES_NEW (independent commit), runs in another thread, or uses another DataSource without joining the same transaction.

Scene 4: Cross-service inconsistency – local transactions only guarantee single-DB atomicity; cross-service requires distributed transaction solutions.

Key insight: Scenes 1-3 are "local transaction not effective"; Scene 4 is "transaction boundary not long enough".

2. Technical Analysis: Three Abstractions & Seven Propagations

Spring's transaction abstraction separates what (Definition) from how (Manager) via three core interfaces: PlatformTransactionManager – executes begin/commit/rollback (e.g., DataSourceTransactionManager). TransactionDefinition – holds isolation, propagation, timeout, read-only. TransactionStatus – runtime state: new transaction?, completed?, savepoints?

Seven propagation behaviors are defined; only three are commonly used: REQUIRED (default): join existing or create new. REQUIRES_NEW: always create new, suspend outer. NESTED: create savepoint if existing, else behave like REQUIRED.

Others ( SUPPORTS, MANDATORY, NOT_SUPPORTED, NEVER) serve as constraints.

Isolation level : keep DEFAULT (MySQL = REPEATABLE_READ). Explicitly setting isolation pollutes pooled connections because connections are reused.

Read-only : @Transactional(readOnly=true) is an optimization hint (disables Hibernate dirty checking, hints DB), not a safety guard – writes still commit.

3. Core Flow: Eight Steps of a @Transactional Call

Proxy intercepts.

Parse annotation attributes (propagation, isolation, timeout, readOnly, rollbackFor).

Obtain TransactionManager. getTransaction() – handle existing transaction per propagation (join/suspend/new).

Bind resources (Connection) to thread via ThreadLocal.

Execute target method.

Normal return → commit.

Exception thrown → judge rollback rules : match rollbackFor → rollback; match noRollbackFor → commit; default instanceof RuntimeException || Error → rollback; else commit. This is the most counter-intuitive step: exception does not guarantee rollback.

Rollback rule evaluation order: explicit rollbackFor → explicit noRollbackFor → default (RuntimeException/Error) → commit.

Best practice: always declare rollbackFor = Exception.class.

Commit phase can fail (deferred constraints, connection loss, deadlock, disk full). commit() is a network/IO operation; method completing without exception ≠ data persisted.

4. Core Source Code: Five Decisive Classes

4.1 TransactionInterceptor

Thin shell delegating to TransactionAspectSupport.invokeWithinTransaction. Same interceptor+template pattern used by @Cacheable and @Async.

4.2 invokeWithinTransaction (Template Method)

TransactionInfo txInfo = createTransactionIfNecessary(tm, txAttr, joinpointId);
Object retVal;
try {
    retVal = inv.proceed();
} catch (Throwable ex) {
    completeTransactionAfterThrowing(txInfo, ex); // may rollback OR commit
    throw ex;
} finally {
    cleanupTransactionInfo(txInfo);
}
commitTransactionAfterReturning(txInfo);
return retVal;

Method name completeTransactionAfterThrowing hints it may commit.

4.3 AbstractPlatformTransactionManager

Implements propagation logic: REQUIRED joins existing; REQUIRES_NEW suspends outer + doBegin; NESTED creates savepoint; NOT_SUPPORTED suspends. Suspend = unbind resources from thread, store, restore after inner completes – hence REQUIRES_NEW consumes two connections.

4.4 TransactionSynchronizationManager

Holds ThreadLocal<Map> resources and ThreadLocal<Set> synchronizations. This explains: transactions don't cross threads, async methods can't see uncommitted data, cross-thread requires new transaction.

4.5 DataSourceTransactionManager

doBegin

: get Connection, setAutoCommit(false), bind to ThreadLocal. doCommit: connection.commit(). doRollback: connection.rollback() (autoCommit unchanged). Spring restores autoCommit when returning connection to pool; manually changing autoCommit on a pooled connection pollutes the pool.

5. Propagation Behaviors: REQUIRED, REQUIRES_NEW, NESTED

Behavior matrix when outer A calls inner B and B throws: REQUIRED: A & B rollback together (same transaction). REQUIRES_NEW: B rolls back, A can catch and commit (two independent connections). NESTED: B rolls back to savepoint, A continues; if A fails, B also rolls back (single transaction, savepoint).

REQUIRES_NEW cost : each nesting level doubles connection usage (pool 20 → effective 10). Deep nesting causes throughput collapse without errors. Only for short, low-frequency, must-be-independent ops (e.g., audit logging). Never wrap remote calls.

NESTED limitations : requires JDBC driver savepoint support (MySQL InnoDB yes). Cannot commit independently.

Common misconception: inner REQUIRED throws, outer catches – outer still gets UnexpectedRollbackException on commit because transaction marked rollback-only. Partial rollback within same transaction impossible without savepoints (NESTED).

6. Eight Failure Causes & Checklist

this

self-invocation → bypass proxy.

Method non-public → proxy can't intercept.

Method final/static → CGLIB cannot override.

Class not Spring-managed (manual new).

Exception type mismatch – checked exceptions default commit.

Exception caught and not rethrown → normal return → commit.

Cross-thread/async – transaction bound to thread.

Multiple DataSources without specifying manager – default manager used, other DB auto-commits.

Fixes: extract to another bean / inject self-proxy via AopContext.currentProxy(); make public; remove final/static; let container manage; add rollbackFor=Exception.class; rethrow or setRollbackOnly(); move transaction boundary or start new transaction; annotate @Transactional(transactionManager="xxxTxManager").

Self-check five questions per transaction method.

Programmatic TransactionTemplate preferred when some failures are acceptable (explicit rollback points in code).

7. Distributed Transactions: Four Patterns & Selection Matrix

Conclusion: most businesses don't need strong consistency; eventual consistency + compensation suffices. 2PC/XA often kills performance and ops simplicity.

2PC/XA : DB-level two-phase commit, strong consistency, blocking, poor performance.

TCC : Try-Confirm-Cancel at business level, high intrusion, for core financial flows.

Saga : chain local transactions + compensating actions, for long compensable processes.

Local Message Table / Transactional Messages : local transaction writes business data + message atomically; async relay + idempotent consumption. Most cost-effective.

Local Message Table : same local transaction writes business row and message row (status=PENDING). Scheduled task scans, publishes to MQ, marks SENT. Consumer uses idempotency key.

RocketMQ Transactional Message : send half-message → execute local transaction → commit/rollback half-message. Broker polls back if commit/rollback fails. Moves message table maintenance to MQ; requires implementing check-back interface.

Selection matrix:

Cross-service, seconds delay OK → Local Message Table / Transactional Message.

Financial, strong consistency → TCC (cautiously).

Long multi-step compensable → Saga.

Same DB multi-table → Local transaction! No distributed needed.

Anti-pattern: wrapping all cross-service calls with Seata AT → global locks + performance collapse.

Iron law: if solvable in one DB, never do distributed transaction. Many "distributed transaction needs" stem from over-splitting microservices.

8. Complete Practice: Order, Inventory, Points

8.1 Boundary Division

Order DB: order + detail → local transaction (strong consistency).

Inventory service: deduct → TCC or message + reconciliation.

Points service: add → eventual consistency (delay acceptable, compensable).

Forcing points into strong consistency is classic over-engineering.

8.2 Order Local Transaction

@Transactional(rollbackFor = Exception.class, timeout = 3)
public Long create(CreateCmd cmd) {
    Order o = build(cmd);
    orderMapper.insert(o);
    itemMapper.insertBatch(o.getId(), cmd.items());
    // same transaction writes outbox message
    msgMapper.insert(OutboxMsg.of("order.created", o));
    return o.getId();
    // NO remote calls inside!
}

8.3 Post-Commit Message Dispatch & Idempotent Consumption

@TransactionalEventListener(phase = AFTER_COMMIT)
public void onCreated(OrderCreatedEvent e) {
    outboxSender.trySend(e);
}

@Transactional
public void consume(DeductMsg m) {
    if (deductLog.exists(m.msgId())) return; // idempotent
    stockMapper.deduct(m);
    deductLog.insert(m.msgId());
}

Key: AFTER_COMMIT guarantees message sent only after transaction commits; consumer uses msgId idempotency table.

8.4 Before/After Metrics

Order-detail inconsistency: Occasional → 0

Inventory oversell: Occasional → 0

Points loss: Monthly dozens → 0 (compensable)

Order RT: 120ms → 135ms

Transaction hold time: Includes remote call → Local writes only

+15ms RT (extra message table write) eliminates inconsistency. Removing remote calls from transaction is the real concurrency win.

9. Eight Pitfalls & Troubleshooting Manual

9.1 Eight High-Frequency Pitfalls

Default rollback only RuntimeException – always write rollbackFor=Exception.class.

Try-catch swallows exception – normal return commits; manually setRollbackOnly() if handling.

Remote call inside transaction – holds connection, exhausts pool, timeouts cascade. Move remote calls outside. REQUIRES_NEW nested too deep – each level doubles connections, concurrency halves exponentially.

Want partial rollback in same transaction – transaction marked rollback-only, outer commit throws UnexpectedRollbackException.

Multiple DataSources, only one manager configured – other DB auto-commits. Explicitly specify transactionManager.

Explicit isolation level – pollutes pooled connections, subsequent code inherits weird isolation.

Assuming commit never fails – deferred constraints, connection drop, deadlock throw on commit; must catch.

9.2 Symptom-Based Troubleshooting

Exception thrown but no rollback → check exception type, caught?, rollbackFor configured.

UnexpectedRollbackException → inner REQUIRED threw, outer caught → inner change to NESTED or outer don't catch.

Log table has extra rows → used REQUIRES_NEW? cross-thread write? → decide per business.

Pool exhausted / RT spike → remote call or big loop in transaction? → shrink boundary.

9.3 Two Must-Enable Debug Switches

logging.level.org.springframework.transaction=DEBUG

Shows transaction create, propagation, commit, rollback, savepoint rollback. Invaluable for NESTED/REQUIRES_NEW debugging.

10. Learning Path & Interview Questions

10.1 Four-Stage Path

Stage 1 (1 day): Use – four @Transactional elements (rollbackFor, timeout, readOnly, propagation). Acceptance: explain rollback rules.

Stage 2 (1 week): Diagnose – reproduce eight failures, internalize self-check five questions. Acceptance: spot issues in code review.

Stage 3 (2 weeks): Design – propagation selection, boundary design, programmatic transactions. Acceptance: no remote calls in transactions.

Stage 4 (ongoing): Trade-offs – local message table / transactional messages, TCC/Saga boundaries. Acceptance: don't sacrifice availability for strong consistency.

10.2 Twelve Interview Questions (with answers)

Default rollback exceptions? → RuntimeException & Error; checked exceptions commit.

Why same-class call fails? → this bypasses proxy, interceptor not executed.

REQUIRED vs REQUIRES_NEW? → former joins same transaction (rollback together), latter new transaction (independent commit, two connections).

NESTED vs REQUIRES_NEW? → NESTED savepoint (same tx), REQUIRES_NEW independent tx.

Inner REQUIRED throws, outer catches? → tx marked rollback-only, outer commit throws UnexpectedRollbackException.

Why transaction fails across threads? → Resources bound in TransactionSynchronizationManager ThreadLocal.

Can readOnly prevent writes? → No, optimization hint only; writes still commit.

Can commit fail? → Yes: deferred constraints, connection loss, deadlock; must catch.

Multi-DB specify manager? → @Transactional(transactionManager="xxxTxManager").

Why local message table ensures consistency? → Business data + message same DB same transaction (atomic); rest via retry+idempotency.

Transactional message check-back purpose? → When commit/rollback of half-message fails, Broker polls app to decide final state.

When to use distributed transactions? → Cross-DB strong consistency required; if same DB can solve, never go distributed.

11. One-Page Cheat Sheet

Summarizes three abstractions, default rollback rule, eight-step flow, commit can fail, propagation behaviors, rollback-only semantics, eight failure causes, boundary rules (no remote calls, avoid distributed when local works), distributed patterns (local message table best ROI, TCC only for finance, anti-pattern: blanket Seata AT).

Final takeaway: Transaction correctness hinges on three things – call went through proxy, exception reached interceptor, boundary drawn correctly. First two are code details; third is architectural judgment, often costlier.

Original Source

Signed-in readers can open the original source through BestHub's protected redirect.

Sign in to view source
Republication Notice

This article has been distilled and summarized from source material, then republished for learning and reference. If you believe it infringes your rights, please contactadmin@besthub.devand we will review it promptly.

Spring BootRocketMQ@Transactionaldistributed transactionsSeatatransaction propagationsavepointsrollback rules
Architecture Pony
Written by

Architecture Pony

Learning notes

0 followers
Reader feedback

How this landed with the community

Sign in to like

Rate this article

Was this worth your time?

Sign in to rate
Discussion

0 Comments

Thoughtful readers leave field notes, pushback, and hard-won operational detail here.