Lesson 2

Who removes an expired key

A key that expires at 10:00:00 does not leave memory at 10:00:00. It leaves when something touches it, or when the background cycle happens to draw it into a sample. In between, the key still occupies memory and is still counted by DBSIZE, even though every read behaves as if it were gone.

Two deletion mechanisms

Both increment expired_keys in INFO stats, so that counter is a combined total and cannot tell you which mechanism did the work. The way to separate them is to switch the active cycle off entirely.

Switching off the cycle to separate them

DEBUG SET-ACTIVE-EXPIRE 0 disables the cycle. Since Redis 7, DEBUG is locked down by default, so the first attempt returns:

ERR DEBUG command not allowed. If the enable-debug-command option is set
to "local", you can run it from a local connection, otherwise you need to
set this option in the configuration file, and then restart the server.

This is the easiest place in the whole lesson to measure the wrong thing. The DEBUG command fails but the rig carries on, so the first table looks as though the cycle had been switched off when it was still running — and every number in that table is meaningless. The container has to be restarted with the option:

docker run -d --name rdlab -p 8088:6379 redis:7-alpine \
  redis-server --enable-debug-command local

docker exec rdlab redis-cli DEBUG SET-ACTIVE-EXPIRE 0
# OK

With the cycle off, build 300 keys with a 200 ms TTL in a single EVAL — so they all expire at the same instant — plus one key with no expiry, then wait a second:

DBSIZE        → 301
KEYS *        → 1 key
expired_keys  → 0
These three numbers do not contradict each other DBSIZE counts entries in the key table without checking any expiry, so it says 301. KEYS checks expiry while filtering its result, so it says 1. And expired_keys is 0 because no key has been deleted yet. Three commands, three different answers, all of them correct.

Nine commands, each measured alone

Every row below is a fresh rebuild of the same state — 300 keys that expired 800 ms ago plus one live key, active cycle off — followed by exactly one command:

CommandReturnsDBSIZE afterexpired_keys afterKeys removed
DBSIZE30130100
KEYS *130100
SCAN 0 COUNT 1012911010
SCAN 0 COUNT 100011300300
GET k2(nil)30011
MGET k5 k6 k73 × (nil)29833
DEL k10030011
SET k9 newOK30111
RANDOMKEYalive2287373
KEYS filters without deleting, SCAN deletes Both commands return one key, but KEYS * leaves all 300 dead keys in place while SCAN 0 COUNT 1000 clears every one of them. And SCAN 0 COUNT 10 removes exactly 10 — the number of keys it walked past. If you use SCAN to clean up a keyspace, most of the cleaning comes from the scan itself, not from the DEL you issue afterwards.
Two more rows worth reading closely DEL k10 returns 0, not 1 — as far as Redis is concerned that key no longer exists, even though it is still in the table and is removed by this very command. And RANDOMKEY removed 73 keys in one command, because it draws a key at random and keeps drawing until it finds a live one; the number 73 is random and will differ on every run.

How fast the active cycle really is

Switch the cycle back on, build 20,000 keys with a 400 ms TTL in one EVAL, then read DBSIZE every 100 ms from the moment they expire, with no client touching a key:

Time after expiryDBSIZEexpired_keys
0 ms36619,634
100 ms4219,958
200 ms020,000

20,000 keys that expire at the same instant are cleared within about 200 ms, with nobody reading them. The first row is also instructive: DBSIZE read 366 while expired_keys had already reached 19,634 — the two commands ran a few milliseconds apart, and in that gap the cycle had removed nearly everything. The cycle is not slow; what makes people think it is slow is a moment like that 0 ms row.

Because the cycle samples at random, how long cleanup takes depends on the fraction of expired keys among the keys that carry an expiry. When that fraction is tiny — a handful of expired keys among a million with long TTLs — a single key can sit in memory for a long time, and DBSIZE will keep counting it.

The lab

Set the number of live keys and expired keys, then pick a command. The active cycle is treated as switched off, so only the command you run can remove a key. The algorithm was checked against the eight measured rows above; RANDOMKEY is not in the lab because it is random.

Keyspace state

The single command you run

The measured commands:

After the command

A TTL belongs to the key, not to the value

The second common question about expiry is which commands lose the TTL. The table below is measured by running SET k 1 EX 100, then exactly one command, then reading TTL k:

CommandTTL afterwardsWhich means
SET k 2-1recreates the key, expiry gone
SET k 2 KEEPTTL100keeps the old expiry
SET k 2 EX 5050sets a new expiry
APPEND k x100edits the contents in place
INCR k100edits the contents in place
SETRANGE k 0 9100edits the contents in place
GETSET k 9-1equivalent to SET, expiry gone
GETDEL k-2the key is gone
GETEX k100with no options it changes nothing
GETEX k PERSIST-1drops the expiry
PERSIST k-1drops the expiry
COPY k k2100the copy carries the expiry
RENAME k k2100the expiry follows the key
RENAME k2 k (k2 has no expiry)-1overwriting a key with an expiry drops it
RPUSH, HSET, SADD on a key with an expiry100edits the contents in place
One sentence for the whole table A command that replaces the key loses the expiry; a command that edits what is inside the key keeps it. SET is in the first group, which is why KEEPTTL exists. The clause people miss is RENAME: the expiry travels with the source key, and if the destination had an expiry it is overwritten along with the data.

The TTL left after the command

The key starts out as SET k 1 EX 100.

Check yourself

A dashboard shows DBSIZE = 5 million but KEYS pattern:* returns almost nothing. What is going on? Most of those keys have expired but have not been deleted. DBSIZE counts them; KEYS filters them out. To find the real number, read expired_keys from INFO stats and compare it against the write rate, or run SCAN over the whole keyspace — the scan itself will clear them.
Why does DEL on an expired key return 0? Because before deleting, DEL looks the key up and finds it past due, so as far as Redis is concerned it does not exist. The 0 is the number of existing keys the command deleted. The key is still swept out of the table by that same command, and expired_keys goes up by one.
You extend a session by rewriting its value with SET. What is wrong with that? SET without EX or KEEPTTL drops the expiry, and the session becomes permanent. Use SET … EX <seconds> if you mean to extend it, or SET … KEEPTTL if you only mean to change the value and keep the existing expiry.
A million keys carry a 30-day TTL and ten of them have just expired. How long until they leave memory? Nothing in this lesson's measurements can tell you. The cycle samples at random from the keys that carry an expiry, so with an expired fraction that low, the chance of any one tick catching one of those ten is small. The 200 ms figure for 20,000 keys above corresponds to nearly the entire expiring set being past due — the opposite extreme.