Writing to an RDB from your application and then publishing the same change to Kafka is a dual write: if either side fails, consistency breaks. Kafka does not support distributed transactions (XA) with external databases, so you need to design the dual write away from the start.
The Outbox pattern persists business data and the event in a single RDB transaction, then reliably delivers the event to Kafka afterwards. It is also a recurring topic on CCDAK, so we walk through the fundamentals from a practical, operational angle.
If the application updates the RDB and sends to Kafka separately, partial failures or retries lead to inconsistency (DB succeeded but Kafka did not, or vice versa). Kafka does not run two-phase commit with external systems (per the official spec), so the application alone cannot make both sides succeed or fail atomically.
In the Outbox pattern, the business table update and the insert into the outbox table are committed in the same RDB transaction. That guarantees the existence of the event atomically on the DB side. Delivery to Kafka is handled by a downstream CDC (Change Data Capture) job or poller, with at-least-once delivery and idempotent consumers absorbing any duplicates.
The Outbox is a chronological log of update facts. It is append-only by default, and rows are written inside the application's transaction alongside the business tables. Typical columns are listed below.
id (event ID, UUID recommended), aggregate_type (e.g. Order), aggregate_id (e.g. order_id), event_type (e.g. OrderCreated), payload (JSON / Avro binary, etc.), headers (optional: schemaVersion, messageId, etc.), occurred_at / created_at (monotonically increasing timestamps for ordering), and partition_hint (the value used as the Kafka key). A processed flag is generally unnecessary because the CDC job or poller tracks its own consumption position.
There are roughly three delivery paths. In practice, CDC + Outbox routing (Kafka Connect) is the easiest to operate and tends to lead to a calm production setup. An in-app poller gives you fine-grained control, but you end up implementing deduplication and concurrency control yourself. Direct dual-writes to DB and Kafka have a high consistency risk and are not recommended.
Combining a Kafka Connect CDC connector with an Outbox Event Router (such as Debezium's EventRouter SMT) routes rows from the outbox table to different topics based on aggregate type or event_type. Kafka Connect sources are generally at-least-once; duplicates are absorbed by message IDs or compaction (availability depends on the connector and platform features).
| Approach | Delivery guarantee | Idempotence / dedup | Ordering |
|---|---|---|---|
| CDC + Outbox routing (Kafka Connect) | At-least-once (some connectors offer EOS depending on the implementation) | Use messageId as the key + compacted topic; dedup on the consumer side | Use key=aggregate_id to preserve per-partition ordering |
| In-app poller + transactional producer | At-least-once (idempotent producer suppresses duplicates) | Unique ID per Outbox row, idempotent producer, consumer-side dedup | Control keys in the app; watch out for concurrent polling and locking |
| Direct dual-write to DB and Kafka (anti-pattern) | Non-atomic; partial failures cause inconsistency | Risk of both duplicates and lost events | Ordering can be broken |
Example: Kafka Connect (Debezium Postgres + Outbox Event Router)
{
"name": "pg-outbox-connector",
"config": {
"connector.class": "io.debezium.connector.postgresql.PostgresConnector",
"database.hostname": "postgres",
"database.port": "5432",
"database.user": "debezium",
"database.password": "*****",
"database.dbname": "appdb",
"topic.prefix": "pg",
"table.include.list": "public.outbox",
"tombstones.on.delete": "false",
"transforms": "outbox",
"transforms.outbox.type": "io.debezium.transforms.outbox.EventRouter",
"transforms.outbox.route.by.field": "aggregate_type",
"transforms.outbox.route.topic.replacement": "${routedBy}",
"transforms.outbox.table.fields.additional.placement": "event_type:header:eventType,aggregate_id:header:aggregateId",
"transforms.outbox.table.expand.json.payload": "true",
"key.converter": "org.apache.kafka.connect.storage.StringConverter",
"value.converter": "io.confluent.connect.avro.AvroConverter",
"value.converter.schema.registry.url": "http://schema-registry:8081"
}
}Kafka's idempotent producer suppresses duplicates caused by producer retries, but it does not guarantee exactly-once between DB and Kafka. Outbox handles atomicity on the DB side, and you make the Kafka path idempotent on the assumption of at-least-once delivery.
Ordering is guaranteed only per partition. Use the same aggregate_id as the key so that events for one aggregate land in the same partition. As a rule, do not design for global ordering across different aggregates.
In Kafka Streams, processing.guarantee=exactly_once_v2 wraps reads, writes, and offset commits inside Kafka in a transaction. Remember, this is about processing segments inside Kafka and is a separate problem from atomicity with an external DB.
Design the Outbox payload around schema manageability. Wrapping it in an envelope (type, version, data) lets consumers branch parsing on event_type and version. Using a Schema Registry (Avro / Protobuf / JSON Schema) stabilizes compatibility checks and schema evolution.
Evolve schemas backward-compatibly by default and add fields with default values. When a breaking change is unavoidable, bump the event_type or version and split the topic (e.g. order.v2) or branch in the consumer.
For cleanup, either delete by retention window after confirming CDC has ingested rows, or rotate via partitioned tables. It is safer for the reader (CDC / poller) to track its own offset and preserve replayability than for the application to flip a "processed" flag.
Designing replay and backfill so that scanning the entire Outbox and resending is enough makes you resilient to operational incidents. When you assume duplicates from the start, the psychological cost of resending drops.
Use connector task health, source latency (DB commit time → Kafka publish time), consumer lag, and DLT (Dead Letter Topic) count as your core monitoring metrics.
CCDAK
問題 1
You want to keep RDB transactions and Kafka event delivery consistent. Assuming Kafka does not provide two-phase commit with an external DB, which architecture is the most robust?
正解: A
Kafka does not provide distributed transactions (2PC / XA) with external DBs. With the Outbox pattern, you atomically commit business data and the event inside the RDB, then reliably deliver to Kafka via CDC. B is unsupported, C makes the source of truth easy to drift, and D leads to dual-write inconsistency.
Can Kafka alone deliver "exactly once" with a database?
No. Kafka transactions and idempotence only cover writes within Kafka and offset commits. They do not provide atomicity with an external database, so you need an Outbox that commits atomically on the DB side.
How should I evolve the Outbox schema?
Include a version in the envelope and set a backward-compatibility policy in the Schema Registry. For breaking changes, run a new event_type or a new topic (e.g. order.v2) in parallel and migrate consumers one by one.
How do I deal with table bloat and latency?
Partition the Outbox by date and optimize the (aggregate_id, created_at) index. Monitor CDC throughput and scale out when latency exceeds your threshold. Keep size in check by time-based deletes of ranges that have already been ingested.
Practice with certification-focused question sets
無料で問題を解いてみるNicheeLab Editorial Team
NicheeLab editorial team focused on data engineering and cloud certification learning. Content is structured around practical study needs and official exam domains.
Kafka Topics & Partitions: Distribution Fundamentals (2026)
How Kafka topics and partitions enable scale — ordering guar...
CCDAK Exam Guide: Confluent Certified Developer (2026)
Complete prep for the CCDAK exam — Producer/Consumer API, St...
CCAAK Exam Guide: Confluent Certified Administrator (2026)
Pass the CCAAK exam — cluster management, partitions, securi...
Kafka Replicas & ISR: Fault Tolerance Explained (2026)
Replica placement, in-sync replicas (ISR), leader election. ...
Kafka Offsets: Commit Modes & Consumer Position (2026)
Offset semantics — auto vs. manual commit, __consumer_offset...