Back to Devoured - September 17, 2026
DEVOURED
Why your Redis is slow: it is single threaded and you sent it KEYS

Why your Redis is slow: it is single threaded and you sent it KEYS

Data Ankit Rana
Redis's single-threaded nature means one O(N) command like KEYS can block all traffic, turning a 30-microsecond request into a 900-millisecond stall.
What: Expensive commands (KEYS, SMEMBERS, HGETALL, DEL) block the entire Redis event loop. Latency spikes occur because the server processes only one command at a time, causing head-of-line blocking for all other clients.
Why it matters: This reveals a common misconception that Redis is non-blocking due to its speed; in reality, its atomicity guarantee is exactly what makes it vulnerable to long-running, non-indexed operations.
Takeaway: Audit your production Redis commands immediately: disable dangerous commands like KEYS and FLUSHALL via `rename-command`, use UNLINK instead of DEL for large keys, and replace SCAN-heavy logic with explicit indexing.
Deep dive
  • Event Loop Blocking: Every Redis command executes synchronously on a single thread; large operations (e.g., deleting a set of 2M items) pause all other client requests.
  • The KEYS Trap: Never use KEYS in production. It scans the entire keyspace, blocking the engine for the duration of the scan.
  • Collection Hazards: Commands like SMEMBERS, HGETALL, and LRANGE key 0 -1 scale linearly with collection size; monitor key growth to avoid future latency degradation.
  • UNLINK vs DEL: Use UNLINK for large keys, which reclaims memory asynchronously in the background without blocking the main event loop.
  • Monitoring: Set slowlog-log-slower-than to 1000 microseconds to catch non-obvious performance offenders.
Decoder
  • Head-of-Line Blocking: A phenomenon where a series of packets or operations is held up by the first one in line, in this case, a blocking Redis command.
Original article

Redis p99 goes from 0.4ms to 900ms. Every operation is affected, including plain GET calls against small string values. CPU on the Redis host is at 30 percent, memory is fine, the network is not saturated, and INFO shows no evictions.

Then it clears, and forty seconds later it happens again.

Somebody added an admin endpoint that lists cache keys matching a pattern. It runs every minute. It calls KEYS.

One thread, one command at a time

Redis executes commands on a single thread. That is a design decision rather than an oversight, and it is why every Redis operation is atomic without any locking: nothing else can be running while your command runs.

The consequence is head-of-line blocking with no escape. A command that takes 200 milliseconds does not slow down your request. It makes every other client wait 200 milliseconds, including clients whose request would have taken 30 microseconds.

Client B asked for one small string. It waited 340 milliseconds because of a command it has no relationship to. From B’s perspective Redis was slow, and every metric B has will say so.

Redis 6 added threaded I/O, and it is worth knowing exactly what that changed. Reading from and writing to sockets can now happen on multiple threads, which helps when a large number of connections saturates the network handling. Command execution is still single threaded, deliberately, because that is what keeps operations atomic. Threaded I/O does nothing for this problem.

The commands that are linear and do not look it

KEYS is the famous one and it is not the only one. Anything whose cost scales with the size of the data it touches will block for proportionally long.

Command Cost Why it surprises people
KEYS pattern whole keyspace the pattern does not narrow the scan
SMEMBERS key size of the set fine at 100 members, not at 2 million
HGETALL key size of the hash same shape, same trap
LRANGE key 0 -1 length of the list the 0 -1 is the problem
DEL key elements in the collection deleting feels like it should be free
FLUSHALL whole keyspace blocks until every key is freed
SORT size plus sort cost rarely used, always expensive

The DEL row is the one that catches people, because deletion intuitively feels cheap. Removing a set with two million members means freeing two million objects, and that happens synchronously on the same thread everything else is waiting on. UNLINK exists precisely for this: it removes the key from the keyspace immediately and reclaims the memory in a background thread, so the blocking portion is constant.

LRANGE key 0 -1 deserves a mention because it appears in code that looks careful. Somebody wrote a helper that fetches a whole list, the list had 40 elements in development, and it now has 900,000 in production.

Finding the offender

SLOWLOG is where to look and it is the first thing I check when Redis latency moves without a corresponding change in traffic.

redis-cli SLOWLOG GET 10

It records commands exceeding slowlog-log-slower-than, which defaults to 10,000 microseconds. On a server where normal commands take tens of microseconds, 10ms is an enormous threshold and will only catch the extremes. Lowering it to 1,000 makes the log genuinely useful.

LATENCY DOCTOR and LATENCY HISTORY cover a broader set of causes, including fork pauses during persistence, which is the other common source of multi-hundred-millisecond stalls that look like nothing in the application logs.

The signature worth memorising: every operation slows at once, CPU is not saturated, and the slowdown clears without intervention. That combination almost always means one command is holding the thread. If instead one particular key or operation is slow and others are fine, the problem is elsewhere.

SCAN and the cursor contract

SCAN replaces KEYS and the difference is that it returns after a bounded amount of work.

def scan_keys(redis, pattern, count=500):
    cursor = 0
    while True:
        cursor, batch = redis.scan(cursor=cursor, match=pattern, count=count)
        for key in batch:
            yield key
        if cursor == 0:
            break

Each call examines roughly count slots and yields, so other clients get served between iterations. The whole scan takes longer in wall clock terms and blocks nobody.

Two properties of the guarantee are worth knowing before relying on it. Keys present for the entire scan are returned at least once, so you may see duplicates and must handle them. Keys added or removed during the scan may or may not appear, so SCAN gives you a fuzzy snapshot rather than an exact one. For finding keys to expire or clean up that is fine. For anything requiring exactness it is the wrong tool, and the right answer is usually to maintain an explicit index in a set rather than scanning the keyspace at all.

The same cursor pattern exists for collections: HSCAN, SSCAN and ZSCAN do for large hashes, sets and sorted sets what SCAN does for the keyspace.

Big keys are a latency problem before they are a memory problem

A single key holding a large collection is a latency risk on every operation that touches it, including its eventual deletion and its serialisation during persistence.

redis-cli --bigkeys
redis-cli --memkeys

--bigkeys samples the keyspace and reports the largest key per type. Running it during a quiet period is one of the higher value ten second investigations available, because a single 4 million element set is a stall waiting for whichever unlucky request touches it.

The structural fix is sharding the key. A set of 4 million members becomes 64 sets of 60,000 members addressed by a hash of the member, and every operation touches one shard. That also spreads the key across a cluster rather than pinning it to one node, which no amount of ring based key distribution will do for you, because the hash of a given key is fixed.

What I would put in place

Set slowlog-log-slower-than to 1,000 microseconds so the log records things that matter on a server where normal is measured in tens of microseconds.

Disable the dangerous commands outright in production rather than relying on nobody calling them:

rename-command KEYS ""
rename-command FLUSHALL ""
rename-command FLUSHDB ""

This is the rare case where I favour removing a capability over documenting it, because the cost of one accidental KEYS against a large keyspace is an outage and the benefit of having it available in production is close to zero.

Run --bigkeys on a schedule and alert when the largest key crosses whatever threshold your latency budget tolerates.

And treat the documented complexity of every command as a latency budget rather than trivia. On a single threaded server, O(N) is not a note about that command. It is a statement about what every other client will experience while it runs.

Explore all 60 articles in today's digest

Fresh dev news, AI-digested daily. Never miss a trend at devoured.fyi