What maxmemory and maxmemory-policy actually do
maxmemory caps how much memory Redis will use for data, measured as the allocator's view of used memory. maxmemory-policy decides what happens when a write would push usage past that cap: evict keys according to some rule, or refuse the write. The default policy is noeviction. On 64-bit builds, the default maxmemory of 0 means no limit.
Both defaults are a poor fit for a cache. With no limit, Redis keeps growing until the host swaps or the kernel or container runtime kills the process, and you lose the whole cache at once. With a limit and noeviction, reads keep working but writes that need memory fail with OOM command not allowed when used memory > 'maxmemory'. Your API then logs errors on every cache fill, when the expected behavior was a cache miss followed by a fallback to the database.
Set maxmemory well below the container or host limit, because the process's real memory footprint (RSS) exceeds used_memory:
- Allocator fragmentation inflates RSS relative to used memory.
- Replication buffers and the backlog grow during resyncs. Some replica buffers are not counted against
maxmemory, so eviction will not reclaim them. - Client output buffers grow when slow consumers or large replies back up.
- Copy-on-write during background saves can duplicate pages that change while the save runs.
Illustrative estimate using assumed inputs:
- Container limit: 8 GiB.
- Buffer reserve for replication and client output: 768 MiB.
- Expected fragmentation ratio: 1.2.
That gives (8 GiB − 0.75 GiB) ÷ 1.2 ≈ 6 GiB for maxmemory, or about 75% of the container limit.
#!/usr/bin/env bash
# Inspect and set the memory cap on a Redis cache instance.
# Host/port are placeholders for a cache inside an 8 GiB container.
set -euo pipefail
R="redis-cli -h cache.internal -p 6379"
# 1. Check the current settings. On a 64-bit build with no config,
# maxmemory is 0 (no limit) and maxmemory-policy is noeviction.
$R CONFIG GET maxmemory
$R CONFIG GET maxmemory-policy
# 2. Compare what Redis counts (used_memory) with what the OS sees
# (used_memory_rss). Size the cap based on the gap between them.
$R INFO memory | grep -E '^(used_memory|used_memory_rss|mem_fragmentation_ratio|maxmemory|maxmemory_policy):'
# 3. Illustrative estimate (assumed inputs, not measured):
# (8192 MiB limit - 768 MiB buffer reserve) / 1.2 fragmentation
# = about 6187 MiB, rounded down to 6 GiB. In Redis units, 1gb = 1024^3 bytes.
$R CONFIG SET maxmemory 6gb
# 4. Evict keys instead of rejecting writes. With noeviction, a write
# that needs memory at the cap fails with:
# (error) OOM command not allowed when used memory > 'maxmemory'.
$R CONFIG SET maxmemory-policy allkeys-lru
# 5. CONFIG SET changes only the running process. Persist the change
# to redis.conf. This step fails if Redis was started without a config file.
$R CONFIG REWRITE
# Equivalent redis.conf lines:
# maxmemory 6gb
# maxmemory-policy allkeys-lru
Checks the default limit and policy with redis-cli, compares used_memory with RSS, then sets maxmemory to the illustrative 6 GiB value with an evicting policy so a full cache drops keys instead of returning OOM errors on writes.
How Redis eviction works: policy families and approximate LRU/LFU
When used memory crosses maxmemory, Redis does not run a background sweeper. The check happens inline, when a command that could grow memory is processed:
Write arrives → memory check → under limit? accept → over limit? sample candidate keys → evict the best candidate → repeat until under limit → accept the write, or reject it with an OOM error if nothing is evictable
Eviction cost therefore lands on the request path. The policy decides which keys are candidates and how they are ranked.
| Policy | Candidate keys | Ranking |
|---|---|---|
noeviction |
none | Writes are rejected and reads still work |
allkeys-lru |
all keys | Least recently used |
allkeys-lfu |
all keys | Least frequently used, with decay over time |
allkeys-random |
all keys | Random |
volatile-lru |
keys with a TTL | Least recently used |
volatile-lfu |
keys with a TTL | Least frequently used |
volatile-random |
keys with a TTL | Random |
volatile-ttl |
keys with a TTL | Shortest remaining TTL |
The volatile-* policies only look at keys that have an expiry set. If your application writes keys without TTLs, a volatile policy finds nothing to evict and fails exactly like noeviction. Teams often learn this from a production OOM error rather than from the docs.
Approximate, not exact
Redis does not keep a global LRU list or a global frequency table, because maintaining either on every access would cost memory and CPU. Instead, it samples a few keys, keeps a small pool of good candidates across rounds, and evicts the worst one it has seen. maxmemory-samples sets the size of each sample. A larger value tracks true LRU/LFU more closely, but each eviction takes more CPU. A smaller value is cheaper but more likely to evict a key that is still hot.
+------------------------------+
| Write command arrives |
+--------------+---------------+
|
v
+------------------------------+ no +-----------------------+
+->| Over maxmemory? |------>| Accept the write |
| +--------------+---------------+ +-----------------------+
| | yes
| v
| +------------------------------+ none +-----------------------+
| | Policy picks candidate set |------->| Reject write: OOM err |
| | allkeys-* : every key | | (noeviction, or |
| | volatile-* : keys with TTL | | volatile-* with no |
| | noeviction : nothing | | TTL'd keys) |
| +--------------+---------------+ +-----------------------+
| | some
| v
| +------------------------------+
| | Sample maxmemory-samples keys|
| | merge into candidate pool |
| | rank: LRU / LFU / TTL / rand |
| +--------------+---------------+
| |
| v
| +------------------------------+
| | Evict best candidate |
| +--------------+---------------+
| |
+-----------------+
Redis checks memory inline on each write. Over the limit, it samples keys from the policy's candidate set and evicts the best one. It repeats this until memory is under maxmemory, then accepts the write. If no keys can be evicted, it rejects the write with an OOM error.
Who should use allkeys-lru, and when allkeys-lfu fits better
If an instance holds nothing but API responses, every key can be rebuilt from the origin. That makes any key a valid eviction candidate. Set maxmemory explicitly, because on a typical 64-bit build the default is no limit. Then start with allkeys-lru. API traffic is usually recency-driven: the endpoints and IDs requested in the last few minutes are the ones most likely to be requested again.
Switch to allkeys-lfu when the hot set is stable and skewed, for example a few thousand product or config keys that serve most reads. LFU also helps when that hot set competes with occasional scans, backfills or bursts of one-off keys. Under LRU, each of those fresh keys looks "recent" and can push the hot set out. Under LFU, a key touched once has a low counter and is evicted first.
allkeys-lru |
allkeys-lfu |
|
|---|---|---|
| Ranks keys by | Time since last access | Approximate access frequency, decayed over time |
| Adapts to a shifting hot set | Quickly | Slowly; old favourites keep high counters until they decay |
| Scans and bulk loads | Can flush the hot set | Mostly absorbed by the eviction policy |
| Good default for | Recency-driven API responses | Stable, skewed hot sets with one-off noise |
Keep TTLs and eviction separate in your design. TTLs control freshness. Eviction is the memory safety net. Expiry alone does not bound memory: a burst of writes can exceed maxmemory long before any TTL fires. Keys without a TTL never expire.
When to avoid allkeys-* and noeviction: mixed-purpose instances
An allkeys-* policy treats every key as disposable. On an instance that also holds sessions, distributed locks, rate-limit counters, job queues, or idempotency keys, that assumption breaks correctness, not just performance. An evicted session logs a user out. An evicted lock lets two workers run the same job. An evicted rate-limit counter resets to zero and lets a client exceed its quota. An evicted idempotency key allows a payment retry to execute twice. None of these failures shows up as a slower cache hit. They appear later as data bugs that are hard to trace back to memory pressure.
volatile-lru (or volatile-lfu) narrows eviction to keys that have a TTL. This works when every cache entry has an expiry and every key that must survive has none. Be careful with volatile-ttl. It evicts the keys closest to expiry, which are often the hot, short-TTL entries you wrote a moment ago and read constantly. Volatile policies have another catch: if no key with a TTL is available to evict, Redis rejects writes just as noeviction does.
noeviction is the right choice for data that must persist. It refuses writes with an out-of-memory error rather than dropping keys. For a cache, that turns "some misses and higher latency" into failed requests across the API.
If choosing a policy depends on carefully tagging keys with TTLs, the instance is serving two jobs with conflicting failure modes. Treat that as a design smell, not a tuning problem.
Alternatives: splitting workloads and sizing instead of tuning
The usual fix is to stop sharing. Give the cache and the durable data their own instances, so each gets a maxmemory and a policy that matches its failure mode: allkeys-lru or allkeys-lfu for the cache, noeviction for queues, sessions, or rate-limit counters.
Logical databases look like a cheaper version of this split, but they don't provide it. maxmemory and maxmemory-policy are instance-wide settings. Keys in database 1 and database 2 compete for the same memory, and the same eviction pass can remove keys from either. Logical databases separate key names, not memory. Redis Cluster also supports only database 0, so the approach doesn't carry over if you shard later.
| Option | Policy per workload | Memory isolation | Operational cost |
|---|---|---|---|
| Separate instances | Yes | Yes | More processes, connection pools, config, and dashboards |
| Logical databases | No | No | Low, but solves nothing for eviction |
| More memory / sharding | No (one policy) | No | Higher spend and resharding work, but no topology change for clients |
Separate instances do have costs. Each service may need an extra client and connection pool, every instance needs its own capacity alerts, and failovers multiply. For a small team, one well-sized instance per failure mode is usually the right limit, not one per microservice.
None of this replaces sizing. If the hot working set doesn't fit in maxmemory, every policy just picks which useful keys to throw away. Eviction is a pressure valve for the cold tail. It is not how a cache should run day to day. Measure the working set first, then pick the policy.
Implementing and rolling out a policy safely
Once you know the working set, apply the settings at runtime with CONFIG SET maxmemory and CONFIG SET maxmemory-policy. Both take effect immediately and need no restart. Then read them back with CONFIG GET maxmemory* to confirm the server accepted what you sent. Runtime changes disappear on restart, so persist them too. Either edit redis.conf directly or run CONFIG REWRITE. The rewrite only works if the server was started with a config file and that file is writable by the Redis process. If your deployment generates config from templates or a managed service controls it, change it there instead. Otherwise the next deploy silently reverts your change.
Before production, test the candidate policy against real access patterns. Synthetic uniform keys tell you little. Capture a representative slice of production traffic and replay it against a staging instance capped at a proportionally smaller maxmemory. Then compare hit rate and evicted-key counts across policies. Skewed, bursty traffic is where LRU and LFU actually diverge.
The application has its own duties:
- Treat every read as a possible miss. Any key can vanish under memory pressure, regardless of its TTL. Code that assumes presence will break.
- Protect the origin. When many hot keys are evicted or expire together, the resulting misses can overload your database. Request coalescing (one fetch per key, other callers wait for it) and jittered TTLs spread that load out.
- Avoid very large values. Evicting one multi-megabyte value frees memory in a single large chunk. Small values let eviction track pressure more smoothly and keep the hit rate predictable.
#!/usr/bin/env bash
# Apply, verify and persist an eviction policy on one Redis instance.
set -euo pipefail
REDIS="redis-cli -h ${REDIS_HOST:-cache-staging.internal} -p ${REDIS_PORT:-6379}"
# Placeholder auth: pass the password via REDISCLI_AUTH, never on the command line.
# export REDISCLI_AUTH="change-me"
# 1. Apply at runtime. Both settings take effect immediately, with no restart.
$REDIS CONFIG SET maxmemory 2gb
$REDIS CONFIG SET maxmemory-policy allkeys-lfu
# 2. Read back to confirm the server accepted the values.
# maxmemory is reported in bytes, so expect 2147483648.
$REDIS CONFIG GET 'maxmemory*'
# 3. Persist the change. CONFIG REWRITE returns an error if the server was
# started without a config file or cannot write to it. In that case, update
# redis.conf or your config template by hand so the next deploy keeps it.
if ! $REDIS CONFIG REWRITE; then
echo "CONFIG REWRITE failed: persist these settings in redis.conf or your template:" >&2
echo " maxmemory 2gb" >&2
echo " maxmemory-policy allkeys-lfu" >&2
fi
# 4. While replaying captured traffic against staging, sample hit and eviction
# counters. Compare these numbers across candidate policies.
$REDIS INFO stats | grep -E '^(keyspace_hits|keyspace_misses|evicted_keys):'
A redis-cli script that sets maxmemory and maxmemory-policy at runtime, confirms them with CONFIG GET, persists them with CONFIG REWRITE (with a fallback when the config file is not writable), and reads the hit, miss and eviction counters you compare when replaying traffic in staging.
Monitoring eviction and the impact on cost and engineering time
Two INFO sections cover most of what you need. INFO memory reports used_memory and maxmemory. When used_memory sits near the limit, eviction is active. INFO stats reports evicted_keys, expired_keys, keyspace_hits and keyspace_misses. These counters are cumulative since startup or the last stats reset, so sample them periodically and compute deltas. A lifetime ratio hides recent change.
Hit ratio = Δhits / (Δhits + Δmisses)
Rising evicted_keys alone is not alarming, because it is the policy doing its job. The undersizing signal is evictions climbing while the hit ratio falls. When that happens, Redis is discarding keys that clients still request. Every extra miss adds a cache lookup, an origin query and a write-back. At sustained rates, the misses can push the origin database past its capacity. Also watch expired_keys. If most removals come from TTLs rather than eviction, memory pressure is not your problem.
Illustrative estimate (assumed numbers, not a benchmark). Assume 5,000 requests/s, and assume the hit ratio drops from 0.95 to 0.85.
Origin load = requests/s × (1 − hit ratio)
That is 250/s before and 750/s after, or three times the database traffic. If the database is provisioned for 500 queries/s, the drop alone overloads it.
Compare that exposure with the cost of more memory or a separate instance. Engineering time also counts. A sensible default policy and separating cache data from durable data prevent several kinds of incident:
- OOM errors from
noeviction - Silently evicted session or queue keys
- Hours spent tracing correctness bugs back to eviction
Extra infrastructure is a predictable line item. Incident time is not.
#!/usr/bin/env bash
# Sample INFO counters twice and report per-interval deltas, since the raw values are cumulative.
# Usage: ./redis-evict-check.sh [interval_seconds] (set REDISCLI_AUTH if the server needs a password)
set -euo pipefail
HOST="${REDIS_HOST:-127.0.0.1}"
PORT="${REDIS_PORT:-6379}"
INTERVAL="${1:-60}"
# One INFO stats call per sample. tr strips the \r that INFO puts at the end of each line.
snapshot() {
redis-cli -h "$HOST" -p "$PORT" INFO stats | tr -d '\r' | awk -F: '
/^(keyspace_hits|keyspace_misses|evicted_keys|expired_keys):/ { v[$1] = $2 }
END { print v["keyspace_hits"], v["keyspace_misses"], v["evicted_keys"], v["expired_keys"] }'
}
read -r h1 m1 ev1 ex1 <<< "$(snapshot)"
sleep "$INTERVAL"
read -r h2 m2 ev2 ex2 <<< "$(snapshot)"
# A restart or CONFIG RESETSTAT between samples resets the counters and makes these deltas negative. Discard that sample.
dh=$((h2 - h1)); dm=$((m2 - m1)); dev=$((ev2 - ev1)); dex=$((ex2 - ex1))
# Memory pressure: eviction only runs when used_memory reaches maxmemory. maxmemory 0 means no limit is set.
read -r used max <<< "$(redis-cli -h "$HOST" -p "$PORT" INFO memory | tr -d '\r' | awk -F: '
$1 == "used_memory" { u = $2 } $1 == "maxmemory" { m = $2 } END { print u, m }')"
awk -v dh="$dh" -v dm="$dm" -v dev="$dev" -v dex="$dex" -v t="$INTERVAL" -v used="$used" -v max="$max" 'BEGIN {
ratio = (dh + dm) > 0 ? dh / (dh + dm) : 0
printf "hit ratio (interval): %.3f\n", ratio
printf "evicted/s: %.1f expired/s: %.1f\n", dev / t, dex / t
if (max > 0) printf "memory: %.1f%% of maxmemory\n", 100 * used / max
else print "memory: no maxmemory set (eviction policy never triggers)"
# Undersizing signal: evictions outpace TTL expiry while the hit ratio is low.
# Thresholds are placeholders. Tune them against your own baseline.
if (dev > dex && ratio < 0.90) print "WARN: eviction-driven misses; check sizing or split workloads"
}'
A redis-cli script that samples INFO stats and INFO memory over an interval. It computes the interval hit ratio, eviction and expiry rates, and memory use against maxmemory, then flags the pattern of rising evictions alongside a low hit ratio that indicates undersizing.
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 maxmemory-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.