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
- Lazy deletion. Any command that looks a key up checks its expiry first. An expired key is deleted right there, and the command answers as if the key never existed. This mechanism only ever removes the keys a command actually touches.
-
The active cycle. On each
serverCrontick, Redis draws a random sample from the set of keys that carry an expiry, deletes the ones that are past due, and repeats if the expired fraction of the sample is still high. No client has to be involved.
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
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:
| Command | Returns | DBSIZE after | expired_keys after | Keys removed |
|---|---|---|---|---|
DBSIZE | 301 | 301 | 0 | 0 |
KEYS * | 1 | 301 | 0 | 0 |
SCAN 0 COUNT 10 | 1 | 291 | 10 | 10 |
SCAN 0 COUNT 1000 | 1 | 1 | 300 | 300 |
GET k2 | (nil) | 300 | 1 | 1 |
MGET k5 k6 k7 | 3 × (nil) | 298 | 3 | 3 |
DEL k10 | 0 | 300 | 1 | 1 |
SET k9 new | OK | 301 | 1 | 1 |
RANDOMKEY | alive | 228 | 73 | 73 |
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.
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 expiry | DBSIZE | expired_keys |
|---|---|---|
| 0 ms | 366 | 19,634 |
| 100 ms | 42 | 19,958 |
| 200 ms | 0 | 20,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:
| Command | TTL afterwards | Which means |
|---|---|---|
SET k 2 | -1 | recreates the key, expiry gone |
SET k 2 KEEPTTL | 100 | keeps the old expiry |
SET k 2 EX 50 | 50 | sets a new expiry |
APPEND k x | 100 | edits the contents in place |
INCR k | 100 | edits the contents in place |
SETRANGE k 0 9 | 100 | edits the contents in place |
GETSET k 9 | -1 | equivalent to SET, expiry gone |
GETDEL k | -2 | the key is gone |
GETEX k | 100 | with no options it changes nothing |
GETEX k PERSIST | -1 | drops the expiry |
PERSIST k | -1 | drops the expiry |
COPY k k2 | 100 | the copy carries the expiry |
RENAME k k2 | 100 | the expiry follows the key |
RENAME k2 k (k2 has no expiry) | -1 | overwriting a key with an expiry drops it |
RPUSH, HSET, SADD on a key with an expiry | 100 | edits the contents in place |
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.