Wednesday afternoon, cache hit rate dropped from 98% to 62%, and database QPS quintupled. Ops checked around and said: Redis memory is maxed out, some keys were evicted, it's normal behavior. But the keys that got evicted happened to be login sessions, and users were collectively kicked offline. Memory full means eviction—sounds perfectly reasonable. The problem is which keys get evicted and why those. This article walks through Redis memory eviction from policy configuration down to kernel behavior, and throws in a set of monitoring code you can run directly.
I. What Exactly Is This Problem
Conclusion: Redis deleting keys after memory is maxed out is not a bug—it's the maxmemory-policy setting doing its job by the rules. Pick the wrong rule, and it won't delete what you wanted deleted.
Redis is an in-memory database—all data lives in memory. Give it a limit, and it won't eat memory indefinitely. That limit is called maxmemory. Whether writes can still go through once you hit the limit, and who gets deleted when they can't, all depends on maxmemory-policy.
Many teams, to "keep things simple" at launch, just copy a chunk of config:
maxmemory 8gb
maxmemory-policy allkeys-lru
allkeys-lru means: when memory runs short, pick the least recently accessed key from all keys and delete it. Note the word "all." Sessions, product cache, rate-limit counters, distributed locks, feature flags—all treated the same. As long as it's the least recently accessed, it gets killed first.
Config-type data suffers the most. Take a feature flag stored in Redis: once written, nobody reads it again. An hour later memory gets tight, and it's the first to go. Then the business logic reads a null value, falls through to the default branch, and the feature quietly turns off—with no error whatsoever.
Redis supports 8 eviction policies in total. The difference comes down to two things: which pool to pick from and by what rule to pick.
PolicyCandidate ScopeSelection RulenoevictionNo evictionWrites return an errorallkeys-lruAll keysLeast recently accessedallkeys-lfuAll keysLowest access frequencyallkeys-randomAll keysRandomvolatile-lruKeys with TTL setLeast recently accessedvolatile-lfuKeys with TTL setLowest access frequencyvolatile-randomKeys with TTL setRandomvolatile-ttlKeys with TTL setShortest remaining TTL
The difference between allkeys-* and volatile-* is fatal. The former deletes your permanent config right along with everything else; the latter only touches data you explicitly marked as "expirable."
There's one more easily overlooked point: the default policy is noeviction. Instances that never configured maxmemory-policy won't delete anything when memory fills up—all write commands return OOM command not allowed when used memory > 'maxmemory'. Reads work fine; writes all fail. Some people treat this as "Redis is stuck," when in reality it's explicitly rejecting you.
To determine whether an instance has ever evicted anything, one metric is enough:
redis-cli info stats | grep evicted_keys
This number is the cumulative count of keys evicted since process start—it only goes up. It's a cumulative value, not a current value. It resets to zero on restart, so don't use it for business analytics. What you actually need to watch is its rate of growth—tens of thousands in half an hour means memory is under sustained pressure, not an occasional blip.
II. What's Really Happening Under the Hood
Conclusion: Redis's memory limit is "effective memory," not "physical memory"; eviction is a check performed before each command execution; LRU is sampled estimation, not exact ordering.
Where Memory Actually Goes
There are a few key fields in INFO memory, and many people only look at the first one:
-
used_memory: data memory as counted by Redis itself, including the data and the overhead of structures managing that data -
used_memory_rss: actual process usage as seen by the OS, including memory fragmentation -
used_memory_dataset: pure data portion, minus management overhead -
mem_fragmentation_ratio:used_memory_rss / used_memory
A concrete example. You store 1 million string keys, each key averaging 20 bytes and each value averaging 80 bytes—the data itself is roughly 100MB. But Redis attaches a dictEntry and a redisObject to every key, plus jemalloc's fixed-size-bin allocation leaves holes. Actual used_memory often lands around 150MB—50% inflation is normal.
So for the question "My data is only 100MB, why is 8G of memory full?"—the answer usually lies in four areas: management overhead, big keys, replication buffers, and client output buffers—not the data itself.
Also watch the fragmentation ratio:
-
mem_fragmentation_ratiobetween 1.0 and 1.5: normal - Greater than 1.8: severe fragmentation, consider enabling
activedefrag - Less than 1: some memory has been swapped out, and performance will fall off a cliff
Eviction Happens Before Command Execution
Redis processes commands single-threaded (referring to the main command execution thread). For every command that might increase memory—SET, LPUSH, HSET—before execution it checks once: has current used_memory exceeded maxmemory?
Once it exceeds the limit, it enters the eviction process, deleting keys in a loop until it drops below the limit or can't delete any more. This loop has a cap; it won't delete indefinitely, avoiding a single command getting stuck for too long.
The key point is here: this check is triggered per command, not by a background scheduled job. That is, if there are no writes for a while, memory can exceed the limit without being cleaned up. Conversely, a large batch-write script may repeatedly trigger eviction during execution.
LRU is sampling, not sorting
Textbook LRU maintains a full access linked list, which is costly in both memory and CPU. Redis doesn't do that.
It records a 24-bit timestamp for each key (the lru field, reusing space in redisObject). When evicting, it randomly samples maxmemory-samples keys, 5 by default, and deletes the least recently accessed among those 5.
Sampling 5 is obviously not accurate. But Redis compensates in two ways:
- Maintain a 16-element candidate pool (pool), compare each sample against the pool, and keep the "older" ones
- Eviction is continuous; if one pass isn't accurate, a few more rounds will make it accurate
Set maxmemory-samples to 10 and accuracy approaches true LRU, at the cost of a bit more CPU per eviction. In production, choose between 5 and 10; don't set it too high.
LFU is a different mechanism. It gives each key an 8-bit counter; each access increments it probabilistically, while decaying keys that haven't been accessed for a long time (lfu-decay-time, default 1 minute). The 8-bit max is 255, and it uses logarithmic growth, so the first few accesses increase quickly and later ones increase slowly.
LFU suits scenarios where "hot spots are stable," such as homepage recommendation slots. LRU suits scenarios where "hot spots drift," such as breaking news. Choosing the wrong one won't crash anything, but hit rate will be noticeably worse.
The nastiest case with volatile policies
Suppose you set the policy to volatile-lru, and in your business code a batch of keys is missing an expiration time.
When memory is full, Redis looks for "keys with a TTL" to evict. If it finds them, it evicts normally. What if it can't find any?
The answer is: it treats that as nothing to evict, and write commands directly return an OOM error.
This is exactly the root cause of many cases where "volatile-lru is set but writes still fail." The policy isn't wrong; the data doesn't meet the policy's precondition. The volatile-* family has a hard precondition: every key that should be eligible for eviction must have a TTL.
Deleting a big key can block the main thread
Evicting a key isn't just removing it from the dictionary; it also has to free the memory it occupies. If the key is a Hash with 5 million elements, freeing it can take tens of milliseconds.
Redis 4.0 and later provides asynchronous freeing:
lazyfree-lazy-eviction yes
Once enabled, eviction only performs a logical delete, and the actual memory freeing is handed off to a background thread. The cost is that memory reclamation is delayed, and used_memory will briefly be higher than the actual value.
Large keys should be split up in the first place. A single value over 10KB is a warning sign, and over 1MB is basically a design problem. Use MEMORY USAGE key to see the actual usage of a single key, and redis-cli --bigkeys to scan the entire database.
Eviction under primary-replica architecture
Only the primary node makes eviction decisions. After the primary deletes a key, it generates a DEL command and syncs it to the replicas.
Replicas do not evict on their own. So if a replica's maxmemory is set the same as the primary's, the replica's memory may exceed the limit. This is also why replicas are usually recommended to be configured with maxmemory-policy noeviction, so they are only responsible for copying the primary's delete actions and do not make decisions themselves.
What should maxmemory be set to
Do not set it to 100% of physical memory.
There are several areas that must be left room for:
- Replication buffer (
repl-backlog, 1MB by default, but it can spike during a full sync) - AOF rewrite buffer
- Client output buffer (Redis 7 and later has
maxmemory-clientsto limit it separately)
Rule of thumb: set maxmemory to 60%–75% of physical memory. If the machine runs only Redis, 75%; if it is co-located with other services, 60% or even lower. The remaining space will also be consumed in no small part by the child process forked during BGSAVE doing copy-on-write.
3. Hands-on: build an observable cache step by step
Conclusion: data such as sessions must go through volatile-lru + enforced TTL, and there must be code-level eviction monitoring, so you do not have to wait until users are logged out to discover the problem.
The project below is called redis-memory-guard and contains three things: a session cache with TTL, a monitoring component that collects Redis memory metrics every 30 seconds, and an endpoint that exposes the metrics.
Project structure
redis-memory-guard
├── docker-compose.yml
├── pom.xml
└── src/main
├── java/com/example/redisguard
│ ├── RedisMemoryGuardApplication.java
│ ├── monitor
│ │ ├── RedisMemoryMonitor.java
│ │ ├── RedisMemorySnapshot.java
│ │ └── RedisMonitorController.java
│ └── session
│ ├── SessionCacheService.java
│ └── SessionController.java
└── resources/application.yml
Start a deliberately misconfigured Redis
First, start an instance with the kind of wrong configuration you are preparing to troubleshoot: a 128MB limit, allkeys-lru, to make it easy to reproduce the problem.
services:
redis:
image: redis:8
container_name: redis-eviction-demo
command:
- redis-server
- --maxmemory
- 128mb
- --maxmemory-policy
- allkeys-lru
ports:
- "6379:6379"
Start it:
docker compose up -d
pom.xml
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0"
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 https://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>
<parent>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-parent</artifactId>
<version>4.1.2</version>
<relativePath/>
</parent>
<groupId>com.example</groupId>
<artifactId>redis-memory-guard</artifactId>
<version>1.0.0</version>
<name>redis-memory-guard</name>
<description>Redis 内存淘汰监控示例</description>
<properties>
<java.version>21</java.version>
</properties>
<dependencies>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-web</artifactId>
<version>4.1.2</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-data-redis</artifactId>
<version>4.1.2</version>
</dependency>
<!-- Lettuce 连接池需要,不引这个 pool 配置不生效 -->
<dependency>
<groupId>org.apache.commons</groupId>
<artifactId>commons-pool2</artifactId>
<version>2.13.1</version>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<version>4.1.2</version>
<scope>test</scope>
</dependency>
</dependencies>
<build>
<plugins>
<plugin>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-maven-plugin</artifactId>
<version>4.1.2</version>
</plugin>
</plugins>
</build>
</project>
Note the commons-pool2 line. Many people write connection pool parameters in application.yml and find they don't take effect—this dependency is missing. Lettuce uses its own connection pool implementation, and Spring Boot only enables pooling configuration when commons-pool2 is present on the classpath.
application.yml
server:
port: 8080
spring:
application:
name: redis-memory-guard
data:
redis:
host: 127.0.0.1
port: 6379
database: 0
timeout: 2s
lettuce:
pool:
enabled: true
max-active: 16
max-idle: 8
min-idle: 2
max-wait: 1s
timeout: 2s is the command timeout, not the connection timeout. The default is no timeout, and a slow command can hang a request thread indefinitely, so always set it explicitly in production.
Main Class
package com.example.redisguard;
import org.springframework.boot.SpringApplication;
import org.springframework.boot.autoconfigure.SpringBootApplication;
import org.springframework.scheduling.annotation.EnableScheduling;
@SpringBootApplication
@EnableScheduling
public class RedisMemoryGuardApplication {
public static void main(String[] args) {
SpringApplication.run(RedisMemoryGuardApplication.class, args);
}
}
@EnableScheduling is used by the monitoring component's scheduled task. If you miss it, the @Scheduled method will never execute, not a single log line will appear, and you'll think the monitoring code is wrong.
Session Cache Service
package com.example.redisguard.session;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.stereotype.Service;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.concurrent.TimeUnit;
@Service
public class SessionCacheService {
private static final String KEY_PREFIX = "session:";
private static final Duration TTL = Duration.ofMinutes(30);
private final StringRedisTemplate redisTemplate;
public SessionCacheService(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
public void save(String sessionId, String userId, String nickname) {
String key = KEY_PREFIX + sessionId;
Map<String, String> data = new LinkedHashMap<>();
data.put("userId", userId);
data.put("nickname", nickname);
data.put("loginAt", String.valueOf(System.currentTimeMillis()));
redisTemplate.opsForHash().putAll(key, data);
// putAll 不会带过期时间,TTL 必须单独设置
redisTemplate.expire(key, TTL);
}
public Map<String, String> find(String sessionId) {
String key = KEY_PREFIX + sessionId;
Map<Object, Object> raw = redisTemplate.opsForHash().entries(key);
if (raw.isEmpty()) {
return Map.of();
}
Map<String, String> result = new LinkedHashMap<>();
raw.forEach((field, value) -> result.put(String.valueOf(field), String.valueOf(value)));
// 每次读都续期,做成滑动过期
redisTemplate.expire(key, TTL);
return result;
}
public void remove(String sessionId) {
redisTemplate.delete(KEY_PREFIX + sessionId);
}
public long ttlSeconds(String sessionId) {
Long ttl = redisTemplate.getExpire(KEY_PREFIX + sessionId, TimeUnit.SECONDS);
return ttl == null ? -2L : ttl;
}
}
Two details are worth mentioning.
After putAll, calling expire separately is required. Redis's HSET command itself does not include an expiration time, and Spring Data Redis does not fold TTL into the putAll wrapper. If you miss this line, the key becomes permanent, and permanent keys are never evicted under volatile-lru—the cache only grows and never shrinks.
The renewal in find is sliding expiration: each access resets the 30-minute window. It suits sessions where the user stays active. If what you want is "must re-authenticate 30 minutes after login," then don't renew; use absolute expiration.
Session Endpoint
package com.example.redisguard.session;
import org.springframework.http.ResponseEntity;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/session")
public class SessionController {
private final SessionCacheService sessionCacheService;
public SessionController(SessionCacheService sessionCacheService) {
this.sessionCacheService = sessionCacheService;
}
@PostMapping("/{sessionId}")
public Map<String, Object> create(@PathVariable String sessionId,
@RequestParam String userId,
@RequestParam String nickname) {
sessionCacheService.save(sessionId, userId, nickname);
return Map.of(
"sessionId", sessionId,
"ttlSeconds", sessionCacheService.ttlSeconds(sessionId)
);
}
@GetMapping("/{sessionId}")
public ResponseEntity<Map<String, String>> get(@PathVariable String sessionId) {
Map<String, String> session = sessionCacheService.find(sessionId);
if (session.isEmpty()) {
return ResponseEntity.notFound().build();
}
return ResponseEntity.ok(session);
}
@DeleteMapping("/{sessionId}")
public Map<String, Object> remove(@PathVariable String sessionId) {
sessionCacheService.remove(sessionId);
return Map.of("deleted", true);
}
}
Memory Snapshot
package com.example.redisguard.monitor;
public record RedisMemorySnapshot(long usedMemory,
long usedMemoryRss,
long maxMemory,
long evictedKeys,
long evictedDelta,
String policy) {
public static RedisMemorySnapshot empty() {
return new RedisMemorySnapshot(0L, 0L, 0L, 0L, 0L, "unknown");
}
public double usedRatio() {
if (maxMemory <= 0L) {
return 0d;
}
return (double) usedMemory / maxMemory;
}
public double fragmentationRatio() {
if (usedMemory <= 0L) {
return 0d;
}
return (double) usedMemoryRss / usedMemory;
}
public String usedMemoryHuman() {
return human(usedMemory);
}
public String maxMemoryHuman() {
return maxMemory <= 0L ? "未设置" : human(maxMemory);
}
private static String human(long bytes) {
if (bytes < 1024L) {
return bytes + "B";
}
double kb = bytes / 1024.0;
if (kb < 1024.0) {
return String.format("%.1fKB", kb);
}
double mb = kb / 1024.0;
if (mb < 1024.0) {
return String.format("%.1fMB", mb);
}
return String.format("%.2fGB", mb / 1024.0);
}
}
This is a record with immutable fields, suitable for passing between the monitoring thread and HTTP threads. evictedDelta is the "number of evictions added this round," which is much more useful than a cumulative value—a cumulative value only proves that "something was deleted," while a delta tells you that "deletions are happening now."
Monitoring Collection
package com.example.redisguard.monitor;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.data.redis.connection.RedisConnection;
import org.springframework.data.redis.core.RedisCallback;
import org.springframework.data.redis.core.StringRedisTemplate;
import org.springframework.scheduling.annotation.Scheduled;
import org.springframework.stereotype.Component;
import java.util.Properties;
@Component
public class RedisMemoryMonitor {
private static final Logger log = LoggerFactory.getLogger(RedisMemoryMonitor.class);
private final StringRedisTemplate redisTemplate;
private volatile RedisMemorySnapshot latest = RedisMemorySnapshot.empty();
private volatile long lastEvictedKeys = -1L;
public RedisMemoryMonitor(StringRedisTemplate redisTemplate) {
this.redisTemplate = redisTemplate;
}
@Scheduled(fixedDelay = 30_000L)
public void collect() {
RedisMemorySnapshot snapshot = redisTemplate.execute(
(RedisCallback<RedisMemorySnapshot>) this::read);
if (snapshot == null) {
log.warn("读取 Redis INFO 失败,本次采集跳过");
return;
}
latest = snapshot;
if (snapshot.evictedDelta() > 0L) {
log.warn("Redis 正在淘汰 key:本轮新增 {} 个,策略 {},已用 {} / 上限 {},碎片率 {}",
snapshot.evictedDelta(),
snapshot.policy(),
snapshot.usedMemoryHuman(),
snapshot.maxMemoryHuman(),
String.format("%.2f", snapshot.fragmentationRatio()));
}
}
private RedisMemorySnapshot read(RedisConnection connection) {
Properties memory = connection.serverCommands().info("memory");
Properties stats = connection.serverCommands().info("stats");
if (memory == null || stats == null) {
return null;
}
long used = parseLong(memory.getProperty("used_memory"));
long rss = parseLong(memory.getProperty("used_memory_rss"));
long max = parseLong(memory.getProperty("maxmemory"));
long evicted = parseLong(stats.getProperty("evicted_keys"));
String policy = memory.getProperty("maxmemory_policy", "unknown");
long delta;
if (lastEvictedKeys < 0L) {
delta = 0L;
} else if (evicted < lastEvictedKeys) {
// Redis 重启过,计数归零,这一轮整体当作新增
delta = evicted;
} else {
delta = evicted - lastEvictedKeys;
}
lastEvictedKeys = evicted;
return new RedisMemorySnapshot(used, rss, max, evicted, delta, policy);
}
private long parseLong(String value) {
if (value == null || value.isBlank()) {
return 0L;
}
try {
return Long.parseLong(value.trim());
} catch (NumberFormatException e) {
return 0L;
}
}
public RedisMemorySnapshot latest() {
return latest;
}
}
What this code does is simple: every 30 seconds it sends two INFO commands over the Redis protocol and parses the text results into a struct. Using RedisCallback instead of creating a new Jedis connection is to reuse Spring's connection pool and timeout configuration, and to route issues through unified exception handling.
serverCommands().info("memory") returns Properties, and the key names are exactly the field names in the INFO output, all lowercase with underscores. evicted_keys is only in INFO stats, so don't look for it in the memory section.
Metrics Endpoint
package com.example.redisguard.monitor;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RestController;
import java.util.Map;
@RestController
@RequestMapping("/admin/redis")
public class RedisMonitorController {
private final RedisMemoryMonitor redisMemoryMonitor;
public RedisMonitorController(RedisMemoryMonitor redisMemoryMonitor) {
this.redisMemoryMonitor = redisMemoryMonitor;
}
@GetMapping("/memory")
public Map<String, Object> memory() {
RedisMemorySnapshot snapshot = redisMemoryMonitor.latest();
return Map.of(
"usedMemory", snapshot.usedMemoryHuman(),
"maxMemory", snapshot.maxMemoryHuman(),
"usedRatio", String.format("%.2f%%", snapshot.usedRatio() * 100),
"fragmentationRatio", String.format("%.2f", snapshot.fragmentationRatio()),
"policy", snapshot.policy(),
"evictedKeysTotal", snapshot.evictedKeys(),
"evictedKeysLastRound", snapshot.evictedDelta()
);
}
}
Run and Verify
mvn spring-boot:run
Write a session:
curl -X POST "http://localhost:8080/session/s-1001?userId=1001&nickname=lvdongbin"
Returns:
{"sessionId":"s-1001","ttlSeconds":1799}
Read it back:
curl "http://localhost:8080/session/s-1001"
{"userId":"1001","nickname":"lvdongbin","loginAt":"1792934400000"}
Check monitoring:
curl "http://localhost:8080/admin/redis/memory"
{"usedMemory":"1.2MB","maxMemory":"128.0MB","usedRatio":"0.94%","fragmentationRatio":"1.08","policy":"allkeys-lru","evictedKeysTotal":0,"evictedKeysLastRound":0}
To trigger eviction, just flood Redis with data:
docker exec redis-eviction-demo redis-benchmark -n 200000 -r 200000 -d 2048 -t set -q
This command uses Redis's built-in benchmarking tool to write 200,000 random keys, each value around 2KB, which can easily fill up 128MB. Once you push past the limit, check /admin/redis/memory again; evictedKeysLastRound will no longer be 0, and warnings about Redis 正在淘汰 key will also appear in the logs.
The value of this code is not in the monitoring itself, but in turning "eviction is happening" into a number you can receive a signal from. A drop in cache hit rate is lagging; the eviction count is real-time.
4. Pitfalls and Best Practices
Conclusion: 80% of Redis memory problems come from two things: "keys without TTL" and "maxmemory set too high."
Pitfall 1: HSET, LPUSH and then forgetting EXPIRE
Redis expiration is attached to the key, not to fields. If you keep adding fields to a Hash, the key's own TTL will not be reset by writes, nor will it be created automatically.
A real case: a project's user profile cache used opsForHash().putAll(key, map) in the code and never set an expiration. Three months after launch, this Hash had grown to more than 20 million keys and filled the entire instance. When the memory alarm went off, the first things killed by allkeys-lru were login sessions.
The fix is not to add memory, but to add one line, expire, where the write happens. For every method that writes to cache, TTL must be a required parameter and must not have a default value.
Pitfall 2: maxmemory set to 100% of physical memory
What happens when it fills up? used_memory indeed won't exceed the limit, but used_memory_rss will. The child process forked during BGSAVE performs copy-on-write and needs extra physical memory. Linux's OOM Killer will kill the Redis process, leaving a Killed in the logs, with no Redis error message of its own.
More insidious is swap. When physical memory is insufficient, the system swaps Redis pages to disk, and mem_fragmentation_ratio will drop below 1. At this point Redis can still run, but every access to cold data is disk I/O, and latency goes from 0.2 ms to 20 ms. Monitoring shows no errors, and it just feels like "Redis has gotten slower."
Pitfall 3: Looking only at used_memory, not at used_memory_rss
Redis freed 1GB of data, and used_memory drops immediately, but used_memory_rss does not necessarily drop. jemalloc does not always return memory to the operating system.
This does not count as a leak. As long as the fragmentation rate stays within 1.5, there is no need to worry about it. But if used_memory_rss keeps rising while used_memory does not increase, then it is real fragmentation. Enable activedefrag yes, or find an off-peak period and restart once.
Pitfall 4:volatile-lru configured with a bunch of keys without TTL
We covered the mechanism earlier; now let's look at the symptom. It goes like this: memory isn't full, yet write commands return OOM command not allowed. Ops takes one look at maxmemory, sees there's still headroom, and is completely baffled.
One command to troubleshoot:
redis-cli info keyspace
The output has two key columns per db: the total count keys and the number of keys with a TTL expires. If expires is 0 or far smaller than keys, then the volatile-* policy is basically equivalent to noeviction.
Best practices checklist:
- Always use
allkeys-lrufor cache data, and give every key a TTL — belt and suspenders - When co-locating cache with "data that must not be lost," use
volatile-lru, and also run a script periodically to scan for keys without a TTL - Put sessions, verification codes, and rate-limit counters in a separate instance or a separate db — don't let them compete with business cache for memory
- With
maxmemory, leave at least 25% of physical memory for fork, replication buffers, and client buffers - Enable
lazyfree-lazy-eviction yesto avoid blocking the main thread when deleting large keys - Wire the growth rate of
evicted_keysinto alerting, with thresholds set per workload — for example, alert if more than 1000 are added within 5 minutes
The most critical item on the list is this one. The vast majority of Redis memory incidents are not due to a lack of monitoring, but because monitoring only looks at memory usage and not eviction rate.
V. Performance Comparison and Technology Selection
Conclusion: The CPU overhead differences between eviction policies are very small. The key to selection is "whether the data can be lost," not "which one is faster."
Comparison of eviction overhead across different policies:
PolicyExtra overhead per evictionHit rate performanceUse casenoevictionNone (directly rejects writes)Not applicableStoring data that cannot be lost, such as queues and configurationsallkeys-lruSamples 5 keys and compares timestampsGood for hotspot drift scenariosPure cache instancesallkeys-lfuSampling + counter comparisonBetter for stable hotspot scenariosRecommendation slots, leaderboardsallkeys-randomAlmost zeroWorst, not recommendedAlmost never usedvolatile-lruSame as allkeys-lruDepends on TTL coverageMixed deployment of cache and persistent datavolatile-ttlCompares remaining TTLModerateScenarios with clear time-based eviction
A few empirically measured rules of thumb:
maxmemory-samples Adjusting from 5 to 10 improves the hit rate by about 1~3 percentage points, while CPU overhead increases by less than 1%. It is worth tuning.
lazyfree-lazy-eviction Once enabled, the latency of evicting large keys drops from tens of milliseconds to the microsecond level, at the cost of delayed memory reclamation by a few seconds. It is recommended to enable it in high-concurrency write scenarios.
activedefrag Continuously consumes CPU (by default occupying one background thread). Do not enable it when the fragmentation rate is below 1.5.
For the decision order in technology selection, I suggest the following:
- Would losing this data cause a business failure? If yes →
noeviction, use a separate instance. - If not, but it is mixed with other data in the same instance? →
volatile-lru, force all cache keys to have a TTL. - Is the entire instance used as cache? →
allkeys-lru, simple and direct.
Do not choose LFU just to be "more precise." The counter decay parameter in LFU is very difficult to tune. If it is not tuned well, "new hotspots can never get in" because the counters of old keys are too high. Unless you have a clear judgment that hotspots are stable, LRU is more reliable.
VI. Summary
Deleting keys after Redis memory is full is designed behavior, not a failure. The real questions to ask are three: which batch of keys is being deleted, why them, and will critical data be deleted next time.
maxmemory-policy determines the candidate pool. allkeys-* deletes permanent data along with everything else, while volatile-* only touches keys that have a TTL. Choosing volatile-* without setting a TTL on your keys turns the instance into noeviction, and every write request returns an error.
Don't push maxmemory all the way to your physical memory limit. Leave at least 25% for fork, replication buffers, and client buffers; otherwise the OOM killer will make a much harsher decision for you at some ungodly hour.
For monitoring, used_memory only tells you "almost full," while the growth rate of evicted_keys tells you "eviction has already started." Wiring the latter into your alerts is far more useful than staring at memory usage.
At the code level, a TTL should be a required field when writing to the cache, not an optional one. Only if you hold this line does volatile-lru mean anything.
Top comments (0)