What is a Redis hot key, and why doesn't adding shards fix it?

A Redis hot key is a single key whose request rate or payload size concentrates CPU or network load on one instance. Two shapes are common. One is a small key read tens of thousands of times per second, such as a feature-flag blob or a homepage counter. The other is a large value, such as a multi-megabyte serialized list, that saturates a node's outbound bandwidth even at modest request rates. Redis executes commands on a single main thread, so one busy key competes for the same CPU as every other key on that node.

Adding shards does not help. In Redis Cluster, every key hashes to exactly one of 16,384 slots, and each slot is served by exactly one primary at a time. Resharding can move that slot to another node, but the slot moves whole, and the load moves with it. A standalone instance behind client-side partitioning behaves the same way, because each key still lands on one server. This makes a hot key a load distribution problem, not a capacity problem. The cluster can have plenty of spare capacity while one node is overloaded.

You can tell a hot key from general overload by these symptoms:

  • One node's CPU or network is saturated while its peers sit idle.
  • Latency spikes are limited to commands on specific keys.
  • Client timeouts cluster on one shard.

Hot keys also shift over time. A trending post or one large tenant can make a different key hot next week. Backend and platform engineers running Redis or Redis Cluster under skewed traffic should plan for this.

 +---------+  +---------+  +---------+
 |  app 1  |  |  app 2  |  |  app N  |   every client reads
 +---------+  +---------+  +---------+   the same key
      |            |            |
      +------------+------------+
      |  GET feature:flags (very high request rate)
      v
 +-----------------------------------------------+
 | HASH_SLOT = CRC16("feature:flags") mod 16384  |
 | -> one slot -> one primary at a time          |
 +-----------------------------------------------+
      |
      |  all of this key's traffic
      v
 +-------------+   +-------------+   +-------------+   +-------------+
 |   Node A    |   |   Node B    |   |   Node C    |   |   Node D    |
 | hot slot    |   |             |   |             |   | (new shard) |
 | CPU [#####] |   | CPU [#    ] |   | CPU [#    ] |   | CPU [     ] |
 | NET [#####] |   | NET [#    ] |   | NET [#    ] |   | NET [     ] |
 | timeouts    |   | idle        |   | idle        |   | idle        |
 +-------------+   +-------------+   +-------------+   +-------------+

 Resharding moves the slot whole: Node A ==(slot + load)==> Node D
 Adding Node D adds idle capacity; the hot key still hits one node.

Every request for one key hashes to the same slot and primary, so one node saturates while its peers, including a newly added shard, stay idle.

Who should use these hot key techniques?

These techniques are for engineers who operate Redis or Redis Cluster where traffic is not spread evenly across keys. That includes:

  • Backend engineers who own services that read shared keys on every request, such as feature flags, configuration blobs, rate-limit counters, leaderboards, or session data for very active users.
  • Platform and SRE teams who run shared Redis clusters for many services and get paged when one shard degrades while the others look healthy.
  • Teams running multi-tenant systems, where one large tenant can push its keys far above the average and turn them hot without warning.

Look for these signs that you need them: per-node CPU or network graphs that diverge sharply within one cluster, latency or timeout alerts that always name the same shard, or a scale-out that did not improve the incident it was meant to fix.

You probably do not need this work if a single instance handles your whole load with plenty of headroom, or if per-node metrics in your cluster stay close to each other under peak traffic. In those cases a hot key is not your bottleneck. Detection is still cheap to set up, though, and it tells you early when that changes.

How does hot key detection and mitigation work?

The approach has two halves. First you find which keys concentrate load. Then you change how requests reach those keys.

Detection works from one of three signals:

  • Redis's own LFU counter. When the eviction policy is LFU-based, Redis stores a small per-key access counter. The counter is probabilistic and logarithmic, and its growth and decay are controlled by lfu-log-factor and lfu-decay-time. redis-cli --hotkeys and OBJECT FREQ read this counter. They give you a ranking of recently busy keys, not exact request counts.
  • Key size. redis-cli --bigkeys and --memkeys scan for large values, which can saturate bandwidth even at moderate request rates.
  • Observed traffic. MONITOR shows every command for a short window. Counters in the client or a proxy record per-key request rates continuously.

Mitigation relies on the fact that a key is pinned to one slot and one node. Every technique in this article does one of three things:

  1. Reduces the requests that reach Redis. A local in-process cache answers repeated reads from application memory.
  2. Spreads requests across more nodes. Read replicas serve the same slot from additional servers. Key sharding replaces one key with several keys whose names hash to different slots, so different nodes own them.
  3. Reduces the cost of each request. Smaller values and selective reads move fewer bytes per command.

Which one fits depends on whether the key is read-heavy or write-heavy, how much staleness callers can tolerate, and whether the limit is request rate or payload size. The final section covers that choice.

How do you detect hot keys with redis-cli --hotkeys and OBJECT FREQ?

redis-cli --hotkeys does not observe live traffic. It reads the LFU access counter Redis already keeps for each key, so that counter has to exist first. Redis only maintains it when the eviction policy is LFU-based. Check the current setting with CONFIG GET maxmemory-policy. If it is not allkeys-lfu or volatile-lfu, the scan exits with an error.

Switching with CONFIG SET maxmemory-policy allkeys-lfu is not a diagnostic-only change. It changes which keys get evicted across the whole instance under memory pressure. Do it on purpose, and persist it in the config file only if you want to keep LFU.

With LFU active, run redis-cli --hotkeys. It walks the keyspace with SCAN and asks for each key's frequency, then prints the keys with the highest counters. Because it touches every key, it adds load to a server that may already be struggling. Run it against a replica where possible, or add -i 0.1 to make the scan pause periodically.

Read the output as a ranking, not as request counts. The counter is logarithmic: it climbs quickly at first, then more slowly, and caps at 255. It also decays over time (see lfu-decay-time). A key showing 255 is among the most accessed recently, but the number does not say how many requests it served.

To check one suspect key without a full scan, use OBJECT FREQ user:42:profile. It is cheap enough to poll during an incident, and you can compare its value against keys you know are normal.

HOST=10.0.0.12
PORT=6379

redis-cli -h "$HOST" -p "$PORT" CONFIG GET maxmemory-policy

redis-cli -h "$HOST" -p "$PORT" CONFIG SET maxmemory-policy allkeys-lfu

redis-cli -h "$HOST" -p "$PORT" --hotkeys -i 0.1

redis-cli -h "$HOST" -p "$PORT" OBJECT FREQ user:42:profile

redis-cli -h "$HOST" -p "$PORT" OBJECT FREQ user:1001:profile

redis-cli -h "$HOST" -p "$PORT" CONFIG GET lfu-decay-time

Checks and sets an LFU eviction policy, runs a throttled redis-cli --hotkeys scan, and uses OBJECT FREQ to compare a suspect key's LFU counter against a normal key.

What are the alternatives to redis-cli hotkeys for finding hot keys?

--hotkeys scans the whole keyspace and depends on an LFU eviction policy, so it is not always available or affordable. The other options trade accuracy against runtime cost in different ways.

Method What it finds Runtime cost Accuracy
MONITOR Every command, in real time High: can sharply reduce throughput Exact, but only for the capture window
redis-cli --bigkeys / --memkeys Largest keys by element count or memory Moderate: full SCAN Size only, not access frequency
Client or proxy counting Per-key request counts Low, spread across clients Highest: sees real traffic

MONITOR streams every command the server executes to your connection. That stream competes with real work, so use it only in short, bounded sessions during low-traffic windows. Its output includes key names and argument values, which can expose sensitive data, so treat captures as confidential.

--bigkeys and --memkeys answer a different question. A large value read at moderate frequency can still saturate network bandwidth or a single CPU core. These tools find that kind of hot spot, but they cannot tell you how often a key is read.

Client-side or proxy-side counting is usually the most accurate option and the cheapest at runtime. The application already knows which key each request touches. Here is a minimal sketch:

  1. Wrap the client's command methods.
  2. Increment an in-process counter for each key name, optionally sampling one request in N.
  3. Every few seconds, export only the top entries and reset the counter.

Exporting only the top entries keeps metric cardinality bounded.

Run this instrumentation continuously, because hot keys shift with traffic, releases, and time of day. A one-off scan only shows the key that was hot at that moment. The cost is a small amount of CPU and memory on every client, plus the work of maintaining the wrapper across languages and services.

import random, threading, time
from collections import Counter
import redis

SAMPLE_RATE = 10      # count roughly 1 in 10 requests to keep overhead low
TOP_N = 20            # export only the top keys to bound metric cardinality
EXPORT_INTERVAL = 10  # seconds between exports

class CountingRedis(redis.Redis):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self._counts = Counter()
        self._lock = threading.Lock()
        threading.Thread(target=self._export_loop, daemon=True).start()

    def execute_command(self, *args, **options):
        # For single-key commands (GET, SET, HGET...) the key is args[1].
        # Multi-key commands and pipelines need extra handling in real code.
        if len(args) > 1 and random.randrange(SAMPLE_RATE) == 0:
            with self._lock:
                self._counts[args[1]] += 1
        return super().execute_command(*args, **options)

    def _export_loop(self):
        while True:
            time.sleep(EXPORT_INTERVAL)
            with self._lock:
                top = self._counts.most_common(TOP_N)
                self._counts.clear()  # reset so each window reflects current traffic
            for key, n in top:
                # Swap print for your metrics client; scale by SAMPLE_RATE
                # to estimate the true request count for the window.
                print(f"hotkey key={key!r} est_requests={n * SAMPLE_RATE}")

r = CountingRedis(host="redis.internal.example", port=6379)
r.get("user:1001:profile")

A redis-py client subclass that samples single-key commands, counts requests per key in process, and periodically exports and resets the top entries so hot-key detection can run continuously at low cost.

How does a local cache absorb read-heavy hot keys?

A hot key's traffic usually comes from many application instances asking for the same value many times per second. An in-process cache in each instance turns those requests into roughly one Redis read per instance per TTL window. With 50 instances and a 1-second TTL, Redis sees at most about 50 reads per second for that key, no matter how much traffic the application receives (illustrative estimate).

Choose the TTL based on how stale a value your callers can accept, not on the hit rate you want. A feature flag can tolerate seconds. An account balance usually cannot be cached locally at all. Keep the cache bounded by entry count or bytes, with LRU or LFU eviction, so that a burst of distinct keys cannot exhaust the heap.

For invalidation you have three options:

  • TTL only. This is the simplest option, and staleness is capped at the TTL.
  • Pub/sub messages published on every write. Pub/sub delivery is at-most-once, so a client that disconnects misses messages. Flush the whole local cache on every reconnect.
  • Client-side caching with CLIENT TRACKING, where your client library supports it. Redis sends the invalidations for you, but the same reconnect rule applies.

The costs:

  • Every instance can serve stale data independently of the others.
  • Memory use is multiplied by the number of instances.
  • The hot key disappears from Redis-side monitoring, so emit local hit and miss metrics per key.

Finally, add single-flight loading. When an entry expires, all concurrent misses for that key should wait on one shared backend fetch rather than each issuing its own. Otherwise every expiry causes a thundering herd against Redis.

import threading
import time
from collections import OrderedDict
from concurrent.futures import Future

class LocalCache:
    def __init__(self, loader, ttl_seconds=1.0, max_entries=10_000):
        self._loader = loader            # e.g. lambda k: redis_client.get(k)
        self._ttl = ttl_seconds          # pick from tolerable staleness, not desired hit rate
        self._max = max_entries          # hard bound so a burst of distinct keys can't exhaust the heap
        self._data = OrderedDict()       # key -> (expires_at, value), oldest first (LRU order)
        self._inflight = {}              # key -> Future shared by all concurrent misses
        self._lock = threading.Lock()

    def get(self, key):
        with self._lock:
            entry = self._data.get(key)
            if entry and entry[0] > time.monotonic():
                self._data.move_to_end(key)          # mark as recently used
                return entry[1]
            leader = key not in self._inflight       # first miss becomes the single fetcher
            fut = self._inflight.setdefault(key, Future())
        if not leader:
            return fut.result()                      # wait on the one backend fetch
        try:
            value = self._loader(key)
            with self._lock:
                self._data[key] = (time.monotonic() + self._ttl, value)
                self._data.move_to_end(key)
                while len(self._data) > self._max:
                    self._data.popitem(last=False)   # evict least recently used
            fut.set_result(value)
            return value
        except BaseException as exc:
            fut.set_exception(exc)                   # waiters get the same error; next call retries
            raise
        finally:
            with self._lock:
                self._inflight.pop(key, None)

    def clear(self):  # call on Redis reconnect: invalidation messages may have been lost
        with self._lock:
            self._data.clear()

A thread-safe, size-bounded LRU cache with per-entry TTL in which concurrent misses for the same key share one loader call, so an expiry triggers a single Redis read per instance instead of a thundering herd.

How do you implement key sharding for read-heavy and write-heavy keys?

Read-heavy keys: replicate the value

Store N identical copies under suffixed names, product:42:0 through product:42:N-1. Each reader picks a suffix at random, so read traffic spreads across every node that owns one of those slots. Writers must update all N copies. Do not wrap the base name in a hash tag such as {product:42}:3. A hash tag tells the cluster to hash only the braced part, which puts every copy in the same slot and therefore on the same node. Suffixes alone do not guarantee an even spread either. Run CLUSTER KEYSLOT on each name to see where it lands, and pick N as at least the node count, ideally a small multiple of it.

The cost is that writes and memory both grow by a factor of N. The copies also live in different slots, so a single MULTI cannot update them atomically. While an update is in flight, readers can see different versions, so use this only where brief staleness is acceptable.

Write-heavy counters: split the value

Split one counter into N sub-counters. Each writer runs INCRBY on a randomly chosen shard, and readers sum all N. Size N so that the peak write rate divided by N stays well under what a single node handles comfortably, and keep N at least equal to the node count. Reads become more expensive. In cluster mode, MGET rejects keys that span slots, so the client has to fan out by node. Any atomic read-modify-write on the total, such as "decrement only if the sum is above zero," needs extra coordination like a lock or a single authoritative key.

When should you use read replicas or shrink big values instead?

If the hot key is read-heavy and you can tolerate slightly stale data, routing its reads to replicas spreads the load across more nodes without changing the key layout. Replication in Redis is asynchronous, so a read sent to a replica right after a write can return the old value, or nothing at all if the key was just created. That makes replica reads a poor fit for read-your-own-write flows such as "update profile, then render profile." It also requires client support. In Redis Cluster, a connection must issue READONLY before a replica will serve reads for its slots, and the client has to choose which node to send each read to. Replicas do not remove the hotspot. With one primary and two replicas, a key that saturated the primary can still saturate each replica once traffic grows.

Big-value hot keys often fail on network bandwidth before they run out of CPU. A 500 KB value read 2,000 times per second moves about 1 GB/s (illustrative estimate), which saturates a 10 Gbit link. You can reduce the bytes per request in three ways:

  • Split the value. Break a large JSON blob into separate keys or hash fields so each request reads only a small piece.
  • Compress on the client. This trades CPU in your application for fewer bytes on the network. It only helps when the data compresses well.
  • Fetch selectively. Request only the fields or ranges you need.
Instead of Use Effect
HGETALL HGET / HMGET Returns only the named fields
GET on a large string GETRANGE Returns only a byte range
LRANGE key 0 -1 LRANGE key 0 19 Returns only the first 20 elements

How can hot key detection and mitigation save engineering time?

Most of the time saved comes from not chasing the wrong fix and from shorter incidents.

  • No misdirected scaling work. Without a hot key diagnosis, the usual reaction to one overloaded node is to add shards, reshard, or move to larger instances. As shown above, none of that moves the load off the node that owns the key. Planning and running those changes and then watching them fail takes engineering effort. A quick OBJECT FREQ check or a look at client-side top-key metrics rules this out early.
  • Faster incident triage. When per-key request counts are already exported from clients, the on-call engineer can see which key is hot and which service sends the traffic from a dashboard. They do not need to run MONITOR or a full keyspace scan against a node that is already struggling, and they do not need to reconstruct the cause from logs afterwards.
  • Targeted fixes instead of redesigns. Once you know the specific key and whether it is read-heavy, write-heavy, or just large, the fix is usually local: a TTL cache around one lookup, a sharded counter, or a HGETALL replaced with HMGET. That is a change to one code path, not a change to data architecture.
  • Fewer repeat incidents. Hot keys move as traffic patterns, tenants, and releases change. Continuous detection catches a new hot key while it is climbing, so the team can fix it during normal work instead of during a page.

To estimate the benefit for your own team, count how many Redis incidents in a recent period traced back to a single key or shard, and how many engineer-hours each one took, from first alert through any scaling work that did not help. Those figures come from your own incident history. This article does not supply them.

Which hot key mitigation should you choose, and when should you avoid each?

Start with three questions about the hot key:

  1. Is it read-heavy or write-heavy? Local caches and replicas only absorb reads. A key that takes a large share of writes needs key sharding.
  2. How much staleness can callers tolerate? A local cache with a short TTL serves data that can be up to one TTL old. Replicas lag the primary under asynchronous replication. Sharded writes need a merge step on read.
  3. Is the bottleneck request rate or payload size? If one key returns large values, the node's network and serialization saturate before its command rate does. Shrinking the value fixes this directly.
Mitigation Development cost Operational cost Consistency impact Avoid when
Local cache Low: wrap reads with a TTL map Memory on every app instance Stale for up to one TTL per instance Values must be read-your-writes, or the key is write-heavy
Read replicas Low: route reads to replicas More nodes to run and monitor Replication lag Writes are the hot path
Key sharding Medium to high: fan-out writes, merge reads More keys to expire and reason about Reads aggregate N keys Reads must be atomic across the whole value
Value shrinking Medium: change data shape or access commands None None The bottleneck is request rate, not bytes

Apply the cheapest mitigation that answers all three questions, then confirm the result with ongoing hot key detection. A single key always lives in one slot, and a slot lives on one node. Resharding the cluster or buying larger machines leaves that node carrying the same load, while a TTL map in the client often removes most of it in an afternoon.

How OneEnv can help

Disclosure: OneEnv is the product behind this blog.

OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.

Applied to redis hot keys, this is where it helps:

Saves time

  • Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
  • One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
  • Changing a shared variable is one edit and one review instead of editing every service's .env by hand.

Saves cost

  • Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
  • Less duplicated configuration to maintain: one shared definition inherited by many services.
  • A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
  • OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.

These are qualitative benefits, not measured results — how much you save depends on how many services share the value.

Want to try it? Start at oneenv.io.