skip to content

Kafka Transactions & Exactly-Once

Kafka transactions plus read_committed consumers make read-process-write atomic across the produced records and the committed offsets. Interviewers ask what exactly-once really guarantees and where it stops — notably at a database on the other side.

part ofSpring Frameworkoverview, primer and where to startread it →
on this pageshow

explore

questions

5

What does setting a Kafka consumer's isolation.level to read_committed do, and why does it matter when producers use transactions?

level: juniorimportance: must knowfreq 45%

answer

  1. default = read_uncommitted (dirty reads)
  2. read_committed hides aborted records
  3. won't read past Last Stable Offset (LSO)
  4. must set it explicitly — consumer side
  5. plain non-tx records always visible

basics

~10 s

read_committed makes the consumer skip records from aborted transactions and not read records from transactions that are still open. It only sees data the producer actually committed, so a rolled-back send never becomes visible.

solid answer

~40 s

A Kafka consumer's isolation.level is either read_uncommitted (the default) or read_committed. With read_uncommitted the consumer sees every record, including ones written by transactions that later abort. With read_committed the consumer only delivers records from committed transactions: it filters out aborted records and, crucially, will not read past the Last Stable Offset (LSO) — so records belonging to a transaction that is still in flight stay invisible until that transaction commits or aborts. This is what makes producer-side transactions actually useful end to end: if the produce side rolls back, downstream read_committed consumers never observe the phantom messages. In Spring you set it via consumer property spring.kafka.consumer.isolation-level=read_committed (or ConsumerConfig.ISOLATION_LEVEL_CONFIG). Without it, transactional producers give you atomic writes but consumers can still read data that gets aborted.

code

properties · 4 lines
properties
# application.properties — make listeners honor producer transactions
spring.kafka.consumer.isolation-level=read_committed
spring.kafka.consumer.enable-auto-commit=false
spring.kafka.consumer.group-id=orders-processor

go deeper

for a junior

Know the two values, that read_committed hides aborted records, and that it is not the default.

for a middle

Explain the LSO and the latency trade-off, and where to set it in Spring.

for a senior

Tie it to end-to-end EOS: consumer read_committed plus a transactional producer are both required.

for a principal

Discuss operational impact — long-open transactions stalling read_committed consumers and monitoring LSO lag.

## The problem Kafka transactions let a producer write to multiple partitions (and commit consumer offsets) atomically — all messages become visible together or none do. But a transaction only helps if *readers* respect it. `isolation.level` is the **consumer** setting that decides whether readers honor transaction boundaries. ## The two values - **`read_uncommitted`** (the default): the consumer receives *all* records as soon as they are appended to the log, including records written by a transaction that is later **aborted** (rolled back). You get 'dirty reads'. - **`read_committed`**: the consumer only delivers records from **committed** transactions. It does two things: 1. **Filters aborted records** — the broker knows (via control/marker records) which records belonged to an aborted transaction and does not hand them to the consumer. 2. **Respects the Last Stable Offset (LSO)** — the consumer will not read beyond the LSO, which is the offset of the first still-open transaction. So records from an *in-flight* transaction stay invisible until it commits or aborts. This can add latency: a slow or hung transaction blocks visibility of everything after it. Non-transactional (plain) records are always visible under both settings — `read_committed` only gates *transactional* records. ## How to set it in Spring ```properties spring.kafka.consumer.isolation-level=read_committed ``` or programmatically via `ConsumerConfig.ISOLATION_LEVEL_CONFIG = "read_committed"`. In a `@KafkaListener` / `ConcurrentKafkaListenerContainerFactory` setup this flows through the consumer factory. ## Why it matters for exactly-once Exactly-once semantics (EOS) for a read-process-write pipeline depends on **both** ends: the producer wraps produce + offset-commit in a transaction, *and* downstream consumers run `read_committed` so aborted work is never observed. If you forget the consumer setting, a rolled-back batch can still be read and processed — breaking the guarantee. ## Gotchas - `read_committed` is **not the default** — you must set it explicitly. - It adds visibility latency proportional to how long transactions stay open; keep transactions short. - It does not deduplicate or make *your* consumer transactional; it only hides aborted/in-flight transactional records. - Control records (transaction markers) are consumed internally and never delivered to your application.

  • What is the Last Stable Offset and how can read_committed add latency?
    The LSO is the offset of the first still-open transaction; a read_committed consumer won't read past it. If a producer holds a transaction open (or hangs before commit/abort), consumers stall on everything after the LSO until it resolves, so long-running transactions add visibility latency.
  • Does read_committed guarantee exactly-once on its own?
    No. It only ensures consumers don't see aborted/in-flight transactional records. Exactly-once also needs the producer side to be transactional (transactional.id + atomic produce plus offset commit). Both ends together give the guarantee.

saying these in an interview costs you the question

  • Thinking read_committed is the default (it is not — read_uncommitted is).
  • Believing read_committed deduplicates or makes the consumer itself transactional.
  • Assuming read_committed hides plain non-transactional messages too.
  • Not realizing it can add latency by blocking at the LSO.

context

open as a page

How do you enable Kafka transactions in Spring, and what roles do transactional.id (transactionIdPrefix) and KafkaTransactionManager play?

level: middleimportance: must knowfreq 40%

basics

~20 s

Give the producer a transactional.id (in Spring, a transactionIdPrefix on the producer factory) so it can run transactions and be fenced across restarts. Then use KafkaTransactionManager so KafkaTemplate sends inside @Transactional commit or roll back atomically.

open as a page

In a read-process-write pipeline, how does Spring Kafka make the output sends and the consumer offset commit atomic (exactly-once)?

level: seniorimportance: must knowfreq 40%

basics

~20 s

Configure the listener container with a KafkaTransactionManager. For each batch it begins a transaction, runs your listener's sends, then commits the consumed offsets into that same transaction via the producer, and commits once. If anything fails it aborts, so neither the output nor the offset advance.

open as a page

When would you use KafkaTemplate.executeInTransaction versus @Transactional with a KafkaTransactionManager?

level: seniorimportance: should knowfreq 30%

basics

~10 s

Use executeInTransaction for a quick local, producer-only transaction without any transaction manager or Spring context. Use @Transactional with KafkaTransactionManager when you want declarative transactions that can coordinate with the listener container or other resources.

open as a page

What are the real guarantees and limitations of Kafka exactly-once semantics, and how do fencing, EOSMode, and non-Kafka side effects factor in?

level: principalimportance: should knowfreq 25%

basics

~20 s

Kafka EOS guarantees exactly-once only for read-process-write within Kafka: idempotent producers plus transactions make produce+offset-commit atomic. It does not cover external systems like databases or HTTP calls, so those must be idempotent. Fencing and read_committed complete the picture.

open as a page