How maxmemory and maxmemory-policy work together
A Redis eviction policy is the rule Redis follows to free memory by deleting keys when the dataset hits a configured ceiling. It exists because Redis keeps the whole dataset in RAM. Without a ceiling, a growing cache keeps expanding until the operating system's OOM killer terminates the process or the host starts swapping, which destroys latency. The policy lets you decide which data gets discarded first, so you lose low-value keys instead of the whole instance.
Two directives control this, and neither does anything without the other:
maxmemorysets the Redis memory limit, for examplemaxmemory 4gbinredis.conf. A value of0means no limit. With no limit, no eviction policy ever runs.maxmemory-policychooses the eviction algorithm, for examplemaxmemory-policy allkeys-lru.
Eviction is reactive. When Redis processes a command and memory use is above maxmemory, it removes keys according to the policy before it runs the command. Under heavy write load, memory can briefly go past the limit. The process's resident memory can also stay above maxmemory because of allocator fragmentation, so leave headroom on the host.
The default policy is noeviction. Under this policy Redis deletes nothing. Commands that would allocate more memory fail with an OOM command not allowed when used memory > 'maxmemory' error. Reads and deletes continue to work. This is safe for data you cannot lose, but your application has to handle failed writes.
To check what a running instance is using, run CONFIG GET maxmemory and CONFIG GET maxmemory-policy. The first returns the limit in bytes, so 4gb appears as 4294967296. Check these values on the live instance rather than trusting the config file. A runtime CONFIG SET may have changed them since startup.
# redis.conf: both directives are needed for eviction to happen.
# maxmemory 4gb # 0 (the default) means no limit, so nothing is evicted
# maxmemory-policy allkeys-lru # default is noeviction
REDIS_HOST=redis-cache-01.internal # placeholder host
REDIS_PORT=6379
# Read the values the running instance is using. A CONFIG SET after
# startup may have changed them, so redis.conf can be out of date.
redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" CONFIG GET maxmemory
# 1) "maxmemory"
# 2) "4294967296" <- 4gb reported in bytes
redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" CONFIG GET maxmemory-policy
# 1) "maxmemory-policy"
# 2) "allkeys-lru"
# Compare current usage with the limit. A maxmemory of 0 here means
# no ceiling is set and no policy will ever run.
redis-cli -h "$REDIS_HOST" -p "$REDIS_PORT" INFO memory \
| grep -E '^(used_memory_human|maxmemory_human|maxmemory_policy):'
# Under noeviction, a write that needs more memory once the limit is
# reached fails with this error, and no keys are removed:
# (error) OOM command not allowed when used memory > 'maxmemory'.
Shows the two redis.conf directives that enable eviction and how to confirm the live maxmemory and maxmemory-policy values with redis-cli CONFIG GET and INFO memory.
How allkeys-lru and volatile-ttl choose what to evict
Eviction happens on the command path, not in a background sweep. Before executing a command that can grow memory, Redis checks usage against maxmemory and evicts until it is back under the limit. Only then does it run the write. Reads are unaffected.
client write
│
▼
used_memory > maxmemory? ──no──► execute write
│ yes
▼
policy defines candidate set
allkeys-lru : every key
volatile-ttl: keys with an expiry only
│
▼
candidates exist? ──no──► reject write with OOM error
│ yes
▼
sample maxmemory-samples keys, evict best match
│
└──► repeat until under limit, then execute write
allkeys-lru
Every key is a candidate. Redis does not keep a global linked list ordered by access time, because that would add memory to every key and work to every read. Instead, each key stores a coarse last-access clock. At eviction time Redis samples a small number of keys, set by maxmemory-samples, and evicts the one idle longest. The result approximates LRU: a key that is not the true least-recently-used may go first. Raising the sample count tightens the approximation and costs more CPU on each eviction.
volatile-ttl
Only keys with an expiry are candidates. Redis samples them the same way and evicts the one with the shortest remaining TTL, on the reasoning that it was about to disappear anyway. Keys without a TTL are never evicted. If the expiring keys run out, or none were ever set, nothing is eligible. The policy then behaves like noeviction: writes fail with an OOM error while memory stays full.
┌──────────────┐
│ client write │
└──────┬───────┘
▼
┌──────────────────────────┐ no ┌───────────────┐
┌──►│ used_memory > maxmemory? ├──────►│ execute write │
│ └──────┬───────────────────┘ └───────────────┘
│ │ yes
│ ▼
│ ┌────────────────────────────────┐
│ │ policy defines candidate set │
│ │ allkeys-lru : every key │
│ │ volatile-ttl: keys with expiry│
│ └──────┬─────────────────────────┘
│ ▼
│ ┌──────────────────────┐ no ┌──────────────────────────┐
│ │ any candidates left? ├──────►│ reject write: OOM error │
│ └──────┬───────────────┘ │ (e.g. volatile-ttl with │
│ │ yes │ no expiring keys left) │
│ ▼ └──────────────────────────┘
│ ┌────────────────────────────────────┐
│ │ sample maxmemory-samples keys │
│ │ allkeys-lru : evict longest idle │
│ │ volatile-ttl: evict shortest TTL │
│ └──────┬─────────────────────────────┘
│ │
└──────────┘ evict one key, re-check memory
Before a write that can grow memory, Redis evicts sampled candidates chosen by the active policy until usage is back under maxmemory, or rejects the write with an OOM error if no eligible keys remain.
Who should use which policy, and when
The deciding question is whether every key can be thrown away safely. If each key is a copy of data held somewhere else, such as a database row, a rendered fragment or an API response, then losing it costs one recomputation. That is a pure cache, and allkeys-lru fits it. Every key is a candidate, so the instance cannot reach a state where nothing is evictable. Retention follows actual reads rather than TTLs someone chose months ago, and TTLs become optional. Developers can add keys without thinking about eviction at all.
Mixed workloads are harder. Sessions, distributed locks, job queues and rate-limit counters often sit next to cache entries, and losing them causes user-visible errors or correctness bugs. The common response is a volatile-* policy that sets TTLs on cache keys and leaves critical keys without them. That makes TTL presence act as a "do not evict" flag. Nothing enforces it, and one missing EXPIRE in a code path breaks it. The safer pattern is to split the data. Run disposable data on an instance with allkeys-lru. Run state on a separate instance with noeviction, sized so that hitting the limit is an alert, not a routine event.
allkeys-lru |
volatile-ttl |
|
|---|---|---|
| Candidate set | All keys | Only keys with a TTL |
| TTL dependency | None | Total, since eligibility and ordering both come from TTLs |
| Failure mode at the limit | Hot data evicted if the working set exceeds memory, so hit rate drops | OOM write errors once expiring keys run out |
| Best-fit workload | Pure cache with a rebuildable source of truth | Disciplined TTL-only data where shortest-remaining-life is a meaningful priority |
When to avoid each policy: failure modes in production
allkeys-lru
The risk is that it treats every key as disposable. A cache instance tends to pick up other uses over time, such as session state, a job queue, or a feature-flag hash. Under memory pressure those keys get evicted the same way cache entries do, and nothing reports it. The data just disappears. If an instance holds any key that has no rebuildable source, either move that key elsewhere or switch the instance to a policy that leaves it alone.
volatile-ttl
This policy assumes a shorter remaining TTL means a less valuable key. Distributed locks and rate-limit counters break that assumption, because they are short-lived and critical. Evicting a lock lets two workers into the same critical section. Evicting a counter resets someone's quota.
It also depends on every write path setting a TTL. One code path that writes without a TTL creates memory the policy can never reclaim. Once the expiring keys run out, writes fail with OOM errors. A small volatile pool causes a related problem. Evictions concentrate on a few short-TTL hot keys, so they churn constantly, and each eviction can trigger a stampede of concurrent rebuilds against the backing store.
Both policies
- Approximate selection. Redis samples a handful of keys (
maxmemory-samples) instead of tracking exact order. It can evict a key that is still in use. - Main-thread cost. Eviction runs before commands that allocate memory, so write bursts add latency. Freeing a large hash or set blocks the main thread unless
lazyfree-lazy-evictionis enabled. - Replica divergence. Replicas usually wait for DELs from the primary rather than evicting on their own. Replication buffers and fragmentation can push a replica past its limit even when the primary is within budget.
Alternatives: the rest of the eviction policy space
The two policies compared above are part of a matrix with two axes. One axis is which keys are candidates: all keys, or only keys with an expiry set. The other is how a victim is picked from that set.
| Policy | Candidate keys | Selection rule |
|---|---|---|
volatile-lru |
Keys with a TTL | Approximate least recently used |
allkeys-lfu |
All keys | Approximate least frequently used |
volatile-lfu |
Keys with a TTL | Approximate least frequently used |
allkeys-random |
All keys | Uniform random |
volatile-random |
Keys with a TTL | Uniform random |
noeviction |
None | Reject writes that need more memory |
Volatile policies with no expiring keys. When no keys have a TTL, every volatile-* policy has nothing to remove. It then fails writes the same way noeviction does.
LFU versus LRU. LFU helps when access is skewed and driven by frequency. LRU only knows when a key was last touched. A single batch job or full scan can push long-lived hot keys out of the cache. LFU keeps a small counter for each key that decays over time. A key read thousands of times keeps a high count, so one burst of cold reads does not evict it.
Random policies. These skip the recency and frequency bookkeeping entirely. They suit uniform access patterns, where no key is meaningfully hotter than another.
noeviction. This is the right choice when Redis holds data you cannot lose, such as queues, locks or session state. In that case, a write error is better than silent loss.
Splitting the workload. Often the better answer is architectural. If a single Redis server holds both a disposable cache and durable state, no single policy fits both. Put each workload on its own instance with its own memory limit and policy, for example allkeys-lfu for the cache and noeviction for the state. This works better than splitting by logical database, because maxmemory and the eviction policy apply to the whole server, not to each database.
Implementing it: sizing headroom, changing policy and observing eviction
Start from physical RAM and work down. maxmemory caps the dataset as Redis accounts for it, but the process consumes more than that. Allocator fragmentation sits outside the accounting, and so do copy-on-write pages from the forked child during BGSAVE or AOF rewrite. Replica output buffers are also excluded from the eviction check. If the process outgrows the box, the kernel OOM killer terminates Redis outright. It never consults maxmemory-policy.
Illustrative estimate. Assume a 16 GB host, maxmemory 13gb, a fragmentation ratio of 1.15, and a background save during a write burst that dirties 25% of pages. Resident memory reaches about 15 GB. Copy-on-write adds roughly 3.7 GB, for a total near 18.7 GB. The box looked big enough on paper and still gets OOM-killed. On hosts that fork for persistence, a common starting point is to leave 25–40% of RAM as headroom.
To change the policy at runtime, run CONFIG SET maxmemory-policy allkeys-lru and adjust CONFIG SET maxmemory-samples if needed. More samples give a closer approximation of true LRU and cost more CPU. These changes live only in memory, so a restart reverts them. Follow up with CONFIG REWRITE, or edit the config file your deployment tooling manages. Before production, rehearse the change in staging by filling the instance past maxmemory while a background save runs.
From INFO, track used_memory, evicted_keys, expired_keys, keyspace_hits and keyspace_misses. The counters are cumulative, so compute per-minute deltas. Alert when the eviction rate rises and the hit ratio, hits / (hits + misses), falls in the same window. That combination means Redis is evicting keys your application still reads.
#!/usr/bin/env bash
# Apply the eviction policy, persist it, then watch per-minute eviction and hit-ratio deltas.
set -euo pipefail
CLI=(redis-cli -h 10.0.0.12 -p 6379) # auth via REDISCLI_AUTH env var, not on the command line
# Runtime change only; a restart would revert these...
"${CLI[@]}" CONFIG SET maxmemory-policy allkeys-lru
"${CLI[@]}" CONFIG SET maxmemory-samples 10 # more samples = closer to true LRU, more CPU
# ...so write them back to the config file the server was started with.
"${CLI[@]}" CONFIG REWRITE
# Pull cumulative counters by name (INFO lines end in \r).
snap() {
{ "${CLI[@]}" INFO stats; "${CLI[@]}" INFO memory; } | tr -d '\r' | awk -F: '{v[$1]=$2}
END{print v["evicted_keys"]+0, v["expired_keys"]+0, v["keyspace_hits"]+0,
v["keyspace_misses"]+0, v["used_memory"]+0}'
}
EVICT_MIN=1000 # evictions per minute worth caring about (tune per workload)
RATIO_DROP=0.02 # hit-ratio drop between windows that counts as "falling"
read -r ev0 ex0 hi0 mi0 _ < <(snap)
prev_ratio=""
while sleep 60; do
read -r ev ex hi mi mem < <(snap)
dev=$((ev - ev0)); dex=$((ex - ex0)); dh=$((hi - hi0)); dm=$((mi - mi0))
ratio=$(awk -v h="$dh" -v m="$dm" 'BEGIN{printf "%.4f", (h+m) ? h/(h+m) : 1}')
echo "used_memory=$mem evicted/min=$dev expired/min=$dex hit_ratio=$ratio"
# Alert only when evictions rise AND the hit ratio falls in the same window:
# Redis is evicting keys the application still reads.
if [[ -n "$prev_ratio" ]] && (( dev > EVICT_MIN )) &&
awk -v p="$prev_ratio" -v r="$ratio" -v d="$RATIO_DROP" 'BEGIN{exit !(p - r > d)}'; then
echo "ALERT: evictions=$dev/min, hit ratio $prev_ratio -> $ratio" >&2
fi
ev0=$ev; ex0=$ex; hi0=$hi; mi0=$mi; prev_ratio=$ratio
done
Sets and persists allkeys-lru with CONFIG SET and CONFIG REWRITE, then turns cumulative INFO counters into per-minute deltas. It alerts when the eviction rate is high and the hit ratio falls in the same window.
Impact on development cost and engineering time
The policy you pick determines where the engineering effort goes: into application code, into infrastructure, or into incident response.
allkeys-lru moves the burden off application code
On an instance that holds only cache data, allkeys-lru lets Redis decide what to drop based on access recency. Developers don't need to choose a TTL for every key, and reviewers don't need to check every new write path for a missing EXPIRE. TTLs become a freshness tool rather than a precondition for eviction, so a forgotten expiry costs a little memory instead of causing an outage.
volatile-ttl turns TTLs into a correctness requirement
Under volatile-ttl, a key written without an expiry can never be evicted. One code path that forgets to set a TTL can fill the instance with unevictable keys until writes start failing with out-of-memory errors. Every write path therefore needs a deliberate expiry, and every review has to verify it. The resulting incidents tend to show up long after the change that caused them shipped.
Separate instances cost more to run and less to debug
Splitting caches from durable data adds processes, dashboards, alerts and connection settings in every service. That overhead buys clarity. When keys vanish or writes get rejected, you know which policy applied and whether that behavior was expected.
A per-instance convention pays off during incidents
Write down one policy per instance and wire up the eviction-rate and hit-ratio alerts described above. The on-call engineer can then answer "is this eviction expected?" from the convention and a dashboard, without having to reconstruct key lifecycles from application code mid-incident.
How OneEnv can help
Disclosure: OneEnv is the product behind this blog.
OneEnv (oneenv.io) is a workspace-based governance platform for the things services share — environment variables, database structure, API routes and validation rules. A change to a shared value is staged on a branch, reviewed once, and then every affected service explicitly approves or rejects it before it goes live, so you can see who a change touches before it ships instead of finding out in production.
Applied to redis eviction policy, this is where it helps:
Saves time
- Every service that inherits a shared value is listed in one place, so nobody searches repos and chats to find out who is affected.
- One review per branch replaces coordinating each team separately; each service still keeps its own approve/reject.
- Changing a shared variable is one edit and one review instead of editing every service's
.envby hand.
Saves cost
- Fewer production incidents and rollbacks caused by a shared value, route or column changing under a consumer unannounced.
- Less duplicated configuration to maintain: one shared definition inherited by many services.
- A wrong or stale value is caught at review time by the services it affects, not by a failed deploy.
- OneEnv has a free plan (1 workspace, unlimited services and members, full governance), so trying it costs nothing.
These are qualitative benefits, not measured results — how much you save depends on how many services share the value.
Want to try it? Start at oneenv.io.