How One 2.8-Million-Member Sorted Set Froze Redis Mid-Reshard, Two Days Before a Flash Sale
11:42 UTC, two days before the sale goes live. Datadog pages on-call:
redis_p99_latency over 2000ms on three of six shards. Nobody's running
load tests. There's no traffic to speak of yet. The only thing happening on the
cluster right now is the resharding job that's supposed to make this weekend boring.
the scramble
The team had provisioned three new primaries the day before, planning to migrate half
the cluster's 16,384 hash slots onto them with redis-cli --cluster reshard,
finish early, and have a full day to watch it sit quietly before the sale started. The
reshard kicked off at 11:30 UTC. Twelve minutes later, the pages started.
First theory: the new nodes are undersized. Someone pulls up top on the
affected shard. 21% CPU, memory nowhere near the configured maxmemory, disk iowait at
zero. The box looks bored, not overloaded, which makes the latency graph look like a
lie.
Second theory: the new primaries sit in a second availability zone, added for redundancy as part of this scale-up, and cross-AZ latency is the usual suspect when a topology change coincides with a slowdown. Ping times between the zones come back at 0.6ms. That's not six seconds of anything.
Third theory: a noisy neighbor on shared EC2 hardware. CloudWatch shows no CPU steal, no burst balance depletion, nothing unusual on the instance. Three plausible causes, three dead ends, and the latency graph keeps climbing in the same sawtooth pattern every few minutes.
the hunt
The reshard job is still running in a tmux session from the first engineer who kicked it off. Someone scrolls up through its output and lines the timestamps up against the Datadog graph on a second monitor. Every latency spike lands within a second of a "Moving slot" line in the migration log. Not traffic-correlated. Migration-correlated.
Moving slot 7823 from 10.0.4.12:6379 to 10.0.6.21:6379:
Moving slot 7824 from 10.0.4.12:6379 to 10.0.6.21:6379: .
Moving slot 7825 from 10.0.4.12:6379 to 10.0.6.21:6379: ....
Moving slot 7826 from 10.0.4.12:6379 to 10.0.6.21:6379: .......
(6.1s pause between dots)
That last slot stalls for over six seconds mid-migration, no output, then resumes.
INFO commandstats on the sending node confirms where the time is going.
cmdstat_migrate:calls=412,usec=38204991,usec_per_call=92731.53,
rejected_calls=0,failed_calls=0
Ninety-two milliseconds average is already odd for a command that's supposed to move
one key at a time in small batches. CLIENT LIST on the same node shows the
real picture: dozens of client connections sitting with high age and no
movement, all queued behind something that isn't releasing the main thread. Pausing the
reshard and running a scan on the slot it stalled on answers the question.
Biggest sorted set found 'trending:global:zscore' has 2834107 members
Biggest sorted set size in bytes is approximately 341 MB
trending:global:zscore backs the homepage "trending now" rail. Every
product view across the entire catalog calls ZINCRBY against it. It has
no TTL, no cap, and no trim job. Fourteen months of launch traffic had grown it to 2.8
million members with nobody noticing, because a sorted set that size behaves fine under
normal reads and writes. It only becomes a problem the moment something tries to move
the whole thing at once.
the find
MIGRATE is not a background operation. It serializes the entire value into
a DUMP payload on the source node and streams it to the destination, which
issues RESTORE and acknowledges. Redis processes commands on a single
thread, so for the entire time it takes to pack and transmit that payload, the node
can't service anything else. Not just the key being migrated. Every client on that
shard, including ones asking about session lookups or cart contents that have nothing
to do with trending products.
The math lines up with the stall almost exactly. Measured throughput on that intra-VPC link for a payload this size came out to roughly 53MB/s once serialization overhead is included. 341MB divided by 53MB/s is about 6.4 seconds, the same length as the pause in the reshard log and the same width as the latency spikes on the dashboard. The "21% CPU" that looked like proof the node was fine was an aggregate across eight cores. The one core running Redis's command loop was pegged the entire time; the other seven were idle and dragged the average down enough to hide it.
the fix
The immediate move was to abort the in-flight reshard and exclude the slot holding
trending:global:zscore until the key itself was dealt with. The real fix
was making sure no single key could ever grow large enough to block a migration again.
The counter was split by day, using a hash tag so the per-day keys still colocate on one shard for atomic scatter-gather reads, and each day's set gets trimmed to the members that actually matter for ranking.
def record_view(product_id: str) -> None:
day = datetime.utcnow().strftime("%Y%m%d")
key = f"trending:{day}:zscore"
redis.zincrby(key, 1, product_id)
redis.expire(key, 60 * 60 * 24 * 3) # 3-day rolling window
def top_trending(limit: int = 50) -> list[str]:
today = datetime.utcnow().strftime("%Y%m%d")
key = f"trending:{today}:zscore"
redis.zremrangebyrank(key, 0, -50_001) # cap growth, keep top 50k
return redis.zrevrange(key, 0, limit - 1)
Each day's key now self-limits to 50,000 members instead of growing without bound, and a 3-day TTL means stale days fall out of the keyspace on their own. The largest a single key can get is bounded by design, not by hoping nobody forgets to run a cleanup job.
Second, the reshard runbook got a mandatory gate before it's allowed to start.
#!/usr/bin/env bash
set -euo pipefail
MAX_BYTES=10000000 # 10MB per key
redis-cli --bigkeys -h "$1" 2>&1 | tee /tmp/bigkeys.out
if grep -qE "size in bytes is approximately ([0-9]{8,})" /tmp/bigkeys.out; then
echo "BLOCKED: a key over the size threshold exists. Resolve before resharding." >&2
exit 1
fi
echo "OK: no oversized keys found, safe to reshard."
Third, a scheduled key-size scan now runs independently of any migration, so an oversized key gets flagged on a Tuesday afternoon instead of during the next scale-up.
the aftermath
-
MIGRATEis not incremental for a single key. It serializes and ships the whole value in one blocking operation, no matter how small the--cluster-pipelinebatch size is set for the keys around it. - Aggregate CPU percentage across cores can hide single-thread saturation completely. A Redis node pegged on its one command-processing core can still report a bored-looking total.
- A key with no cap and no TTL that grows slowly under normal traffic for over a year can still become the exact thing that breaks an infrastructure change, because normal reads and writes never exercise the operation that moves the whole value at once.
- Resharding checklists need a key-size gate, not just a slot-count estimate. The slot distribution looked perfectly balanced; the byte distribution inside one slot didn't.
Nobody had written a bad line of code to get here. ZINCRBY on a global
counter is a reasonable way to build a trending rail, and it worked for fourteen months
without a single complaint. It just never had to move. The first time it did, it took
three shards down with it, two days before the one event this scale-up was supposed to
protect.