Redis · lesson 12 of 14
Beginner
4Intermediate
7- 5 MULTI/EXEC Transactions and the WATCH-Based Optimistic Lock
- 6 Pub/Sub: Channels, Patterns, and the Message That Never Arrives
- 7 RDB vs AOF: What Survives a Hard Kill and What Doesn't
- 8 Replication: A Read-Only Replica, and What WAIT Actually Guarantees
- 9 ACL: a User Restricted to One Key Pattern and One Command
- 12 maxmemory and Eviction: What Redis Throws Away When It's Full
- 13 Cache-Aside with Redis: Stale Reads, Races and Stampedes
Advanced
3Also part of Redis
3maxmemory and Eviction: What Redis Throws Away When It's Full
Cap Redis at 3 MB and fill it: watch noeviction refuse writes, volatile-lru fail with no TTLs, and LRU, LFU and LRM pick different keys to throw away.
By SkillByExample · Published
Redis keeps everything in memory, so something has to give when memory runs out. maxmemory sets
the limit, and maxmemory-policy decides what happens at it: refuse the write, or delete keys to
make room.
Setup
One Redis container, pinned to an exact version so the numbers below are reproducible:
# setup.sh - a fresh Redis for this lesson, pinned to one exact version
docker rm -f redis-lesson > /dev/null 2>&1
docker run -d --name redis-lesson -p 6379:6379 redis:8.10.2-alpine > /dev/null
sleep 1
docker exec redis-lesson redis-cli INFO server | grep redis_version
docker exec redis-lesson redis-cli CONFIG GET maxmemory
docker exec redis-lesson redis-cli CONFIG GET maxmemory-policy
docker exec redis-lesson redis-cli INFO memory | grep used_memory_human
$ bash setup.sh
redis_version:8.10.2
maxmemory
0
maxmemory-policy
noeviction
used_memory_human:1.35M
maxmemory 0 means no limit on a 64-bit build: Redis grows until the operating system or the
container’s memory limit stops it. The policy is noeviction. Every example below sets a 3 MB
limit so it fills up in a second. The empty server already uses 1.35 MB (used_memory_human),
which leaves about 1.65 MB for keys.
noeviction: writes fail, reads keep working
# noeviction.sh - fill a Redis capped at 3 MB that is not allowed to evict anything
r() { docker exec -i redis-lesson redis-cli "$@"; }
r FLUSHALL > /dev/null
r CONFIG SET maxmemory 3mb
r CONFIG SET maxmemory-policy noeviction
r SET greeting hello
# 20,000 SETs of a 64-byte value, sent over one connection; count each kind of reply
seq 1 20000 \
| sed 's/.*/SET key:& 0123456789012345678901234567890123456789012345678901234567890123/' \
| r | grep -v '^$' | sort | uniq -c
r DBSIZE
r GET greeting
r SET one-more value
r INFO stats | grep evicted_keys
$ bash noeviction.sh
OK
OK
OK
13128 OK
6872 OOM command not allowed when used memory > 'maxmemory'.
13129
hello
OOM command not allowed when used memory > 'maxmemory'.
evicted_keys:0
About 13,100 writes succeeded and the rest came back as OOM command not allowed (13,118 to
13,128 across runs from a fresh container). Nothing was evicted: the
database holds every key that made it in, plus greeting. Reads still work, so GET greeting
returns hello, but every later write fails until something is deleted or the limit is raised.
noeviction is the right policy when Redis is the only copy of the data, such as a queue or a
session store you can’t rebuild. For a cache it’s the wrong default, because the application
sees errors instead of a slightly smaller cache.
The volatile-lru trap
volatile-lru sounds like the cache policy. It only evicts keys that have a TTL, though:
# volatile_trap.sh - volatile-lru only evicts keys that have a TTL
r() { docker exec -i redis-lesson redis-cli "$@"; }
r FLUSHALL > /dev/null
r CONFIG SET maxmemory 3mb
r CONFIG SET maxmemory-policy volatile-lru
r CONFIG RESETSTAT > /dev/null
echo "--- 20,000 keys with no TTL"
seq 1 20000 \
| sed 's/.*/SET key:& 0123456789012345678901234567890123456789012345678901234567890123/' \
| r | grep -v '^$' | sort | uniq -c
r INFO stats | grep evicted_keys
echo "--- the same 20,000 keys, each with a 1-hour TTL"
r FLUSHALL > /dev/null
seq 1 20000 \
| sed 's/.*/SET key:& 0123456789012345678901234567890123456789012345678901234567890123 EX 3600/' \
| r | grep -v '^$' | sort | uniq -c
r INFO stats | grep evicted_keys
r DBSIZE
$ bash volatile_trap.sh
OK
OK
--- 20,000 keys with no TTL
13740 OK
6260 OOM command not allowed when used memory > 'maxmemory'.
evicted_keys:0
--- the same 20,000 keys, each with a 1-hour TTL
20000 OK
evicted_keys:9505
10495
With no TTLs, volatile-lru has no candidates, so it fails exactly like noeviction: 6,260 OOM
errors and 0 evictions. (About 600 more keys fit here than in the noeviction run, in every run
of this script. That isn’t the policy: in a separate test, two noeviction fills in a row on one
server fitted 13,119 and then 12,912 keys, so how many keys fit depends on what the server held
before.) Add EX 3600 to the same writes and all 20,000 succeed, with about 9,500 keys (9,490
to 9,525) evicted to make room. Fewer keys fit (10,500) because each key with a TTL also takes an
entry in the expiry table. 10,495 kept plus 9,505 evicted is exactly 20,000. The four volatile-* policies (volatile-lru, volatile-lfu,
volatile-random, volatile-ttl) all have this rule. If your application forgets the TTL, they
give no protection.
allkeys-lru: the cache policy
allkeys-lru can evict any key, so the same 20,000 writes all succeed:
# allkeys_lru.sh - the same 20,000 writes with allkeys-lru, then read every key back
r() { docker exec -i redis-lesson redis-cli "$@"; }
counts() { printf 'DBSIZE\nINFO stats\n' | r | tr -d '\r' | grep -E '^[0-9]+$|^evicted_keys:' | paste -sd' '; }
r FLUSHALL > /dev/null
r CONFIG SET maxmemory 3mb
r CONFIG SET maxmemory-policy allkeys-lru
r CONFIG RESETSTAT > /dev/null
seq 1 20000 \
| sed 's/.*/SET key:& 0123456789012345678901234567890123456789012345678901234567890123/' \
| r | grep -v '^$' | sort | uniq -c
echo "keys, evicted right after the writes: $(counts)"
sleep 1
echo "keys, evicted 1 s later: $(counts)"
r INFO memory | grep -E '^(used_memory_human|maxmemory_human):'
# read all 20,000 keys back; INFO counts every hit and miss
seq 1 20000 | sed 's/.*/GET key:&/' | r > /dev/null
echo "keys, evicted after reading them all: $(counts)"
r INFO stats | grep -E '^(keyspace_hits|keyspace_misses):'
$ bash allkeys_lru.sh
OK
OK
20000 OK
keys, evicted right after the writes: 13322 evicted_keys:6870
keys, evicted 1 s later: 12897 evicted_keys:7103
used_memory_human:3.00M
maxmemory_human:3.00M
keys, evicted after reading them all: 12687 evicted_keys:7313
keyspace_hits:12687
keyspace_misses:7313
All 20,000 writes succeeded with no errors, and Redis settled at the 3 MB limit. That’s the cache behaviour you want: older entries gone instead of failed writes.
Look at the first two count lines. Right after the writes, 13,322 keys plus 6,870 evicted is
20,192, more than were ever written, and a second later the keys had dropped to 12,897 and the
two numbers add up to 20,000. Eviction was still running between the reads: Redis evicts in
small, time-limited steps and finishes in the background, so for a moment it sits above
maxmemory and the counters move between commands. Read them after it settles, as the second
line does.
The third line is the surprise. Reading keys evicted another 210 of them (7,103 → 7,313).
Memory for client reply buffers counts towards maxmemory, so a client reading 20,000 replies
pushed Redis over the limit again, and it evicted to get back under. A Redis that runs at its
limit evicts on reads too, so leave headroom for client buffers.
keyspace_hits and keyspace_misses give the cache’s hit ratio: 12,687 / 20,000 ≈ 63% here.
Watch that ratio in production. If it drops after you shrink maxmemory, the cache is too small
for the working set.
LRU is approximated: maxmemory-samples
Redis doesn’t keep a sorted list of every key’s last access, which would cost memory on every
read. It samples maxmemory-samples random keys and evicts the least recently used of them. This
script reads half the keys, overfills, and counts which half survived:
# samples.sh - Redis's LRU is approximated: how many recently read keys survive?
r() { docker exec -i redis-lesson redis-cli "$@"; }
VALUE=0123456789012345678901234567890123456789012345678901234567890123
run() {
r FLUSHALL > /dev/null
r CONFIG SET maxmemory-policy allkeys-lru > /dev/null
r CONFIG SET maxmemory-samples "$1" > /dev/null
seq 1 12000 | sed "s/.*/SET key:& $VALUE/" | r > /dev/null # fits under 3 MB
sleep 2
seq 1 6000 | sed 's/.*/GET key:&/' | r > /dev/null # read the first half
sleep 2
seq 12001 18000 | sed "s/.*/SET key:& $VALUE/" | r > /dev/null # overfill: forces evictions
read_kept=$(seq 1 6000 | sed 's/.*/EXISTS key:&/' | r | grep -c '^1$')
unread_kept=$(seq 6001 12000 | sed 's/.*/EXISTS key:&/' | r | grep -c '^1$')
echo "maxmemory-samples $1: read half kept $read_kept/6000, unread half kept $unread_kept/6000"
}
r CONFIG SET maxmemory 3mb > /dev/null
run 1
run 5
run 10
$ bash samples.sh
maxmemory-samples 1: read half kept 3765/6000, unread half kept 3806/6000
maxmemory-samples 5: read half kept 5047/6000, unread half kept 1153/6000
maxmemory-samples 10: read half kept 5536/6000, unread half kept 663/6000
True LRU would keep all 6,000 recently read keys and evict only unread ones. With a sample of 1,
Redis evicts whichever single key it picks, so both halves lose about the same amount: that’s
random eviction. The default of 5 keeps about 5,000 of the read half, and 10 keeps about 5,600
(the counts move by a few dozen between runs). The sleep 2 lines matter, because Redis’s LRU
clock has one-second resolution: keys touched within the same second look equally recent.
For the exact algorithms, an LRU list and LFU counters built from scratch, see Cache Eviction: LRU vs LFU. This lesson is about Redis’s cheaper, sampled version and the settings that control it.
allkeys-lfu: keep the hot key through a scan
LRU only knows when a key was last used. A batch job that writes 20,000 new keys once each makes every one of them more recent than a key read 500 times a few seconds earlier:
# lfu.sh - a hot key, then a one-off scan of new keys: LRU vs LFU
r() { docker exec -i redis-lesson redis-cli "$@"; }
VALUE=0123456789012345678901234567890123456789012345678901234567890123
run() {
r FLUSHALL > /dev/null
r CONFIG SET maxmemory-policy "$1" > /dev/null
r SET hot "$VALUE" > /dev/null
seq 1 500 | sed 's/.*/GET hot/' | r > /dev/null # read 500 times
[ "$1" = allkeys-lfu ] && echo "OBJECT FREQ hot: $(r OBJECT FREQ hot)"
sleep 2
seq 1 20000 | sed "s/.*/SET scan:& $VALUE/" | r > /dev/null # each new key written once
echo "$1: hot key exists = $(r EXISTS hot), evicted_keys = $(r INFO stats | grep evicted_keys | cut -d: -f2 | tr -d '\r')"
}
r CONFIG SET maxmemory 3mb > /dev/null
r CONFIG SET maxmemory-samples 5 > /dev/null
r CONFIG RESETSTAT > /dev/null
run allkeys-lru
r CONFIG RESETSTAT > /dev/null
run allkeys-lfu
$ bash lfu.sh
allkeys-lru: hot key exists = 0, evicted_keys = 7508
OBJECT FREQ hot: 15
allkeys-lfu: hot key exists = 1, evicted_keys = 7568
Under allkeys-lru the hot key was the oldest key in the database once the scan started, so it
went (in every run). Under allkeys-lfu it survived (every run), because LFU evicts the keys
used least often.
OBJECT FREQ shows the LFU counter, 14 to 16 after 500 reads. The counter is logarithmic, 8 bits
wide and capped at 255, so it grows quickly at first and then slowly (lfu-log-factor sets how
slowly). It also decays while a key sits unused (lfu-decay-time, 1 minute by default), so a key
that was hot yesterday doesn’t stay protected forever. A new key starts at 5, which stops a
just-written key from being evicted before anyone has had a chance to read it.
allkeys-lrm: reads don’t count
Redis 8.6 added allkeys-lrm and volatile-lrm, which evict the least recently modified
key. A read doesn’t refresh a key; only a write does. This script writes config:* first and
data:* later, then reads every config:* key just before overfilling:
# lrm.sh - keys that are read constantly but never rewritten: allkeys-lru vs allkeys-lrm
r() { docker exec -i redis-lesson redis-cli "$@"; }
VALUE=0123456789012345678901234567890123456789012345678901234567890123
run() {
r FLUSHALL > /dev/null
r CONFIG SET maxmemory-policy "$1" > /dev/null
seq 1 6000 | sed "s/.*/SET config:& $VALUE/" | r > /dev/null # written first
sleep 2
seq 1 6000 | sed "s/.*/SET data:& $VALUE/" | r > /dev/null # written later
sleep 2
seq 1 6000 | sed 's/.*/GET config:&/' | r > /dev/null # config read last
seq 1 6000 | sed "s/.*/SET new:& $VALUE/" | r > /dev/null # overfill
config_kept=$(seq 1 6000 | sed 's/.*/EXISTS config:&/' | r | grep -c '^1$')
data_kept=$(seq 1 6000 | sed 's/.*/EXISTS data:&/' | r | grep -c '^1$')
echo "$1: config kept $config_kept/6000, data kept $data_kept/6000"
}
r CONFIG SET maxmemory 3mb > /dev/null
r CONFIG SET maxmemory-samples 5 > /dev/null
run allkeys-lru
run allkeys-lrm
$ bash lrm.sh
allkeys-lru: config kept 5006/6000, data kept 1182/6000
allkeys-lrm: config kept 1179/6000, data kept 4958/6000
The two policies give mirror-image results. LRU keeps the config:* keys because they were read
last. LRM evicts them because they were written first, and the reads didn’t count. Reach for
LRM when data should age out on a schedule set by writes, such as precomputed results you would
rather rebuild than keep serving stale. For an ordinary read-through cache, a key that is still
being read is exactly the one to keep, so LRU or LFU is the better fit.
Which policy
| Policy | Evicts | Use when |
|---|---|---|
noeviction | nothing; writes fail with OOM | Redis is the only copy of the data |
allkeys-lru | least recently used key | a general cache |
allkeys-lfu | least frequently used key | a cache with a stable set of hot keys and occasional scans |
allkeys-lrm | least recently modified key | data that should age out by write time (Redis 8.6+) |
allkeys-random | a random key | every key is equally likely to be needed |
volatile-lru / -lfu / -lrm / -random | the same, but only keys with a TTL | cache and permanent data share one instance |
volatile-ttl | the key closest to expiring | TTLs already rank how much you need each key |
Gotchas
- Set
maxmemorybelow the container or VM limit. Redis counts its dataset and client buffers towards the limit, but not replication or AOF buffers (mem_not_counted_for_evictinINFO memory), allocator fragmentation, or the fork for a background save. A container limit equal tomaxmemorygets Redis killed by the kernel’s OOM killer instead of evicting. - Replicas don’t evict on their own. The primary evicts and sends the
DELs to its replicas, so a replica with a smallermaxmemorythan its primary can still fill up. CONFIG SETisn’t permanent. These examples change settings at runtime; putmaxmemoryandmaxmemory-policyinredis.conf(or runCONFIG REWRITE) for them to survive a restart.- A big eviction burst is slow. Freeing thousands of keys blocks the server while it happens.
lazyfree-lazy-eviction yesfrees the memory in a background thread instead.
Practice
Each task assumes the redis-lesson container from the setup and the 3 MB limit.
1. volatile-ttl. Write 6,000 keys with 10 minutes left, 6,000 with a day left, then overfill
with 6,000 more that have an hour left. Which group loses keys?
Solution
# p1_volatile_ttl.sh - volatile-ttl evicts the keys closest to expiring first
r() { docker exec -i redis-lesson redis-cli "$@"; }
VALUE=0123456789012345678901234567890123456789012345678901234567890123
r FLUSHALL > /dev/null
r CONFIG SET maxmemory 3mb > /dev/null
r CONFIG SET maxmemory-policy volatile-ttl > /dev/null
seq 1 6000 | sed "s/.*/SET short:& $VALUE EX 600/" | r > /dev/null # 10 minutes left
seq 1 6000 | sed "s/.*/SET long:& $VALUE EX 86400/" | r > /dev/null # 1 day left
seq 1 6000 | sed "s/.*/SET new:& $VALUE EX 3600/" | r > /dev/null # overfill: 1 hour
echo "short kept: $(seq 1 6000 | sed 's/.*/EXISTS short:&/' | r | grep -c '^1$')/6000"
echo "long kept: $(seq 1 6000 | sed 's/.*/EXISTS long:&/' | r | grep -c '^1$')/6000"
echo "new kept: $(seq 1 6000 | sed 's/.*/EXISTS new:&/' | r | grep -c '^1$')/6000"
$ bash p1_volatile_ttl.sh
short kept: 461/6000
long kept: 6000/6000
new kept: 3522/6000
The 10-minute keys went first, then some of the 1-hour keys once few short ones were left; the
day-long keys were never touched. Like LRU, volatile-ttl samples, which is why 390 to 460 short
keys survived while some longer-lived ones were evicted.
2. lfu-log-factor. Read one key 11,100 times under allkeys-lfu with lfu-log-factor set
to 0, 10 (the default) and 100, and compare OBJECT FREQ.
Solution
# p2_log_factor.sh - how fast the LFU counter climbs depends on lfu-log-factor
r() { docker exec -i redis-lesson redis-cli "$@"; }
r CONFIG SET maxmemory-policy allkeys-lfu > /dev/null
for factor in 0 10 100; do
r CONFIG SET lfu-log-factor "$factor" > /dev/null
r DEL hot > /dev/null
r SET hot 1 > /dev/null
for reads in 100 1000 10000; do
seq 1 "$reads" | sed 's/.*/GET hot/' | r > /dev/null
done
echo "lfu-log-factor $factor: OBJECT FREQ after 11,100 reads = $(r OBJECT FREQ hot)"
done
r CONFIG SET lfu-log-factor 10 > /dev/null
$ bash p2_log_factor.sh
lfu-log-factor 0: OBJECT FREQ after 11,100 reads = 255
lfu-log-factor 10: OBJECT FREQ after 11,100 reads = 51
lfu-log-factor 100: OBJECT FREQ after 11,100 reads = 20
With a factor of 0 every read increments the counter, so it hits the 255 cap long before 11,100 reads and can no longer tell a warm key from a very hot one. Higher factors make each increment less likely as the counter grows, which spreads keys over the 0-255 range. The values vary a little between runs (46-56 and 19-22 across runs), because the increment is probabilistic.
3. Cache and permanent data in one instance. Store 2,000 settings with no TTL and 20,000 cache entries with a TTL in the same 3 MB Redis. Pick a policy that never evicts a setting.
Solution
# p3_mixed.sh - one instance, cache keys with a TTL and settings without: volatile-lru keeps the settings
r() { docker exec -i redis-lesson redis-cli "$@"; }
VALUE=0123456789012345678901234567890123456789012345678901234567890123
r FLUSHALL > /dev/null
r CONFIG SET maxmemory 3mb > /dev/null
r CONFIG SET maxmemory-policy volatile-lru > /dev/null
seq 1 2000 | sed "s/.*/SET setting:& $VALUE/" | r > /dev/null # must never be evicted
seq 1 20000 | sed "s/.*/SET cache:& $VALUE EX 3600/" | r | grep -v '^OK$' | sort | uniq -c
echo "settings kept: $(seq 1 2000 | sed 's/.*/EXISTS setting:&/' | r | grep -c '^1$')/2000"
echo "cache kept: $(seq 1 20000 | sed 's/.*/EXISTS cache:&/' | r | grep -c '^1$')/20000"
$ bash p3_mixed.sh
settings kept: 2000/2000
cache kept: 8160/20000
volatile-lru only looks at keys with a TTL, so all 2,000 settings survived, and the grep -v '^OK$' printed nothing because every cache write succeeded. This is the one case where the
volatile-lru trap works for you. Separate Redis instances for cache and permanent data are still
safer, because a bug that writes cache keys without a TTL turns this setup back into OOM errors.