One viral post, one very unhappy shard.
A single counter maps to one hash slot and therefore one primary — so adding Redis shards does nothing for it. The fix is to separate per-entity skew from system-wide capacity, and to recognise those as two different knobs that get turned for two different reasons.
00The core idea
“Separate per-entity skew from system-wide capacity. A viral post should change application-level bucketing. Overall traffic growth should change infrastructure — Redis primaries, and when planned, Kafka partitions. Turning the infrastructure knob to fix a skew problem does not work.”
post:101:likes → one key → one hash slot → one primary → HOT SHARD no amount of adding primaries changes that chain
01Logical bucketing
Split one logical counter into many independently hashable keys, and route each user deterministically: bucket = stable_hash(user_id) % N. The single-key bottleneck disappears because there is no longer a single key.
The cost is that a read now needs an aggregation step — either summing buckets at read time, or maintaining a periodically recomputed total so the read path stays one GET.
02Two hashing layers, two jobs
application routing user_id → stable_hash(user_id) % N → logical bucket Redis routing post_id + bucket → CRC16(key) % 16384 → hash slot → physical primary
| Layer | Problem it solves | Who controls it |
|---|---|---|
| Application hashing | Spreads one hot logical entity across many keys | You, in application code |
| Redis Cluster hashing | Maps those keys onto physical capacity | Redis, automatically |
They are not alternatives and neither substitutes for the other. Redis will happily balance slots perfectly and still leave you with one scorching key, because balancing slots is not the same as balancing load.
03Redis Cluster, slots and resharding
A cluster has exactly 16,384 hash slots, assigned to primaries — not necessarily contiguously. A shard is a primary plus replicas; replicas give availability and optional read scaling, and add no primary write capacity.
The client is cluster-aware: it discovers topology, caches the slot-to-primary map, computes the slot for each key and talks to the right primary directly. It is not a coordinator — every app process has its own. It also handles ASK (this key has moved for now, during migration) and MOVED (slot ownership changed permanently — refresh your map).
Redis Cluster uses a fixed intermediate layer of 16,384 slots rather than a hash ring with virtual nodes. Both avoid remapping everything when nodes change; Redis picks explicit slots because they are easy to rebalance, migrate and describe in cluster metadata. Build a ring in a custom cache if you like — but do not layer one on top of a datastore that already has its own partitioning model.
resharding 10 primaries → 11 before: 10 primaries ≈ 1,638 slots each after: 11 primaries ≈ 1,489 slots each a balanced rebalance takes slots from several primaries, not one. application keys never change — only which primary owns their slots.
04How many buckets?
Logical bucket count N and physical shard count M are independent. With 64 buckets and 16 shards the average is 4 buckets per shard — but the actual placement is probabilistic. A shard may get 7, or 2, or none for that particular post. Redis balances slots, not “buckets per post”.
skew math, for interviews buckets landing on one shard ≈ Binomial(N, 1/M) mean μ = N / M stddev σ = sqrt(N · (1/M) · (1 − 1/M)) typical hottest shard ≈ μ + sqrt(2 ln M) · σ for M ≈ 10, sqrt(2 ln M) ≈ 2.15 then convert back to QPS: per-bucket QPS = total QPS / N never capacity-plan from total QPS / shard count alone
| At 1M writes/sec | Per bucket |
|---|---|
| 64 buckets | 15,625 QPS |
| 256 buckets | 3,906 QPS |
| 1024 buckets | ~977 QPS |
Use substantially more logical buckets than physical shards, so placement luck stops mattering — but not absurdly more, because every bucket adds keys, metadata, aggregation work and operational surface.
05Three knobs: N, M and B
This is the heart of the topic. At 10M QPS with only 64 buckets, one bucket carries ~156K QPS — already past what a primary comfortably serves. Scaling from 10 to 100 primaries changes nothing for that key, because one key still maps to one slot on one primary. N has to rise first.
And the corollary worth stating: you can scale 1M → 10M without batching at all, given enough buckets and enough primaries. Batching is a cost and efficiency optimisation, not the only survival route.
06Kafka batching, done safely
Ten million like events per second need not become ten million Redis commands. Consumers can aggregate increments for the same (post_id, bucket) and issue one INCRBY key 10000 instead of ten thousand INCRs.
Redis INCRBY succeeds, the consumer crashes, the Kafka offset was never committed, the records are redelivered — and the batch is counted twice. In-memory batching is only safe because Kafka still holds the original events; the protection has to be explicit.
atomic, in one Lua script:
if incoming_offset already applied:
return DUPLICATE
INCRBY counter delta
update last_processed_offset
then, and only then, commit the Kafka offsetA high-watermark offset is only safe if that partition is processed sequentially — otherwise a later offset can complete first and make an earlier one look like a duplicate.
First ask whether all related changes can happen in one transactional boundary — if yes, take it, because it gives the simplest correctness model. Only when operations span systems that cannot be atomic do you reach for step-level idempotency, conditional updates, outbox/inbox or state machines. Atomicity means all-or-nothing; idempotency means repeating is harmless. Strong systems use both.
07Changing N safely
N can be raised dynamically from per-post metrics — write QPS, queue lag, latency contribution. The catch is that changing N changes the modulo mapping:
old: bucket = stable_hash(user_id) % 64
new: bucket = stable_hash(user_id) % 256
the same user now maps somewhere else — so the cutover needs
explicit semantics, usually versioned routing:
post:101 metadata
mode = SHARDED
version = 3
bucket_count = 256
new writes → post:101:v3:likes:[bucket]
total = sum(v1) + sum(v2) + sum(v3)Promotion works the same way: a normal post is one key, and when it crosses a threshold the existing count is frozen as a base while new likes go to buckets — total = base + sum(buckets). At very large scale it is often simpler to use stable bucketing from the start than to operate a promotion path.
08What is automatic, what you decide
| Automatic | Your design decision | Platform / SRE |
|---|---|---|
| Kafka record → partition routing | Choosing the Kafka key | Provisioning brokers and Redis nodes |
| Key → slot calculation | Bucket strategy and count | Changing Kafka partition count |
| Slot → primary routing | Hot-entity thresholds | Adding primaries and replicas |
| ASK / MOVED handling | Key layout and hash tags | Triggering rebalance / resharding |
| Replication and failover | Batching window, idempotency, timeouts | Cluster health, rollout and rollback |
Partition count is an infrastructure decision sized from expected throughput and consumer parallelism — a normal post and a viral post share the same pool. Increasing it is infrequent, changes future keyed routing, and leaves historical records where they were. Start with a large stable pool and use a routing key that lets one hot entity spread across it.
09Question drill
One counter is overloading a shard. Add more shards?
No. That key hashes to one slot owned by one primary, and more primaries does not change that. Split the logical counter into N bucket keys so there are N independently hashable keys to distribute.
What are the two hashing layers and why both?
Application hashing (hash(user_id) % N) spreads one hot entity across many keys. Redis Cluster hashing (CRC16 % 16384) maps keys onto physical capacity. The first solves skew, the second solves capacity — neither substitutes for the other.
How many buckets should a hot post have?
Substantially more than the physical shard count, so random placement stops mattering. Work it from QPS: at 1M writes/sec, 64 buckets is 15,625 QPS each; 1024 buckets is ~977 each. Then check the skew — the hottest shard is roughly μ + 2.15σ for around 10 shards.
Can you go from 1M to 10M QPS without batching?
Yes, if N is large enough and Redis has time and headroom to scale M. Batching reduces the number of Redis operations and therefore cost — it is an efficiency lever, not the only way to survive growth.
At 10M QPS with 64 buckets, why won't 100 primaries save you?
Because 10,000,000 ÷ 64 = 156,250 QPS on a single bucket key, and one primary handles around 150k. That key exceeds one primary's capacity, and a key cannot be split across primaries. Raise N first.
Does Redis Cluster use consistent hashing?
Not a classic ring. It uses a fixed layer of 16,384 slots mapped to primaries. Same goal — avoid remapping everything when nodes change — but explicit slots are easier to rebalance, migrate and describe in metadata.
What is the difference between ASK and MOVED?
ASK is temporary: during a slot migration this particular key now lives on another node, retry there. MOVED is permanent: slot ownership changed, so update your cached topology.
Do replicas add write capacity?
No. Replicas provide availability and can serve reads. Writes go to the primary owning the slot, so replicas do nothing for a write-hot key.
A viral post appears. Which knob do you turn?
N — application-level bucket count for that entity. M only moves when the whole cluster shows sustained pressure: CPU, p99, network, memory, command rate, failover headroom. And Kafka partitions are not a per-post lever at all.
Consumer batches increments, crashes before committing. What now?
Kafka redelivers and the batch would be applied twice. Make the dedupe check, the INCRBY and the offset update one atomic Lua script, then commit the Kafka offset. A high-watermark offset requires sequential processing within the partition.
How do you change N on a live hot post?
Versioned routing. Keep mode, bucket_count and version in metadata, send new writes to v3 keys, and compute the total across versions. Changing the modulo without versioning silently remaps users mid-flight.
10Cheat sheet
| Term | One line |
|---|---|
| N — logical buckets | Per-entity skew control. Raised when one entity gets hot. |
| M — primary shards | Total cluster capacity. Raised when the whole cluster is pressured. |
| B — batch size | Events per Redis command. Lowers command rate and cost. |
| Hash slot | One of 16,384 fixed routing buckets owned by a primary. |
| Hash tag | {...} forcing related keys into one slot so Lua can span them. |
| ASK | Temporary redirect during slot migration. |
| MOVED | Permanent ownership change — refresh the topology cache. |
| Resharding | Moving slots (and their keys) between primaries. |
| Promotion | Freezing a normal counter as a base and moving new writes to buckets. |
| Versioned routing | Metadata making a bucket-count change safe mid-flight. |
A viral post changes N. A busy cluster changes M. Confusing the two is the whole trap.
← all notes