A Redis PING can return PONG while the next write is rejected or while an older key is silently removed. The difference is not server health; it is the configured maxmemory policy. In the reproduced Redis 8.0.2 lab, noeviction accepted 48 payload writes and then returned OOM, allkeys-lru accepted all 400 writes by evicting 353 keys, and volatile-lru evicted all 30 TTL keys before it also returned OOM.
This comparison is for developers and operators choosing a Redis memory contract for a cache, mixed workload, or state that must not disappear. It uses three independent loopback instances with persistence disabled, identical 32 KiB values, and the same 2 MiB of headroom. The commands are evidence fixtures, not a production sizing formula.
maxmemory sets a memory ceiling. maxmemory-policy decides what Redis may do when a command needs more memory than that ceiling allows. That distinction turns one capacity event into three materially different application outcomes.
| Policy | Write at the limit | Key Redis may remove | Suitable ownership model |
|---|---|---|---|
noeviction |
Reject the memory-growing write | None | Redis holds non-reconstructable state or the application must choose what to delete |
allkeys-lru |
Admit the write by evicting an approximate least-recently-used key | Any key | Every key is disposable cache data with an authoritative source elsewhere |
volatile-lru |
Evict eligible TTL keys, then reject when none remain | Only keys with expiry | One instance mixes expiring cache entries with persistent keys, with deliberate risk boundaries |
Redis’s eviction reference defines these policies and warns that LRU selection is approximate. “Least recently used” therefore does not mean a perfectly ordered global queue. It means Redis samples candidates and makes an efficient approximation under its configured algorithm.
Policy choice starts with recoverability. If an evicted value can be rebuilt from a database or object store, allkeys-lru can turn memory pressure into cache churn. If a value is the only copy of a job, session, lock, or business record, automatic eviction can turn pressure into data loss. noeviction preserves existing keys but pushes failure to the writer, so the application must handle that error explicitly.
Run every tested block in one fresh Bash session. The first block refuses existing state and occupied ports, creates a mode-0700 marker-owned directory, writes one deterministic 32 KiB payload, and defines PID-checked cleanup. It does not alter the system Redis service, firewall, public listeners, or production data.
set -euo pipefail
lab_root=/tmp/voxfor-redis-maxmemory-164
ports=(16391 16392 16393)
command -v redis-server redis-cli ss python3 >/dev/null
[[ ! -e "$lab_root" ]] || { printf 'Refusing existing path: %s\n' "$lab_root" >&2; exit 1; }
for port in "${ports[@]}"; do
! ss -H -ltn "sport = :$port" | grep -q . || { printf 'Port %s is busy.\n' "$port" >&2; exit 1; }
done
install -d -m 0700 "$lab_root"
printf 'voxfor-redis-maxmemory-164\n' > "$lab_root/OWNER"
python3 - <<'PY' "$lab_root/value.bin"
from pathlib import Path
import sys
Path(sys.argv[1]).write_bytes(b"R" * 32768)
PY
validate_owner() {
[[ -d "$lab_root" && -O "$lab_root" ]]
[[ "$(<"$lab_root/OWNER")" == voxfor-redis-maxmemory-164 ]]
}
stop_owned() {
local name=$1 port=$2 pid cmdline
[[ -f "$lab_root/$name/redis.pid" ]] || return 0
pid=$(<"$lab_root/$name/redis.pid")
[[ "$pid" =~ ^[0-9]+$ ]] || return 1
[[ -r "/proc/$pid/cmdline" ]] || return 0
cmdline=$(tr '\0' ' ' < "/proc/$pid/cmdline")
[[ "$cmdline" == *redis-server* && "$cmdline" == *":$port"* ]] || return 1
kill -TERM "$pid"
for _ in {1..50}; do kill -0 "$pid" 2>/dev/null || return 0; sleep 0.1; done
return 1
}
cleanup_processes() {
validate_owner
stop_owned noeviction 16391
stop_owned allkeys 16392
stop_owned volatile 16393
}
trap cleanup_processes EXIT
Next, launch one instance for each policy. Persistence is disabled so the lab cannot be mistaken for a durable configuration. Each instance measures its own startup footprint and then sets maxmemory to that baseline plus exactly 2 MiB. That normalizes headroom without assuming that Redis overhead is identical across hosts.
start_instance() {
local name=$1 port=$2 policy=$3
install -d -m 0700 "$lab_root/$name"
cat > "$lab_root/$name/redis.conf" <<EOF
bind 127.0.0.1
protected-mode yes
port $port
dir $lab_root/$name
dbfilename disabled.rdb
appendonly no
save ""
daemonize no
logfile $lab_root/$name/redis.log
pidfile $lab_root/$name/redis.pid
maxmemory-policy $policy
EOF
redis-server "$lab_root/$name/redis.conf" >"$lab_root/$name/stdout" 2>"$lab_root/$name/stderr" &
printf '%s\n' "$!" > "$lab_root/$name/redis.pid"
for _ in {1..50}; do
redis-cli -h 127.0.0.1 -p "$port" --raw PING 2>/dev/null | grep -qx PONG && break
sleep 0.1
done
redis-cli -h 127.0.0.1 -p "$port" --raw PING | grep -qx PONG
local baseline limit
baseline=$(redis-cli -p "$port" --raw INFO memory | awk -F: '$1=="used_memory" {gsub("\\r", "", $2); print $2}')
limit=$((baseline + 2097152))
redis-cli -p "$port" --raw CONFIG SET maxmemory "$limit" | grep -qx OK
}
start_instance noeviction 16391 noeviction
start_instance allkeys 16392 allkeys-lru
start_instance volatile 16393 volatile-lru
printf 'redis_version=%s instances=3 payload_bytes=32768\n' \
"$(redis-server --version | sed -n 's/.*v=\([^ ]*\).*/\1/p')"
CONFIG SET is intentionally temporary here. Production configuration must live in the service’s supported persistent configuration or managed-service control plane. A restart that silently returns to a provider default changes the application contract.
Under noeviction, the control writes a small protected receipt, fills memory with unique 32 KiB values, and stops at the first non-OK result. It then reads the old receipt, checks PING, inspects evicted_keys, and reads rejected SET calls from INFO commandstats.
redis-cli -p 16391 --raw SET protected:receipt keep | grep -qx OK
noeviction_accepted=0
noeviction_error=''
for i in $(seq 1 400); do
response=$(redis-cli -p 16391 --raw -x SET "cache:noeviction:$i" < "$lab_root/value.bin")
if [[ "$response" == OK ]]; then
noeviction_accepted=$((noeviction_accepted + 1))
else
noeviction_error=$response
break
fi
done
noeviction_evicted=$(redis-cli -p 16391 --raw INFO stats | awk -F: '$1=="evicted_keys" {gsub("\\r", "", $2); print $2}')
noeviction_rejected=$(redis-cli -p 16391 --raw INFO commandstats | awk -F'[=,]' '$1=="cmdstat_set:calls" {for(i=1;i<NF;i++) if($i=="rejected_calls") print $(i+1)}')
noeviction_read=$(redis-cli -p 16391 --raw GET protected:receipt)
noeviction_ping=$(redis-cli -p 16391 --raw PING)
[[ "$noeviction_error" == OOM* && "$noeviction_evicted" == 0 ]]
[[ "$noeviction_rejected" -ge 1 && "$noeviction_read" == keep && "$noeviction_ping" == PONG ]]
This is the sharpest reason not to use liveness as write readiness. The process is responsive, existing reads work, and PING is green, yet a memory-growing command is rejected. Application error handling, write-path monitoring, and capacity alerts must own that state.
noeviction also does not make Redis durable. A process crash, host loss, disabled persistence, or an untested restore can still lose keys. If the workload relies on append-only persistence, repairing a truncated Redis AOF and proving which keys survived is a separate recovery problem. Memory policy answers what happens at the ceiling; persistence answers what can return after failure.
For allkeys-lru, the control places one pre-fill key, then submits all 400 payload writes. Acceptance requires every SET to succeed, a positive evicted_keys count, fewer live keys than admitted writes, and zero rejected calls. Whether the pre-fill key survives is intentionally not an assertion: approximate LRU and access history determine victims.
redis-cli -p 16392 --raw SET control:before-fill present | grep -qx OK
allkeys_accepted=0
for i in $(seq 1 400); do
response=$(redis-cli -p 16392 --raw -x SET "cache:allkeys:$i" < "$lab_root/value.bin")
[[ "$response" == OK ]]
allkeys_accepted=$((allkeys_accepted + 1))
done
allkeys_evicted=$(redis-cli -p 16392 --raw INFO stats | awk -F: '$1=="evicted_keys" {gsub("\\r", "", $2); print $2}')
allkeys_dbsize=$(redis-cli -p 16392 --raw DBSIZE)
allkeys_rejected=$(redis-cli -p 16392 --raw INFO commandstats | awk -F'[=,]' '$1=="cmdstat_set:calls" {for(i=1;i<NF;i++) if($i=="rejected_calls") print $(i+1)}')
[[ "$allkeys_accepted" == 400 && "$allkeys_evicted" -gt 0 ]]
[[ "$allkeys_dbsize" -lt "$allkeys_accepted" && "${allkeys_rejected:-0}" == 0 ]]
Continued writes are not free capacity. They are a stream of replacement decisions. Watch evicted_keys as a rate, not only a lifetime total, and pair it with hit ratio, backend latency, backend errors, and refill cost. A cache that evicts aggressively can overload the source it was meant to protect.
Memory use also includes more than the logical bytes in key values. Allocator fragmentation, replication buffers, persistence buffers, client output buffers, metadata, and implementation overhead affect headroom. The Redis documentation explains that some buffers are excluded from the eviction calculation to avoid feedback loops. The 2 MiB lab margin is therefore a controlled comparison, not advice to set production maxmemory two megabytes above an idle process.
For a database with persistence, fork behavior adds another budget. Measure Redis fork pause and copy-on-write tail before treating the eviction ceiling as the host’s whole RAM allowance. Host free memory, swap behavior, replica buffers, and a tested peak write rate belong in the sizing decision.
volatile-lru is often described as a safe compromise because it protects keys without expiry. The missing operational clause is that the policy can evict only keys that have a TTL. When that eligible pool is empty, another growing write is rejected even though the policy name contains “LRU.”
In this control, Redis receives one persistent receipt, 30 expiring payloads, and then persistent payloads until the first error. The result is accepted only when evictions occurred, every TTL test key disappeared, the protected persistent key remained, and the final write returned OOM.
redis-cli -p 16393 --raw SET durable:receipt keep | grep -qx OK
for i in $(seq 1 30); do
redis-cli -p 16393 --raw -x SETEX "ttl:volatile:$i" 3600 < "$lab_root/value.bin" | grep -qx OK
done
volatile_accepted=0
volatile_error=''
for i in $(seq 1 400); do
response=$(redis-cli -p 16393 --raw -x SET "persistent:volatile:$i" < "$lab_root/value.bin")
if [[ "$response" == OK ]]; then
volatile_accepted=$((volatile_accepted + 1))
else
volatile_error=$response
break
fi
done
volatile_evicted=$(redis-cli -p 16393 --raw INFO stats | awk -F: '$1=="evicted_keys" {gsub("\\r", "", $2); print $2}')
volatile_ttl_live=$(redis-cli -p 16393 --raw --scan --pattern 'ttl:volatile:*' | wc -l)
volatile_read=$(redis-cli -p 16393 --raw GET durable:receipt)
[[ "$volatile_error" == OOM* && "$volatile_evicted" -gt 0 ]]
[[ "$volatile_ttl_live" == 0 && "$volatile_read" == keep ]]
This mixed contract is harder to reason about than separating durable state and disposable cache into different instances. Every producer must set expiry correctly, every persistent key consumes non-evictable budget, and the error mode changes as the eligible pool drains. A namespace convention cannot enforce that ownership by itself.
If one Redis endpoint serves multiple WordPress sites or workloads, policy is only one boundary. Give each WordPress site a flush-safe Redis cache before assuming eviction policy prevents one tenant’s flush or prefix collision from affecting another. Separate endpoints or databases may still be needed when eviction, persistence, security, or maintenance ownership differs.
This executed receipt is representative of the tested environment. Absolute write counts will move with Redis version, allocator, payload, metadata, and baseline memory. The decision-relevant invariants are the final error, eviction count, rejected-call count, eligible TTL pool, retained protected value, and PING response.
redis_version=8.0.2 instances=3 payload_bytes=32768
noeviction writes_ok=48 evicted=0 rejected=1 ping=PONG protected=keep final=OOM
allkeys_lru writes_ok=400 evicted=353 keys_remaining=48 rejected=0
volatile_lru persistent_writes_ok=48 evicted=30 ttl_keys_remaining=0 protected=keep final=OOM
decision_receipt=noeviction_rejects allkeys_evicts volatile_exhausts_eligible_pool
cleanup=complete path_absent=yes ports_closed=yes
Accept the comparison only when noeviction rejects a write without evicting and still returns the protected value plus PONG; allkeys-lru accepts every attempted write while evicted_keys increases and the live key count stays below admitted writes; and volatile-lru removes every eligible TTL test key, preserves the persistent receipt, then returns OOM. Also require marker removal and all three ports to be closed. A PONG, a single successful SET, or a configured policy name alone does not satisfy those criteria.
Choose allkeys-lru only when every key is disposable and a miss has a measured, acceptable refill path. Choose noeviction when existing keys must not be deleted automatically and callers are built to surface, retry, shed, or route rejected writes safely. Choose a volatile policy only when TTL-bearing keys are a deliberately managed eligible class and the team accepts rejection after that class is exhausted.
Migration needs its own contract. During a single-writer Redis-to-Valkey migration, match memory settings and policy on the destination before cutover, then compare rejection, eviction, and TTL behavior rather than assuming protocol compatibility implies identical capacity outcomes.
For production, record CONFIG GET maxmemory, CONFIG GET maxmemory-policy, INFO memory, INFO stats, and relevant INFO commandstats from the actual endpoint. Managed services may restrict configuration names, reserve memory, or apply settings through a control plane. Google Memorystore publishes its supported Redis policy and memory settings, while IBM distinguishes a temporary runtime change from the deployment configuration that persists a cache policy. Microsoft’s current memory-management practices also emphasize reserved headroom, although that page now carries a retirement notice for Azure Cache for Redis. Benchmark with representative key sizes, TTL distribution, hot-set access, replicas, persistence, and backend refill capacity.
If the application needs root-level control over independent Redis processes, persistent configuration, memory telemetry, and a safe rehearsal host, compare those requirements with VPS hosting with full server control. Infrastructure access enables the test and the chosen layout; it does not decide which keys are safe to evict.
No. PING proves the server can respond to that command. In the reproduced noeviction case, PING returned PONG, an existing key remained readable, and the next memory-growing SET returned OOM. Monitor the real write path and rejected calls.
It deletes keys by design. For a pure cache with an authoritative source, that is cache replacement rather than business-data loss. If Redis contains the only copy of any value, allkeys-lru can delete state the application cannot reconstruct and is the wrong ownership contract.
It can evict only keys with expiry. When no TTL keys exist—or after all eligible keys have been removed—Redis has no candidate and rejects memory-growing commands. The policy does not convert persistent keys into eviction candidates.
Usually not. Redis and the host need headroom for allocators, replication, persistence, clients, the operating system, and workload spikes. Measure on the real topology and respect managed-service reservation rules instead of copying the lab margin.
Runtime CONFIG SET changes should not be treated as durable configuration. Persist the choice through the deployment’s supported configuration file, automation, or provider control plane, then restart a safe instance and verify the effective values again.
Read evicted_keys, rejected command calls, hit and miss behavior, used_memory, fragmentation, latency, and backend load together. The correct signal depends on the contract: evictions may be expected for a cache, while any eviction is a defect for non-reconstructable state.
Cleanup stops only PIDs whose command lines match the expected Redis process and port, removes the exact marker-owned directory, and proves the ports are closed. It does not run FLUSHALL, stop a system service, or delete an unfamiliar Redis directory.
printf 'noeviction writes_ok=%s evicted=%s rejected=%s ping=%s protected=%s final=%s\n' \
"$noeviction_accepted" "$noeviction_evicted" "$noeviction_rejected" "$noeviction_ping" "$noeviction_read" "${noeviction_error%% *}"
printf 'allkeys_lru writes_ok=%s evicted=%s keys_remaining=%s rejected=%s\n' \
"$allkeys_accepted" "$allkeys_evicted" "$allkeys_dbsize" "${allkeys_rejected:-0}"
printf 'volatile_lru persistent_writes_ok=%s evicted=%s ttl_keys_remaining=%s protected=%s final=%s\n' \
"$volatile_accepted" "$volatile_evicted" "$volatile_ttl_live" "$volatile_read" "${volatile_error%% *}"
printf 'decision_receipt=noeviction_rejects allkeys_evicts volatile_exhausts_eligible_pool\n'
cleanup_processes
trap - EXIT
validate_owner
find "$lab_root" -depth -delete
[[ ! -e "$lab_root" ]]
for port in "${ports[@]}"; do ! ss -H -ltn "sport = :$port" | grep -q .; done
printf 'cleanup=complete path_absent=yes ports_closed=yes\n'
If any assertion differs, stop only the marker-recorded lab PIDs, keep production unchanged, and inspect the per-instance log plus effective CONFIG GET values before retrying. For a production policy change, save the current persistent configuration and provider setting, change one non-critical instance first, replay representative reads and writes, and restore the previous policy and memory ceiling through the same configuration owner if rejection, eviction, latency, or backend load breaches the agreed threshold. Never use FLUSHALL or delete an unknown data directory as rollback.