Skip to main content

Redis TTL, Key Expiry & Memory Management

Understanding how Redis expires keys and manages memory under pressure is essential for designing reliable, cost-effective Redis deployments.


Setting TTLโ€‹

# Set with TTL in one command (preferred โ€” atomic)
SET session:abc123 "user-data" EX 3600 # Expire in 3600 seconds
SET session:abc123 "user-data" PX 3600000 # Expire in 3600000 milliseconds
SET session:abc123 "user-data" EXAT 1700000000 # Expire at Unix timestamp (seconds)
SET session:abc123 "user-data" PXAT 1700000000000 # Expire at Unix timestamp (ms)

# Add TTL to existing key
EXPIRE key 3600 # Seconds
PEXPIRE key 3600000 # Milliseconds
EXPIREAT key 1700000000 # Unix timestamp (seconds)
PEXPIREAT key 1700000000000 # Unix timestamp (ms)

# Inspect TTL
TTL key # Returns seconds remaining; -1 = no expiry; -2 = key doesn't exist
PTTL key # Returns milliseconds remaining

# Remove TTL (make key persistent)
PERSIST key

# Redis 7.0+ EXPIRETIME: get exact expiry timestamp
EXPIRETIME key # Returns Unix timestamp when key will expire

How Redis Expires Keysโ€‹

Redis uses a hybrid expiry strategy โ€” it does NOT check every key for expiry on every access:

1. Lazy Expiry (Passive)โ€‹

When a key is accessed, Redis checks if it's expired and deletes it on the spot:

Client: GET expired-key
Redis: 1. Check if key has passed TTL โ†’ YES, it's expired
2. Delete the key
3. Return (nil)

Problem: Lazy expiry alone means expired keys continue consuming memory until they're accessed.

2. Active Expiry (Periodic)โ€‹

Every 100ms, Redis runs a background job that:

  1. Samples 20 random keys with TTLs set
  2. Deletes all expired keys from the sample
  3. If >25% of sampled keys were expired โ†’ repeat immediately
  4. Stop when <25% of sampled keys are expired or time budget exhausted
Active sampling cycle:
Sample 20 random keys with TTL
Delete expired ones
If expired rate > 25%:
โ†’ Run another cycle (up to ~25% of time budget)
Else:
โ†’ Wait until next 100ms cycle

Why this matters: A key with EXPIRE 300 is not guaranteed to be deleted exactly at 300 seconds. Under memory pressure or many expiring keys, deletion can be delayed by 0โ€“200ms typically, sometimes more.

Active Expiry Budgetโ€‹

Redis limits active expiry to prevent blocking:

maxmemory-expire-cycle = ACTIVE_EXPIRE_CYCLE_FAST # 1ms max per cycle (default)
maxmemory-expire-cycle = ACTIVE_EXPIRE_CYCLE_SLOW # 25% of CPU time

Memory Eviction Policiesโ€‹

When Redis exceeds maxmemory, it must evict keys. The policy is configured via maxmemory-policy:

maxmemory 2gb
maxmemory-policy allkeys-lru
PolicyScopeAlgorithmUse Case
noevictionAllReject writes with OOM errorDB use (no data loss)
allkeys-lruAll keysEvict least recently usedGeneral cache (recommend)
allkeys-lfuAll keysEvict least frequently usedNon-uniform access patterns
allkeys-randomAll keysRandom evictionUniform access patterns
volatile-lruKeys with TTLEvict LRU among keys with TTLMixed DB + cache
volatile-lfuKeys with TTLEvict LFU among keys with TTLMixed DB + cache
volatile-randomKeys with TTLRandom among keys with TTLSimple mixed use
volatile-ttlKeys with TTLEvict keys closest to expiryPrioritize short-lived keys

LRU vs LFUโ€‹

LRU (Least Recently Used):

  • Evicts the key that was accessed least recently
  • Problem: a key accessed millions of times but not in the last minute gets evicted

LFU (Least Frequently Used):

  • Counts access frequency with decay over time
  • Better for cache with clear "hot" and "cold" data
  • Available since Redis 4.0
# Redis uses approximate LRU/LFU (not exact) for memory efficiency
maxmemory-samples 5 # Sample 5 keys to pick eviction candidate (higher = more accurate)
maxmemory-samples 10 # Better approximation, slightly more CPU

Eviction Policy Decision Guideโ€‹

Is Redis used purely as a cache (no persistence needed)?
โ†’ allkeys-lru or allkeys-lfu

Is Redis a primary store with some cached keys?
โ†’ volatile-lru or volatile-lfu (only evict TTL keys, protect persistent data)

Is OOM rejection acceptable (you want to know when you're over capacity)?
โ†’ noeviction (forces application-level capacity management)

Uniform access patterns (all keys equally likely)?
โ†’ allkeys-random (fastest, no tracking overhead)

Spring Data Redis: TTL Examplesโ€‹

Setting TTL with ValueOperationsโ€‹

@Service
public class SessionService {

@Autowired
private RedisTemplate<String, Object> redisTemplate;

private static final Duration SESSION_TTL = Duration.ofHours(1);

public void saveSession(String sessionId, Object data) {
ValueOperations<String, Object> ops = redisTemplate.opsForValue();
ops.set("session:" + sessionId, data, SESSION_TTL);
}

public Object getSession(String sessionId) {
return redisTemplate.opsForValue().get("session:" + sessionId);
}

public void extendSession(String sessionId) {
redisTemplate.expire("session:" + sessionId, SESSION_TTL);
}
}

Using @Cacheable with TTL via RedisCacheConfigurationโ€‹

@Configuration
@EnableCaching
public class CacheConfig {

@Bean
public RedisCacheManager cacheManager(RedisConnectionFactory factory) {
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration
.defaultCacheConfig()
.entryTtl(Duration.ofMinutes(10))
.disableCachingNullValues()
.serializeValuesWith(
RedisSerializationContext.SerializationPair
.fromSerializer(new GenericJackson2JsonRedisSerializer())
);

Map<String, RedisCacheConfiguration> cacheConfigs = new HashMap<>();
// Different TTLs per cache
cacheConfigs.put("products", defaultConfig.entryTtl(Duration.ofHours(1)));
cacheConfigs.put("sessions", defaultConfig.entryTtl(Duration.ofHours(24)));
cacheConfigs.put("rateLimits", defaultConfig.entryTtl(Duration.ofMinutes(1)));

return RedisCacheManager.builder(factory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(cacheConfigs)
.build();
}
}

Common TTL Patternsโ€‹

Sliding Expirationโ€‹

Reset TTL each time a key is accessed (extends session on activity):

public Object getWithSliding(String key, Duration ttl) {
Object value = redisTemplate.opsForValue().get(key);
if (value != null) {
redisTemplate.expire(key, ttl); // refresh TTL
}
return value;
}

One-Time Access Tokenโ€‹

Delete the key immediately after reading:

public String consumeOtp(String phone) {
String key = "otp:" + phone;
// GETDEL: atomic get + delete (Redis 6.2+)
return (String) redisTemplate.execute(
(RedisCallback<String>) conn ->
conn.stringCommands().getDel(key.getBytes())
);
}

Leaky Bucket / Rate Limit with TTLโ€‹

public boolean isAllowed(String userId, int maxRequests) {
String key = "rate:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count == 1) {
// First request โ€” set window TTL
redisTemplate.expire(key, Duration.ofMinutes(1));
}
return count <= maxRequests;
}

TTL Antipatterns and Gotchasโ€‹

1. TTL Reset on Writeโ€‹

SET key "value" EX 3600 # TTL = 3600s
# ... 1800 seconds later ...
SET key "new-value" # โŒ TTL is REMOVED! key is now persistent
# Fix: always re-set TTL on update
SET key "new-value" KEEPTTL # Redis 6.0+: preserve existing TTL
# or
SET key "new-value" EX 3600 # Explicitly re-apply TTL

2. Stampede Attack on TTLโ€‹

When many keys with the same TTL expire simultaneously, all clients make requests to the backend at once โ€” overwhelming the database.

00:00:00 โ€” SET product:* EX 3600 (1000 products cached)
01:00:00 โ€” All 1000 expire simultaneously
01:00:00 โ€” 1000 threads all hit the DB at once!

Fix: Jittered TTL

import random

def cache_product(product_id, data):
base_ttl = 3600
jitter = random.randint(0, 300) # 0โ€“5 min jitter
redis.set(f"product:{product_id}", data, ex=base_ttl + jitter)

3. EXPIRE on Non-Existent Keyโ€‹

EXPIRE nonexistent-key 3600 # Returns 0 โ€” key doesn't exist (no error, silently fails)
# Always verify the key exists before applying EXPIRE, or use SET ... EX

4. Volatile Policy with No TTL Keysโ€‹

maxmemory-policy volatile-lru
# If all keys have no TTL โ†’ Redis CANNOT evict anything โ†’ OOM error on writes
# Always ensure cached keys have TTLs when using volatile-* policy

Memory Optimization Techniquesโ€‹

1. Object Sharingโ€‹

Redis shares integer objects from 0 to 9999 โ€” no memory allocation needed:

SET a 1000 # Points to shared integer object
SET b 1000 # Points to SAME shared integer object
# Memory cost: 1 object, not 2

2. Compact Encodingsโ€‹

Keep collections within encoding thresholds to use listpack:

# Good: listpack (compact array)
HSET user:1 name "Alice" age "30" # 2 fields < 128 โ†’ listpack

# Bad: full hashtable
HSET user:1 f1 v1 f2 v2 ... f200 v200 # 200 fields โ†’ hashtable (much larger)

3. Key Compressionโ€‹

# Long key names waste memory
SET user:profile:[email protected]:settings ... # 43-byte key

# Shorter key names
SET up:12345:s ... # 10-byte key (use numeric IDs, not emails)

4. Memory Analysisโ€‹

MEMORY USAGE key # Memory used by one key
MEMORY DOCTOR # Redis's memory health diagnosis
MEMORY STATS # Detailed memory statistics

# Find biggest keys (scan-based, safe for production)
redis-cli --bigkeys # CLI tool โ€” finds top keys by memory

# Object encoding inspection
OBJECT ENCODING key # See ziplist, hashtable, intset, etc.
OBJECT FREQ key # LFU frequency counter
OBJECT IDLETIME key # Seconds since last access (for LRU)

Redis Keyspace Notificationsโ€‹

Subscribe to expiry and other key events:

# Enable in redis.conf
notify-keyspace-events Ex # E=keyevent, x=expiry events
notify-keyspace-events KEA # All events

# Subscribe to expired key events
SUBSCRIBE __keyevent@0__:expired

# Subscriber receives: key name when it expires
# Use case: triggers on session expiry, cleanup tasks, delayed processing
// Spring Data Redis keyspace notification listener
@Component
public class KeyExpirationListener extends KeyExpirationEventMessageListener {
public KeyExpirationListener(RedisMessageListenerContainer container) {
super(container);
}

@Override
public void onMessage(Message message, byte[] pattern) {
String expiredKey = message.toString();
log.info("Key expired: {}", expiredKey);
// Trigger cleanup, send notification, etc.
}
}

Caveat: Keyspace notifications use Pub/Sub โ€” they are at-most-once. If Redis is under load, some expiry notifications may be dropped.