Kafka in Go: The Consumer Group That Actually Works
Every Go service that “integrates with Kafka” starts the same way and ends the same way: a NewConsumerGroup, a fetch loop, and a 3am page — the consumer died, the group rebalanced, the committed offset was for the previous batch, and the next batch got delivered twice. The failure is never in the broker. It is in three decisions your service makes: how it joins the group, when it commits, and what it does on a rebalance.
This post is the design that removes that page, in Go: one group per process, one commit per batch (not per message), and a rebalance that logs instead of panics. Plus the one piece of real logic a consumer needs — an in-memory dedupe store with a TTL — because batched commits buy you at-least-once delivery and redelivered messages are a fact of life, not an accident.
By the end: a working consumer group, a dedupe store, a test, and the three rules that keep the whole thing boring.
How a consumer group actually works
A consumer group is a set of members; the broker assigns each partition of the subscribed topics to exactly one member, and reassigns on a rebalance (a member joins, leaves, or lags). The mental model that fixes most Kafka bugs:
- A Go process is a member, not a group. Four replicas of your service that each open a consumer with the same
GroupIDare four members of one group — Kafka load-balances the partitions across them for free. - A rebalance is normal traffic, not an error. The only thing a rebalance must not do is take your process down with it.
- Offsets are committed per partition, by the group. A commit advances “where the group was up to” — a slow member on one partition holds back the group’s progress on that partition.
stateDiagram-v2
[*] --> Joining: NewConsumerGroup
Joining --> Assigned: group assigns partitions
Assigned --> Consuming: Fetch returns a batch
Consuming --> Committed: handler returned, offsets committed
Committed --> Consuming: next batch
Assigned --> Joining: rebalance (member joined/left)
The three rules
- One group per process. A consumer group is a Kafka concept; a Go process is not one. If you run 4 replicas of your service and each opens its own group (4 distinct
GroupIDs), you have 4 groups, 4× the work, and zero load balancing. SameGroupIDeverywhere: the replicas are 4 members of 1 group, and the broker does the partition assignment. - Commit at the end of the batch, after the handler, not per message. A commit per message is O(n) round-trips; a commit per batch (100 messages or 500ms, whichever first) is O(1) per batch. The trade is at-least-once delivery: a crash between handler and commit redelivers the batch. You absorb that with a dedupe store keyed on the message ID.
- The rebalance callback is where you clean up, not where you panic. A member that panics during a rebalance is dropped from the group, which triggers another rebalance — a rebalance of a rebalance, each one re-fetching offsets. Log, drain the in-flight batch, continue.
The code: the consumer (the whole thing)
| |
The code: the dedupe store (and its test)
| |
| |
The rebalance and the redelivery
The 3am page, traced end to end — this is what the design above survives:
sequenceDiagram
participant R as Go replica (group member)
participant B as Broker (topic, 4 partitions)
participant H as Handler
participant D as Dedupe (TTL map)
B--->>R: batch (100 msgs, partition 2)
R-->>D: check each message ID
D--->>R: 99 new, 1 redelivered from a prior batch
R-->>H: handle the 99
H--->>R: ok
R-->>B: CommitOffsets(partition 2, last offset)
Note over R: process crashes between handler and commit
B--->>R: redelivers the same 100
R-->>D: check IDs --> all 100 inside the TTL window
D--->>R: skip all 100
R-->>B: CommitOffsets (same offsets, no double-processing)
Commit strategy: the actual trade
| Commit style | Round-trips / 1000 msgs | Crash between handler and commit | Redelivery window | Dedupe load |
|---|---|---|---|---|
| Per message | ~1000 | Nothing to redeliver (but 1000× the latency) | None | None needed |
| Per batch of 100 (this post) | ~10 | The incomplete batch redelivers | ≤ 1 batch (≤ 500ms) | IDs for ≤ 500ms |
| Per partition per second | ~10 | Up to 1s of that partition | ≤ 1s | Up to 1s of IDs |
Note: use the producer’s message ID (Kafka’s
Message.Keyor a header) as the dedupe key. A hash of the content only works when you need to bound memory with a hash — and then the hash is the key, not a stand-in for one.
Warning: commits are per-partition, so a slow handler on one partition holds back the entire group’s progress on that partition — other members keep consuming the other partitions, but this one stalls. Keep handlers sub-second, or fan them out to a goroutine pool and commit on completion.
Takeaways
- A consumer group is the load balancer. Same
GroupIDacross all replicas; the broker assigns partitions, you just make your members behave. - Batch the commit: 100 messages / 500ms, whichever first. O(1) per batch instead of O(n) per message.
- Commit after the handler = at-least-once. Redelivered batches are normal; a TTL dedupe store absorbs them.
- A rebalance is not an error. Log it, drain the batch, continue. A panicking member triggers a rebalance of a rebalance.
- The dedupe store is the only real logic in a production consumer; everything else is Kafka doing its job.
Next steps
- Exactly-once? Kafka’s transactional producers plus idempotent consumers get you there — but only if your handler is idempotent; the framework does not make your SQL idempotent for you.
- Add a consumer lag metric (log end-offset − committed-offset per partition). A growing lag on one partition is the 3am page arriving 20 minutes earlier.
- Backpressure: if the handler is slower than the fetch rate, the batch buffer grows without bound. Cap it: when the in-flight batch list exceeds a high-water mark, stop
Fetchand drain — the group’s rebalance protocol handles the rest.