Skip to main content
Redis · lesson 12 of 14
Redis intermediate Lesson 12 of 14

maxmemory 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 maxmemory eviction lru lfu caching

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

PolicyEvictsUse when
noevictionnothing; writes fail with OOMRedis is the only copy of the data
allkeys-lruleast recently used keya general cache
allkeys-lfuleast frequently used keya cache with a stable set of hot keys and occasional scans
allkeys-lrmleast recently modified keydata that should age out by write time (Redis 8.6+)
allkeys-randoma random keyevery key is equally likely to be needed
volatile-lru / -lfu / -lrm / -randomthe same, but only keys with a TTLcache and permanent data share one instance
volatile-ttlthe key closest to expiringTTLs already rank how much you need each key

Gotchas

  • Set maxmemory below 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_evict in INFO memory), allocator fragmentation, or the fork for a background save. A container limit equal to maxmemory gets 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 smaller maxmemory than its primary can still fill up.
  • CONFIG SET isn’t permanent. These examples change settings at runtime; put maxmemory and maxmemory-policy in redis.conf (or run CONFIG 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 yes frees 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.

Frequently Asked Questions

What happens when Redis reaches maxmemory?
It depends on maxmemory-policy. With the default, noeviction, Redis refuses every command that would add memory and returns an OOM error, while reads and deletes keep working. With an eviction policy such as allkeys-lru, Redis deletes keys it picks by that policy until it is back under the limit, and the write succeeds.
Why does volatile-lru still give OOM errors?
The volatile-* policies only evict keys that have a TTL. If none of your keys has an expiry, Redis has nothing it is allowed to evict and behaves exactly like noeviction, refusing writes with an OOM error. Either give your cache keys a TTL or use an allkeys-* policy.
Is Redis LRU eviction exact?
No. Redis samples a few random keys (maxmemory-samples, default 5) and evicts the least recently used of that sample, keeping a small pool of good candidates between evictions. Raising the sample size to 10 makes it closer to true LRU at a small CPU cost; a sample of 1 is effectively random eviction.
When should I choose allkeys-lfu over allkeys-lru?
Choose LFU when a few keys are read far more often than the rest and occasional one-off scans or batch jobs touch many keys once. LRU treats a key read once a moment ago as more valuable than a key read thousands of times a minute earlier, so a scan can push the hot keys out; LFU counts how often a key is used and keeps them.
What is the allkeys-lrm policy?
Added in Redis 8.6, allkeys-lrm evicts the least recently modified key: reads don't refresh a key, only writes do. It suits data that is written once and should age out even if it is still read, such as derived results you would rather recompute periodically. For an ordinary read-through cache, LRU or LFU is the usual choice.