skip to content

You send a JMS message from inside a @Transactional method that also writes to the database. Without XA, how can a message be lost or a phantom message be sent, and how do you mitigate it?

level: middleimportance: must knowfreq 60%

answer

  1. two resources = two commits = can diverge
  2. phantom = send before DB rollback
  3. lost = DB commit then send fails/crash
  4. XA/JtaTransactionManager for true atomicity
  5. best-effort 1PC + idempotency, or outbox

basics

~20 s

The DB and the JMS broker are two separate resources with two independent commits. If the DB commits but the JMS send fails (or vice versa) they diverge: you can lose a message or send one for work that rolled back. Use XA, or a best-effort ordering plus idempotency/outbox.

solid answer

~50 s

A single `@Transactional` with a DataSource transaction manager only governs the database. The `JmsTemplate` send talks to the broker as an *independent* resource. Two failure windows exist: (1) the message is sent *inside* the method but the DB transaction later rolls back — a **phantom message** describing work that never persisted; (2) the DB commits but the JMS send fails or the JVM dies before it — **lost message**. Real atomicity needs an **XA/2-phase-commit** setup via a `JtaTransactionManager` with XA-capable connection factory and DataSource. If you can't afford XA, use **best-effort 1PC**: nest the JMS transaction inside the DB transaction so JMS commits just before DB, accept a small duplicate window, and make consumers **idempotent** — or use the **transactional outbox** pattern (write the message to a DB table in the same transaction, relay it asynchronously).

code

java · 29 lines
java
// ANTI-PATTERN: looks atomic, is NOT.
@Service
class OrderService {
    private final OrderRepository repo;
    private final JmsTemplate jms;

    @Transactional // governs ONLY the DataSource
    public void placeOrder(Order order) {
        repo.save(order);                 // DB row (commits at method end)
        jms.convertAndSend("orders", order); // separate resource!
        // If jms send happens now and the tx later rolls back -> PHANTOM message.
        // If we reordered and the process died after commit -> LOST message.
    }
}

// BETTER: transactional outbox -> single DB transaction, async relay.
@Service
class OrderServiceOutbox {
    private final OrderRepository repo;
    private final OutboxRepository outbox;

    @Transactional
    public void placeOrder(Order order) {
        repo.save(order);
        outbox.save(new OutboxRecord("orders", serialize(order))); // same DB tx = atomic
    }
    // A separate @Scheduled relay reads outbox rows and calls jms.convertAndSend(),
    // marking them sent. Delivery is at-least-once -> consumers must be idempotent.
}

go deeper

for a junior

Recognize that DB and JMS are separate and a plain @Transactional does not cover the message send.

for a middle

Articulate the phantom-vs-lost windows and name XA, best-effort 1PC, and outbox as the three mitigations.

for a senior

Choose between them by cost/guarantees, order the 1PC commits safely, and design idempotent consumers.

for a principal

Weigh XA operational burden vs outbox/CDC, define delivery SLAs, and set idempotency/dedup and DLQ strategy across the system.

**The core problem: two resources, two commits.** A database and a JMS broker are distinct transactional resources. Spring's `@Transactional` (backed by `DataSourceTransactionManager` / `JpaTransactionManager`) commits *only the DataSource*. A `jmsTemplate.convertAndSend(...)` call inside that method uses a JMS session that commits on its own schedule. There is no automatic coordination, so their outcomes can diverge. **Two concrete failure modes.** 1. **Phantom / ghost message (send-then-rollback).** With `sessionTransacted=false` (or an autocommit send), the message hits the broker the moment you call send, *before* the surrounding DB transaction commits. If the DB transaction then rolls back, a consumer already saw a message about an order that was never persisted. Now downstream state is inconsistent. 2. **Lost message (commit-then-crash).** You order it the other way — DB commits first, then you send. If the process crashes, the broker rejects the send, or the network drops between the DB commit and the JMS commit, the DB shows the work done but no message was ever published. A downstream that was supposed to react never does. You cannot eliminate *both* windows with plain single-phase commits — one of the two resources always commits second and can fail after the first already committed. This is the classic *two generals* / dual-write problem. **Solution 1 — XA / two-phase commit (real atomicity).** Configure a `JtaTransactionManager` (backed by a JTA provider such as Atomikos or Narayana) and use **XA-capable** resources: an `XAConnectionFactory` for JMS and an `XADataSource` for the DB. The transaction manager runs 2PC: prepare both, then commit both. If either can't prepare, both roll back. This gives true atomicity but adds cost: transaction logs, recovery handling, slower commits, broker/driver XA support, and operational complexity (in-doubt transactions). Many brokers/drivers have imperfect XA support. **Solution 2 — Best-effort 1PC (chained transactions).** Order the two single-phase commits so the *most* recoverable one is last. A common Spring approach: make the DB transaction the outer one and the JMS session the inner one that commits **just before** the DB commit (Spring's message-driven flow with `sessionTransacted` and a `JmsTransactionManager` can chain this). The only remaining failure window is: JMS committed, DB commit then fails — producing a duplicate/phantom, never a silent loss. You shrink the window to something rare and *always in the safe direction*, then rely on **idempotent** consumers to absorb duplicates. This is what most systems actually do. **Solution 3 — Transactional Outbox.** Don't send to the broker inside the business transaction at all. Instead, in the **same DB transaction**, insert the message payload into an `outbox` table. Because it's the same DataSource, it's atomic with the business data. A separate relay (polling job or CDC like Debezium) reads the outbox and publishes to JMS, marking rows sent. This removes the dual-write entirely; delivery becomes at-least-once (duplicates possible), again requiring idempotent consumers. It avoids XA overhead and is broker-agnostic. **Consumer-side symmetry.** The same reasoning applies when *consuming*: if you receive a message and write to the DB, a transacted session + DB transaction chained (best-effort 1PC) gives at-least-once; XA gives exactly-once-ish; plain independent commits can drop the DB write or reprocess. Idempotency (dedup by message id / business key) is the universal safety net. **Practical guidance.** Default to **best-effort 1PC + idempotency** or the **outbox** pattern. Reach for **XA** only when duplicates are genuinely unacceptable and you've accepted the operational weight. Never assume a plain `@Transactional` around a DB write plus a JMS send is atomic — it is not.

  • If you must send inside the transaction and can't use XA, which ordering is safer and why?
    Commit JMS just before the DB commit (best-effort 1PC). Then the only failure window is 'JMS sent, DB commit fails' -> a duplicate/phantom, which idempotent consumers can absorb. The reverse ordering risks a silent lost message, which is far worse.
  • Why does the outbox pattern avoid the dual-write problem?
    Both the business row and the outbox row are written in the same DataSource transaction, so they commit atomically. Publishing to the broker is deferred to a separate relay, converting the dual-write into a single-resource write plus at-least-once delivery.

saying these in an interview costs you the question

  • Claiming @Transactional makes the DB write and JMS send atomic
  • Assuming sessionTransacted alone coordinates DB + JMS
  • Thinking you can get zero duplicates and zero loss without XA
  • Ignoring idempotency when using best-effort 1PC or outbox

context