Companion suite for the ankurm.com guide. Every test runs against an in-JVM KRaft broker
(spring-kafka-test), so there is no Docker requirement. Verified on Spring Boot 4.1.0,
Spring Kafka 4.1.0, kafka-clients 4.2.1, JDK 25.0.3. results/full-run.txt is unedited
output of mvn test: 15 tests, 0 failures.
Demonstrations
_01_idempotence verifies idempotence from the BROKER via Admin.describeProducers rather
than by reading config back: a default producer registers one producer
state, an acks=1 producer registers zero. Also covers the ConfigException
you get only when idempotence is requested explicitly.
_02_transactions transaction markers consuming offsets, aborted records surviving in the
log, read_committed vs read_uncommitted, and a correction: markers do NOT
inflate consumer lag (a drained consumer reaches zero), they break
counting records by offset arithmetic (measured 100% overstatement).
_03_read_process_write
the Spring loop with container-managed transactions; a listener that
fails after sending runs twice and is committed once.
_04_fencing shared transactional.id fences the zombie; a random per-startup id lets
both producers commit and silently defeats the guarantee entirely.
_05_database Kafka EOS does not extend to a database. Naive listener writes 2 rows for
1 message; the same listener keyed on topic-partition-offset writes 1.
_06_performance cost per transaction size, median of 5 interleaved rounds.
Docs
Seven chapters: Boot 4 setup (auto-configuration moved to spring-boot-starter-kafka),
idempotence, transactions and markers, fencing and KIP-890, the database boundary and the
outbox pattern, performance, and corner cases.
Measured highlights
acks=1 producer 0 broker-tracked producer states (idempotence silently off)
5 records in one tx endOffset 6; offset span overstates record count by 100%
1 record per tx 711 records/s
100 records per tx 78,770 records/s (~111x)
113 lines
4.9 KiB
Markdown
113 lines
4.9 KiB
Markdown
# 5. The boundary: Kafka, your database, and the thing that cannot be done
|
|
|
|
> Runnable companion: [`DatabaseBoundaryTest`](../src/test/java/com/ankurm/kafka/eos/_05_database/DatabaseBoundaryTest.java)
|
|
|
|
## 5.1 State it plainly
|
|
|
|
A Kafka transaction is atomic across **Kafka partitions and Kafka consumer offsets**. That is the
|
|
entire scope. It cannot include:
|
|
|
|
- a database write,
|
|
- an HTTP call to another service,
|
|
- a file,
|
|
- an email, an SMS, a push notification,
|
|
- a cache invalidation.
|
|
|
|
There is no two-phase commit between Kafka and your database, and there is not going to be. Kafka's
|
|
transaction coordinator has no protocol for enlisting a foreign resource manager.
|
|
|
|
So: **a listener that writes to a database is at-least-once with respect to that database, no matter
|
|
what your Kafka configuration says.** The redelivery that makes Kafka's guarantee work is exactly
|
|
the thing that duplicates your row.
|
|
|
|
## 5.2 What `ChainedKafkaTransactionManager` was, and why it is deprecated
|
|
|
|
Spring used to ship `ChainedKafkaTransactionManager` for this, and it is deprecated along with its
|
|
parent `ChainedTransactionManager`. The reason is worth understanding rather than just obeying:
|
|
chaining transaction managers is **best-effort ordering, not distributed coordination**. It commits
|
|
one, then the other. If the process dies in between, they disagree, and nothing reconciles them.
|
|
|
|
It narrows the window. It does not close it. A narrowed window is not a guarantee, and treating it
|
|
as one is worse than knowing you have none — because you will skip the idempotency work.
|
|
|
|
## 5.3 What to do instead
|
|
|
|
### Option A — idempotent writes (usually right)
|
|
|
|
Give the work a natural identity and enforce it with a `UNIQUE` constraint. Then a duplicate
|
|
attempt is a no-op regardless of *why* it duplicated.
|
|
|
|
```java
|
|
String dedupeKey = "%s-%d-%d".formatted(topic, partition, offset);
|
|
if (!repository.existsByDedupeKey(dedupeKey)) {
|
|
repository.save(new ProcessedOrder(dedupeKey, payload));
|
|
}
|
|
```
|
|
|
|
`topic-partition-offset` is a good default key: Kafka guarantees it is unique and stable, it is on
|
|
every consumer record, and it requires no coordination or payload changes. An event ID carried in
|
|
the message is better when the same logical event can arrive on more than one topic or be replayed
|
|
from a different offset.
|
|
|
|
Do the check-and-insert inside the database transaction and let the `UNIQUE` constraint be the
|
|
real enforcement — the `exists` check is an optimisation, not the guarantee, because two concurrent
|
|
attempts can both pass it.
|
|
|
|
Measured in `DatabaseBoundaryTest`: with a naive insert the listener ran twice and wrote **2 rows**
|
|
for one message; with the dedupe key it ran twice and wrote **1**.
|
|
|
|
### Option B — the transactional outbox (when you cannot be idempotent)
|
|
|
|
Invert the problem. The database is the source of truth:
|
|
|
|
1. In **one local database transaction**, write your business change *and* a row into an `outbox`
|
|
table.
|
|
2. A separate relay — a poller, or change-data-capture such as Debezium — reads the outbox and
|
|
publishes to Kafka.
|
|
3. The relay is at-least-once, so consumers must tolerate duplicates, which idempotent consumers
|
|
already do.
|
|
|
|
This gives genuine atomicity for the part that matters (business state and the intent to publish),
|
|
using only a local transaction. The cost is a table, a relay, and publish latency.
|
|
|
|
### Option C — make the listener's side effect naturally idempotent
|
|
|
|
Sometimes the work is already idempotent and nobody noticed: `UPDATE ... SET status='PAID'`,
|
|
`PUT /resource/123`, an upsert keyed by business ID, a `SET` in Redis. If the operation is
|
|
absorbing rather than accumulating, redelivery is harmless. Prefer designing for this over
|
|
detecting duplicates.
|
|
|
|
## 5.4 Ordering, if you use `@Transactional` anyway
|
|
|
|
With a `KafkaTransactionManager` on the container and `@Transactional` on the listener method, you
|
|
get:
|
|
|
|
```
|
|
container begins Kafka transaction
|
|
interceptor begins DB transaction
|
|
listener body runs
|
|
DB transaction COMMITS <-- first
|
|
Kafka transaction COMMITS <-- second
|
|
```
|
|
|
|
The DB commits first, deliberately: if the Kafka commit then fails, the offset is not advanced, the
|
|
record is redelivered, and an idempotent DB write absorbs it. Had Kafka committed first, a DB
|
|
failure would lose the write entirely with the offset already advanced.
|
|
|
|
So `@Transactional` is still worth having — it makes the DB write roll back when your listener
|
|
throws, which is the common case. It just does not make the two systems atomic, and the crash
|
|
window between the two commits is real. Idempotency is what covers it.
|
|
|
|
## 5.5 The rule
|
|
|
|
> **Exactly-once processing = at-least-once delivery + idempotent side effects.**
|
|
>
|
|
> Kafka transactions give you the delivery half. You build the other half.
|
|
|
|
Anyone who tells you Kafka gives you end-to-end exactly-once across your whole system is describing
|
|
Kafka-to-Kafka and has not thought about the database.
|
|
|
|
---
|
|
|
|
Next: [06-performance.md](06-performance.md).
|