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) Source:https://dev.to/nicholas1991/redis-memory-full-and-keys-evicted-a-java-backend-checklist-of-4-spots-2o5iRelated posts
Google will let schools disable its AI tools, but not without sacrificing Search
Ministable: Live on Base testnet
The First 30 Days of a Rescue Project: How to Save a Software Build That Is Going Off the Rails
Snowflake's Always-On Bots Want to Work While You're Away — Explained Honestly
I built 26 PDF and image tools that run entirely on your device
