Sizing Kafka Partitions for Store Capture Throughput
This walkthrough sits under Message Broker Patterns for Capture Events and solves one precise task: choosing a partition count for the capture-event topic that survives a peak reset-window burst while preserving per-fixture ordering and never starving a consumer. The parent cluster owns the topic contract and delivery semantics; this page owns the arithmetic that comes right after — how many partitions, keyed on what? Pick the number too low and consumers cap out at the partition count no matter how many pods you run, so lag climbs through the reset window; pick it too high and you pay for rebalance storms, open file handles, and end-to-end latency you did not need. Worse, a naive key like store_id sends every capture from a flagship superstore to one partition, and that single hot partition lags while the rest sit idle. The fix is a sizing calculation that takes the larger of a throughput requirement and a consumer-parallelism requirement, a composite partition key that spreads a busy store’s fixtures across the log, and a repartitioning plan that does not shear ordering. This page builds that calculation and a skew checker step by step, and each step is independently verifiable.
Prerequisites & Context Jump to heading
Before sizing anything, confirm the following are measured, not guessed. Partition count is cheap to raise and painful to lower, so the inputs matter more than the formula.
- Runtime: Python
3.11+withconfluent-kafka(orkafka-python) on an admin host that can reach the broker’s bootstrap servers; no special hardware for the sizing math itself. - A measured peak, not an average: the events-per-second the capture-event topic sees during a coordinated chain reset, when field teams re-shoot every fixture in a window. This peak often runs
5–10xthe daily mean, and it is the number that must fit — sizing to the average guarantees reset-window lag. - A per-partition throughput ceiling: the sustained rate one partition plus one consumer can absorb end to end, including the vision-batch handoff. Treat
10 MB/sper partition as a starting ceiling and refine it from your own consumer benchmark; the Async Image Batching for High-Volume Stores stage is usually what sets it, because that is where a capture event turns into real work. - A target consumer count: the maximum parallel consumers you expect in the group. A topic’s effective consumer parallelism is capped at its partition count — extra consumers past that sit idle — so this is a hard floor on partitions.
- A partition key decision: whether ordering must hold per
store_id, perfixture_id, or per composite. The capture pipeline needs per-fixture ordering (a fixture’s frames must process in capture order), which as we will see argues for a composite key rather thanstore_idalone. The topology this topic lives in is described in Designing a Scalable Shelf Analytics Architecture.
A note on terms: a hot partition is one that receives disproportionately more traffic than its peers because the key space is skewed. The goal is a partition count and key that keep every partition within a narrow band of the mean at peak — not merely enough partitions on paper.
Step 1 — Measure Peak and Per-Partition Throughput Jump to heading
Everything downstream depends on two numbers, so pin them empirically. Sample the topic’s produce rate at your finest granularity across a real reset window and take the maximum, not the mean — a partition plan built on the daily average will be under-provisioned exactly when the reset floods it. Then benchmark one consumer against one partition end to end to get the per-partition ceiling; do not assume the broker’s raw byte rate, because the true limit is how fast your consumer can hand a capture event to the vision batch.
from dataclasses import dataclass
@dataclass(frozen=True)
class ThroughputProfile:
"""Measured, not guessed. Peak is the reset-window maximum."""
peak_events_per_sec: float
avg_event_size_kb: float
per_partition_ceiling_mb_s: float = 10.0 # start here; refine from benchmark
def __post_init__(self) -> None:
if self.peak_events_per_sec <= 0:
raise ValueError("peak_events_per_sec must be positive")
if self.avg_event_size_kb <= 0:
raise ValueError("avg_event_size_kb must be positive")
if self.per_partition_ceiling_mb_s <= 0:
raise ValueError("per_partition_ceiling_mb_s must be positive")
@property
def peak_mb_per_sec(self) -> float:
return self.peak_events_per_sec * self.avg_event_size_kb / 1024.0Step 2 — Compute Partitions From the Larger of Two Requirements Jump to heading
Partition count is bounded from below by two independent needs, and the answer is the larger of them plus headroom. The throughput requirement is peak megabytes per second divided by the per-partition ceiling. The parallelism requirement is simply the target consumer count, because a consumer group can never run more active consumers than partitions. Take the maximum, then apply a headroom factor so a routine traffic uptick or one lagging broker does not push you back into a resize.
import math
@dataclass(frozen=True)
class PartitionPlan:
throughput_partitions: int
parallelism_partitions: int
headroom_factor: float
recommended: int
def explain(self) -> str:
driver = ("throughput" if self.throughput_partitions
>= self.parallelism_partitions else "parallelism")
return (f"recommend {self.recommended} partitions "
f"(driver={driver}, headroom={self.headroom_factor:g}x)")
def size_partitions(
profile: ThroughputProfile,
target_consumers: int,
headroom_factor: float = 1.5,
) -> PartitionPlan:
"""partitions = ceil(max(throughput_need, consumer_parallelism) * headroom)."""
if target_consumers < 1:
raise ValueError("target_consumers must be >= 1")
if headroom_factor < 1.0:
raise ValueError("headroom_factor must be >= 1.0")
thru = math.ceil(profile.peak_mb_per_sec / profile.per_partition_ceiling_mb_s)
thru = max(thru, 1)
par = target_consumers
recommended = math.ceil(max(thru, par) * headroom_factor)
return PartitionPlan(thru, par, headroom_factor, recommended)
if __name__ == "__main__":
profile = ThroughputProfile(
peak_events_per_sec=4200, avg_event_size_kb=18.0
)
plan = size_partitions(profile, target_consumers=12, headroom_factor=1.5)
print(f"peak = {profile.peak_mb_per_sec:.1f} MB/s")
print(plan.explain())Keep the headroom modest — 1.5x is a sane default. Over-provisioning is not free: every partition adds open file handles, replication traffic, controller metadata, and per-partition end-to-end latency, and it lengthens every rebalance. Aim for the smallest count that clears both requirements with room to breathe, and prefer a number that divides evenly by your consumer count so partitions assign in equal shares.
Step 3 — Pick a Composite Key So Flagship Stores Don’t Go Hot Jump to heading
The partition key is where sizing quietly succeeds or fails. Keying on store_id alone feels natural and preserves store ordering, but it collapses every capture from a flagship superstore onto one partition — and that partition lags through the whole reset while the others idle, exactly the left panel of the diagram. Because the capture pipeline only needs ordering per fixture, not per store, a composite store_id:fixture_id key is both correct and far flatter: a busy store’s many fixtures hash across the entire log while every event for a single fixture still lands on the same partition, so capture order per fixture is preserved.
Do not partition by raw wall-clock time or a monotonic counter either — that concentrates the live edge on one partition. Verify the key’s flatness before you ship it with a skew checker over a real sample of keys.
from collections import Counter
from typing import Iterable
def partition_for(key: str, num_partitions: int) -> int:
"""Deterministic assignment mirroring a stable hash partitioner."""
if num_partitions < 1:
raise ValueError("num_partitions must be >= 1")
digest = 0
for ch in key:
digest = (digest * 31 + ord(ch)) & 0xFFFFFFFF
return digest % num_partitions
def capture_key(store_id: str, fixture_id: str) -> str:
"""Composite key: spreads a store's fixtures, keeps per-fixture ordering."""
if not store_id or not fixture_id:
raise ValueError("both store_id and fixture_id are required")
return f"{store_id}:{fixture_id}"
def check_skew(
keys: Iterable[str], num_partitions: int, tolerance: float = 0.20
) -> dict:
"""Flag hot partitions: any bucket more than tolerance above the mean."""
counts = Counter(partition_for(k, num_partitions) for k in keys)
total = sum(counts.values())
if total == 0:
raise ValueError("no keys provided")
mean = total / num_partitions
loads = {p: counts.get(p, 0) for p in range(num_partitions)}
hot = {p: n for p, n in loads.items() if n > mean * (1 + tolerance)}
peak_ratio = (max(loads.values()) / mean) if mean else 0.0
return {
"mean_per_partition": round(mean, 1),
"peak_to_mean_ratio": round(peak_ratio, 3),
"hot_partitions": hot,
"balanced": not hot,
}Run check_skew over a day of production keys against the count from Step 2. A store_id-only key on a chain with a few dominant stores lights up hot_partitions; the composite key should return balanced with a peak_to_mean_ratio close to 1.0.
Step 4 — Plan Repartitioning Without Breaking Ordering Jump to heading
Adding partitions to a live topic changes the hash mapping, so a key that used to land on partition 3 may now land on partition 7. Any in-flight events for that key already sitting in the old partition can then be consumed out of order relative to the new arrivals — a silent ordering break for that fixture. Never treat --alter --partitions as a routine dial during a reset window.
The safe pattern is to drain, then cut over. Stop producers (or let the group fully catch up so no key has events straddling the change), raise the partition count while consumers are caught up, and only then resume. For a large jump, prefer a new topic sized correctly from the start with a mirror-and-swap: create capture-events.v2 with the target count, dual-write or mirror, let consumers drain v1, then switch the producer alias. This keeps each key’s history contiguous and is the same broker-topology discipline covered by the parent Message Broker Patterns for Capture Events cluster. Size generously in Step 2 precisely so you rarely have to do this.
Verification & Testing Jump to heading
Confirm each property deterministically rather than trusting a dashboard glance:
- Parallelism floor holds. Call
size_partitionswith a tiny peak buttarget_consumers=12and assertrecommended >= 12— the consumer count, not throughput, must drive the answer here. - Throughput floor holds. Call it with
target_consumers=2and a peak that needs9partitions and assert throughput drives it, withrecommendedat leastceil(9 * headroom). - The composite key is flat. Run
check_skewover a representative day ofcapture_key(store, fixture)values and assertbalancedisTrueandpeak_to_mean_ratio < 1.2. store_idalone is not flat. Runcheck_skewoverstore_id-only keys for a chain with a flagship store and asserthot_partitionsis non-empty — this is the regression the composite key fixes.- Ordering is preserved per key. Assert
partition_for(capture_key(s, f), n)is stable across calls for the same key, so every frame of one fixture routes to one partition. - No consumer lag at peak. In a staging replay of the reset window, assert consumer-group
p95lag staysp95 lag < 5 sand end-of-window lag returns to0.
A healthy plan shows every partition within roughly ±20% of the mean depth at peak, lag draining to zero after the reset window closes, and no single consumer pinned at 100% while its peers idle.
Troubleshooting Jump to heading
| Symptom | Likely root cause | Remediation |
|---|---|---|
| One partition lags while the rest idle at peak | Hot partition from a store_id-only key concentrating a flagship store |
Switch to the composite store_id:fixture_id key and re-run check_skew; confirm peak_to_mean_ratio drops toward 1.0 |
| Adding consumers past a point does nothing | Consumer count now exceeds partition count — extras sit idle | Raise partitions to at least the target consumer count via Step 2, then drain-and-cut over per Step 4 |
| Rebalance storms during the reset window | Partition count altered live, or consumers flapping so the group reassigns repeatedly | Never resize during peak; use a mirror-and-swap topic, and tune session.timeout.ms so a slow consumer is not evicted mid-batch |
| End-to-end latency crept up after a resize | Over-partitioning — too many partitions add metadata, file handles, and per-partition latency | Right-size to the smallest count clearing both floors with 1.5x headroom; do not chase a round number far above need |
| Skew persists even with the composite key | A handful of mega-fixtures dominate, or key cardinality is too low for the partition count | Add a bounded salt suffix to the busiest fixtures and re-check, or reduce partitions so cardinality comfortably exceeds them |
Related Jump to heading
- Message Broker Patterns for Capture Events — the parent cluster that owns the capture-event topic contract, delivery semantics, and repartitioning discipline
- Async Image Batching for High-Volume Stores — the consumer stage whose batch throughput sets the per-partition ceiling used in Step 2
- Designing a Scalable Shelf Analytics Architecture — where this topic and its consumer group sit in the end-to-end topology