The log abstraction¶
10:04 AM: a parser bug dropped 40 minutes of production logs last Tuesday. Someone asks, "can we just replay them?" The honest answer depends entirely on what durable thing sits between the producers and every consumer — and whether that thing deletes a record the moment one reader finishes with it.
Predict before you read on: if you built that store as a database table (insert, SELECT ... FOR UPDATE, delete), what breaks first at 2.5 million records a second — the write path, the fan-out to multiple readers, or the replay story? All three fail, for three different reasons; this page is the one abstraction that fixes all three at once.
Start with the situation¶
The observability platform writes every request as an event:
{"timestamp":"2024-01-15T10:03:45.123Z","customer_id":"cust_1842","user_id":"u_99102","service":"api","endpoint":"/orders","region":"eu-west-1","latency_ms":87,"status_code":200,"bytes":4096}
Alerting wants them in seconds. The warehouse wants them tonight. Fraud wants a second copy with a different processor. Last Tuesday a parser bug dropped 40 minutes of logs; you need to replay those 40 minutes without asking 400 services to re-emit.
You need a store that is cheap to append, cheap to read sequentially, and that does not delete a record just because one consumer finished it.
Why the obvious approach breaks at scale¶
Teams try a database table used as a queue: insert event, consumer SELECT … FOR UPDATE, delete.
That fails for specific reasons:
- Coupling. Producers and consumers share a schema and a connection pool. A warehouse backfill locks rows the API needs.
- Single cursor. Delete-on-ack means the second consumer never sees the row. Fan-out becomes N writes, not N reads.
- Wrong I/O pattern. OLTP stores are built for point lookups and updates. A 2.5M record/s append path wants sequential writes and sequential reads.
- No replay. Once deleted, the evidence is gone. A buggy consumer is a data-loss incident.
- Backpressure leaks upstream. If the table is the buffer, a slow consumer either blocks producers or grows the table until the primary falls over.
Object storage (S3) is cheap and durable but has no notion of "consumer group offset 1_204_332 on partition 7" and is a poor fit for 50ms alerting.
Build the mental picture¶
Treat the stream as a file you only append to.
Position: 0 1 2 3 4 5 6 7
─────────────────────────────────────→
Event: [e0] [e1] [e2] [e3] [e4] [e5] [e6] [e7]
Producer appends at the tail (next offset 8)
Consumer A reads from offset 3
Consumer B reads from offset 6
- Immutable. A written record is not updated in place. A "correction" is a new record (or a compacted later value for the same key).
- Ordered. Offsets increase. Offset 5 on partition 0 is not offset 5 on partition 1.
- Retained independently of consumers. Time or size (or compaction) decides when bytes go away.
- Multi-consumer. Many groups, many offsets, same bytes.
A topic is a named stream (service-events, login-events, orders). A topic is split into partitions so that append and consume can happen in parallel. Each partition is one log.
graph LR
subgraph "Topic: service-events (3 partitions)"
P0["Partition 0\n0→1→2→3→4→5"]
P1["Partition 1\n0→1→2→3"]
P2["Partition 2\n0→1→2→3→4"]
end This is the same idea as foundation-level partitioning: split so workers do not share a write cursor.
Internals: segments and indexes¶
On disk, a partition is a directory. Kafka does not append forever into one giant file. It rolls segments.
/var/lib/kafka/service-events-0/
00000000000000000000.log
00000000000000000000.index
00000000000000000000.timeindex
00000000000000001248.log ← active segment (being appended)
00000000000000001248.index
00000000000000001248.timeindex
leader-epoch-checkpoint
The filename is the base offset of the first record in that segment. Default log.segment.bytes is 1 GiB; log.segment.ms can roll earlier so retention can delete whole files.
| File | Role |
|---|---|
.log | Record batches (the bytes) |
.index | Sparse map: relative offset → byte position in the .log |
.timeindex | Sparse map: timestamp → offset |
Indexes are sparse (log.index.interval.bytes, typically 4 KiB of log between entries). A fetch for offset 1_204_332 does a binary search on the offset index, then a sequential scan of a few kilobytes of log — not a full partition scan.
Producers write record batches (many records, often compressed with lz4/zstd). The broker appends the batch to the page cache; Linux writes it out. Kafka's throughput story is "sequential append + sequential read from page cache", not "we invented a faster disk".
Retention deletes closed segments whose newest record is older than log.retention.ms (or whose total bytes exceed log.retention.bytes). The active segment is not deleted. That is why a topic with tiny volume can appear to retain "too long": one segment has not rolled.
Retention is per segment, not per record
A 1 GiB segment that rolled yesterday still holds all of its records until the whole file is eligible. If you need minute-granularity delete, you need smaller segments — and you pay with more files and more indexes.
Internals: topics, keys, and the producer write path¶
A producer chooses a partition, then the leader of that partition appends.
from kafka import KafkaProducer
import json
producer = KafkaProducer(
bootstrap_servers=["localhost:9092"],
value_serializer=lambda v: json.dumps(v).encode("utf-8"),
key_serializer=str.encode,
acks="all",
linger_ms=10,
compression_type="lz4",
)
event = {
"timestamp": "2024-01-15T10:03:45.123Z",
"customer_id": "cust_1842",
"user_id": "u_99102",
"service": "auth",
"endpoint": "/login",
"region": "eu-west-1",
"latency_ms": 87,
"status_code": 401,
"bytes": 512,
}
# Same customer_id → same partition → per-customer order
producer.send("service-events", key=event["customer_id"], value=event)
producer.flush()
The default partitioner hashes the key (murmur2(key) % num_partitions). Null keys use the sticky partitioner (Kafka 2.4+): fill a batch for one partition, then pick another. That is better for compression and throughput than per-record round-robin. Details and hot-key behaviour: partitions.
Internals: consumers and offset commits¶
Consumers read batches, process them, then commit an offset meaning "I have processed up to here". Commits go to the internal __consumer_offsets topic, not to the log you are reading. Two groups never share a cursor.
from kafka import KafkaConsumer
consumer = KafkaConsumer(
"service-events",
group_id="alert-processor",
bootstrap_servers=["localhost:9092"],
enable_auto_commit=False,
auto_offset_reset="earliest",
value_deserializer=lambda v: json.loads(v.decode("utf-8")),
)
for message in consumer:
process_event(message.value)
consumer.commit() # after success — at-least-once
If the process dies after process_event and before commit, the group re-reads that record. That is at-least-once. Auto-commit (enable_auto_commit=True) commits on a timer regardless of whether your handler succeeded — convenient, and a way to skip records after a crash.
auto_offset_reset only applies when the group has no committed offset: earliest replays retained history, latest skips to the tail. It is not "if lag is large, skip".
Retention versus compaction¶
Two cleanup policies, two jobs.
Delete (log.cleanup.policy=delete) — the observability default. Keep N hours or M bytes of raw events, then drop whole segments. Replay window = retention. A consumer slower than retention loses data (OffsetOutOfRange, then auto_offset_reset behaviour).
Compact (log.cleanup.policy=compact) — "latest value per key". User settings, current order status, device shadow, a changelog of a Flink/Kafka Streams table.
Before compaction:
user_1 → {name: "Alice"}
user_2 → {name: "Bob"}
user_1 → {name: "Alice Smith"}
After compaction:
user_2 → {name: "Bob"}
user_1 → {name: "Alice Smith"}
A tombstone (key=user_1, value=null) marks the key for deletion; after delete.retention.ms the tombstone itself goes away.
Compaction is not instantaneous. A cleaner thread rewrites older segments when the "dirty" ratio is high enough (log.cleaner.min.cleanable.ratio). The tail (active segment, plus a configurable amount of recent data) is not compacted. Duplicate keys in the tail are normal.
You can set compact,delete to compact and drop records older than a retention — useful for changelogs that should not grow forever.
Compaction is not a query engine
You still cannot ask Kafka "give me user_1" without a consumer that has read the compacted topic into memory or a store. Compaction only bounds disk for a key-keyed log.
How: topic config you will actually set¶
kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic service-events \
--partitions 12 \
--replication-factor 3 \
--config retention.ms=172800000 \
--config compression.type=producer \
--config min.insync.replicas=2
# Latest-value store for enrichment (e-commerce user profile, IoT device shadow)
kafka-topics.sh --bootstrap-server localhost:9092 --create \
--topic user-profile \
--partitions 12 \
--replication-factor 3 \
--config cleanup.policy=compact \
--config min.cleanable.dirty.ratio=0.5 \
--config delete.retention.ms=86400000
compression.type=producer keeps the codec the producer chose. Re-compressing on the broker wastes CPU.
Where teams get caught¶
Page cache is the real buffer. A broker that looks like it has "free RAM" may be serving all consumers from cache. A second consumer group that scans a cold 7-day topic will hit disk and take the page cache away from the live tail. Observability clusters often isolate "live" topics from "replay" topics, or isolate brokers.
JSON with no schema will work in the lab and hurt in month three. Schema evolution is not "we installed a registry"; it is compatibility rules plus what consumers do with unknown fields. Covered in gotchas.
Offsets are not timestamps. "Replay from 10:00" needs the time index (offsets_for_times / kafka-consumer-groups --reset-offsets --to-datetime). Clock skew in timestamp (create time vs log append time) will seek to the wrong place.
Very small segment.bytes creates tens of thousands of files. Brokers open many file handles; recovery and replica fetch slow down. Very large segments delay retention.
How it fails¶
| Failure | What you see | What actually happened |
|---|---|---|
Consumer OffsetOutOfRangeException | Group jumps to latest (if auto_offset_reset=latest) and skips | Lag exceeded retention; segments deleted |
| Disk full on broker | Producers time out; ISR shrinks | Retention too long, or one hot partition filled a disk |
| Slow replica fetch | Under-replicated partitions | Follower reading cold segments, or disk saturation |
| Compaction "not working" | Topic still huge | Dirty ratio not reached; keys are unique so there is nothing to compact |
| "We lost Monday" | Replay empty | delete policy, 24h retention, nobody noticed lag until Tuesday |
A compacted topic with unique keys (raw service-events keyed by UUID) never shrinks. Compaction only helps when keys repeat.
How to investigate¶
Start with disk and offsets, not with "is Kafka up".
# Segment files and sizes for one partition
ls -lh /var/kafka-logs/service-events-0/
# Consumer position versus log end
kafka-consumer-groups.sh --bootstrap-server localhost:9092 \
--describe --group alert-processor
# Earliest / latest offsets
kafka-run-class.sh kafka.tools.GetOffsetShell \
--bootstrap-server localhost:9092 --topic service-events
| Metric | Healthy | Investigate |
|---|---|---|
LogEndOffset − committed offset (lag) per partition | Flat or sawtooth | Growing on one partition: hot key or stuck consumer |
Broker disk used / log.retention.* | Headroom for a burst | One partition directory dominating a disk |
BytesInPerSec vs BytesOutPerSec | Out ≈ in × (groups + RF-1) | Out >> in: replay storm or too many full-speed groups |
| ISR size / under-replicated partitions | 0 URP | Follower cannot copy segments fast enough |
If lag is high and the earliest offset is racing toward the committed offset, you are about to hit retention. That is a data-loss countdown, not a latency SLO miss.
Scale: 10× / 100× / 1000×¶
Start from ~25k events/s on service-events (a busy SaaS region).
| Scale | Ingest | Log behaviour |
|---|---|---|
| 10× (~250k/s) | Batching (linger_ms, batch.size) and compression start to matter more than partition count. Page cache still absorbs the tail. | |
| 100× (~2.5M/s) | This is the observability number. Sequential disk and network for replication dominate. Split topics by retention (hot 6h vs cold 7d). More disks (log.dirs), not one giant volume. | |
| 1000× | You are into multiple clusters or tiered storage (local tail + object storage for old segments). Controller metadata, replica fetchers, and "replay 7 days" become operational programmes, not flags. |
IoT at 1000× is often many small keys (good for compaction of device state, bad if you naively compact raw telemetry). Fraud at 1000× is usually not 1000× events — it is 1000× cost of a duplicate or a drop.
Trade-offs¶
| Choice | You gain | You give up |
|---|---|---|
| Long retention | Replay, late consumers | Disk, longer ISR catch-up, expensive cold reads |
| Compaction | Bounded "latest per key" | No full history; cleaner CPU; tail still dirty |
| Many small segments | Faster retention | File handles, slower broker recovery |
| JSON values | Fast to ship | Weak evolution, fat bytes, no compact schema |
| One topic for all events | Simple routing | Mixed retention, mixed keys, mixed SLAs |
Alternatives¶
| Alternative | When it wins | When it loses |
|---|---|---|
| Postgres / outbox table | Low rate, transactional write with OLTP | High rate sequential ingest, many independent consumers |
| RabbitMQ / SQS | Work queues, competing consumers, short retention, routing topologies | Replay, 10+ independent readers of the same stream |
| Pulsar | Separate compute/storage, multi-tenancy | Operational complexity; most teams do not need it first |
| Kinesis | Managed, AWS-native | Shard management, cost at observability volume |
| S3 + notification | Cheap archive | Not a tail; not per-partition order; not 50ms consumers |
If the workload is "process each file once into the lake", you may want object storage and Spark, with Kafka only as the live path.
How to apply this at work¶
When someone says "we'll just publish to Kafka", ask:
- What is the key? What ordering do we actually need?
- What is the replay window? Write that as
retention.ms, not as a hope. - Who are the consumer groups, and is any of them allowed to be a day behind?
- Is this a raw event log (delete) or a changelog (compact)? Do not compact unique-key telemetry.
- What happens when a consumer hits
OffsetOutOfRange— skip, halt, or restore from the lake?
If the answer to (2) is "forever" and the answer to (4) is "raw events", you want a lakehouse table, not infinite Kafka.
Check your understanding¶
A compacted topic user-profile (key = user_id) has 50 million unique users. Producers send a full profile snapshot on every change, ~2 updates/user/day. The topic is 1.2 TB and growing. Product wants "rebuild any service from Kafka in 20 minutes".
- Why is compaction not shrinking the topic much?
- What config or design change bounds disk without breaking rebuild?
- Why is
service-eventsthe wrong topic to compact?
Answer
-
Fifty million unique keys means the compacted baseline is "one record per user". If snapshots are large (JSON with nested prefs), 50e6 × ~20 KB is already ~1 TB after compaction. The cleaner only removes older versions. Growth is payload size × cardinality, not "compaction is broken". Also the tail is not compacted, so a high-rate changelog always has a dirty head.
-
Shrink the value (Avro/Protobuf, only changed fields, or store blob in object storage and keep a pointer). Raise cleaner parallelism if the dirty ratio stays high. Optionally
compact,deletewith a retention if you do not need ancient keys — but then a rebuild cannot resurrect users who never updated inside the window. A better rebuild story is: compacted Kafka for hot profiles plus a snapshot in the lake. -
service-eventskeys bycustomer_idor not at all; records are not "latest state". Compacting them would drop history alerting and fraud need, and would not even shrink much if keys are high-cardinality (user_id+ timestamp uniqueness). Raw events use delete retention; changelogs use compact.