Redis for Senior System Architects
A Practical Reference: Concepts → Data Structures → Architecture → Consistency → High Availability → Production Operations
Audience: engineers preparing for Senior / Professional-level system architecture interviews, or teams evaluating Redis for enterprise-scale caching, session, locking, and messaging use cases. Code examples use Spring Data Redis (Spring Boot + Lettuce) as the reference client, since it is the de-facto standard in enterprise Java systems.
Table of Contents
Chapter 1 — Redis Foundations & Core Patterns
- 1.1 What Redis Actually Is
- 1.2 Why Redis Is Fast
- 1.3 Persistence: RDB vs AOF
- 1.4 Core Data Structures
- 1.5 TTL & Expiration
- 1.6 Caching Patterns
- 1.7 Cache Invalidation
- 1.8 Cache Stampede, Penetration, Avalanche
- 1.9 Client & Connection Management
- 1.10 Threading Model
- 1.11 Replication
- 1.12 High Availability: Sentinel
- 1.13 Horizontal Scaling: Redis Cluster
- 1.14 Sentinel vs Cluster
- 1.15 Big Key & Hot Key Problems
- 1.16 Memory & Eviction Policies
- 1.17 Security
- 1.18 Transactions: MULTI / EXEC / WATCH
- 1.19 Distributed Locking
- 1.20 Pub/Sub vs Streams
- 1.21 Redis vs Kafka / RabbitMQ
- 1.22 Serialization
- 1.23 Cache Consistency Strategies
- 1.24 Reference Architecture
- 1.25 Interview Question Bank
- 1.26 Common Interview Traps
- 1.27 Learning Roadmap
Chapter 2 — Professional & Architect Deep Dives
- 2.1 Redis Memory Internals
- 2.2 Production Troubleshooting & Observability
- 2.3 Cluster Failure Scenarios & the Consistency Trade-off
- 2.4 Rate Limiting Patterns
Chapter 3 — Architecture Decisions & Reference
- 3.1 When NOT to Use Redis
- 3.1.1 Don't use Redis as the default source of truth for relational data
- 3.1.2 Don't use Redis when complex relational queries are the main requirement
- 3.1.3 Don't replace a durable event log with Redis Pub/Sub
- 3.1.4 Don't choose Redis when message routing is the primary requirement
- 3.1.5 Don't add Redis simply because "it's faster"
- 3.1.6 Don't use Redis as a cache for data with no rebuild strategy
- 3.2 Redis System Design Cheat Sheet
- 3.3 References
Chapter 1. Redis Foundations & Core Patterns
1.1 What Redis Actually Is
Redis is not "just a cache." It is a data structure server that operates primarily in memory, exposed to clients over a lightweight TCP protocol (RESP).
Common roles Redis plays in an enterprise system:
| Role | Example |
|---|---|
| Cache | Master data (departments, product catalog) |
| Session store | Shared HTTP session across app instances |
| Distributed lock | Prevent duplicate batch job execution |
| Counter / rate limiter | API throttling, OTP attempts |
| Leaderboard | Sales ranking via Sorted Set |
| Pub/Sub | Real-time notification fan-out |
| Durable event log | Redis Streams for async processing |
1.2 Why Redis Is Fast
A shallow answer ("because it uses RAM") is a red flag in a senior interview. The accurate answer combines five factors:
- In-memory data path — most operations never touch disk on the critical path.
- Purpose-built data structures — String, Hash, List, Set, Sorted Set, Stream, Bitmap, HyperLogLog are implemented as efficient in-memory structures (e.g. skip lists for Sorted Set), not generic relational rows.
- Simple access pattern —
GET user:100has no query planning, no join resolution, no execution plan — unlike a relationalSELECT ... JOIN ... WHERE ... ORDER BY. - Low protocol overhead — RESP is a compact binary-safe protocol, cheaper to parse than SQL wire protocols for simple key operations.
- Command execution model — commands are executed by a single-threaded event loop (see §1.10), avoiding lock contention between concurrent commands.
Correct framing for an interview: "Redis is fast for low-latency, key-based access patterns that fit in memory — not universally faster than every database for every workload."
1.3 Persistence: RDB vs AOF
Redis is in-memory, but that does not mean data is volatile by design — persistence is configurable. AOF persistence logs every write operation received by the server, and these operations can be replayed at server startup to reconstruct the original dataset, using the same format as the Redis protocol itself.
RDB (snapshot)
RDB maximizes Redis performance since the only work the parent process does to persist is fork a child process that handles the rest — the parent never performs disk I/O directly, and RDB allows faster restarts with large datasets compared to AOF, though it is not ideal if you need to minimize data loss after an unclean shutdown.
- ✅ Compact file, fast restore, low runtime overhead
- ❌ Data between two snapshots can be lost on crash
AOF (append-only log)
AOF logs are written only after a command executes — Redis does not perform syntax checks before writing, so logging after execution avoids persisting invalid commands, and this does not block the current write; however if the system crashes after execution but before the log write, that write can still be lost. The appendfsync setting controls the durability/performance trade-off: always, everysec (recommended default), or no.
- ✅ Much better durability, tunable via
fsyncpolicy - ❌ Larger file size, slightly higher overhead than RDB
Hybrid (recommended for production)
Most production deployments combine both: RDB snapshots for fast backups and restarts, and AOF for write-by-write durability — when both are present at startup, Redis loads the AOF because it is typically the more complete record.
Interview answer for "RDB or AOF?": "There's no universally correct choice — it depends on the required Recovery Point Objective (RPO) and Recovery Time Objective (RTO). For most enterprise systems, hybrid (RDB + AOF with everysec fsync) is the practical default."
1.4 Core Data Structures
All examples below use Spring Data Redis with RedisTemplate / StringRedisTemplate, which is the standard abstraction in a Spring Boot service.
@Configuration
public class RedisConfig {
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
template.setHashKeySerializer(new StringRedisSerializer());
template.setHashValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
}
String
redisTemplate.opsForValue().set("user:1", "Khanh");
String value = (String) redisTemplate.opsForValue().get("user:1");
Use cases: cache value, token, counter.
Hash — object-like structure
HashOperations<String, String, String> hashOps = redisTemplate.opsForHash();
hashOps.put("user:1", "name", "Khanh");
hashOps.put("user:1", "department", "IT");
user:1
├── name = "Khanh"
└── department = "IT"
List — simple queue / recent-items
redisTemplate.opsForList().leftPush("queue:jobs", "job1");
String job = (String) redisTemplate.opsForList().rightPop("queue:jobs");
Set — unique membership
redisTemplate.opsForSet().add("user:1:roles", "ADMIN", "HR");
Sorted Set — ranking, leaderboards
redisTemplate.opsForZSet().add("sales_ranking", "employeeA", 1_500_000);
redisTemplate.opsForZSet().add("sales_ranking", "employeeB", 1_200_000);
Set<String> top10 = redisTemplate.opsForZSet()
.reverseRange("sales_ranking", 0, 9);
sales_ranking (Sorted Set, ordered by score)
├── Employee A → 1,500,000
├── Employee B → 1,200,000
└── Employee C → 900,000
1.5 TTL & Expiration
redisTemplate.opsForValue().set("otp:123456", "829341", Duration.ofMinutes(5));
Typical uses: OTP, session expiry, cache freshness, soft locks.
SET otp:123456 829341 → key alive
│ (300s pass)
▼
key automatically evicted → GET returns null
A note on "Redis 9/10" and current features
As of mid-2026, there is no Redis 9 or 10 — the current stable line is Redis 8.x (Redis Open Source, AGPLv3, with 8.8+ as the latest minor releases). What people often mean by "Redis 9/10-level" capability is really everything Redis 8 folded into core (previously separate Redis Stack modules), which is genuinely a big jump from Redis 6/7 and worth knowing for a senior interview:
- Hash field TTL — expire individual fields inside a Hash, not just the whole key:
HEXPIRE,HGETEX(get + optionally refresh TTL),HSETEX(set + optionally set TTL),HGETDEL(atomic get-and-delete). Useful for session objects where one field (e.g. a one-time step token) should expire independently of the rest of the session. - Native JSON — document storage with
JSON.SET/JSON.GETand JSONPath queries, no longer a separate module. - Redis Query Engine — secondary indexing and search over Hash/JSON data (full-text, geospatial, vector, aggregations) built into core.
- Vector Sets — a dedicated data type for high-dimensional similarity search (semantic search, RAG, recommendation use cases).
- Probabilistic structures — Bloom filter, Cuckoo filter, Count-min sketch, Top-K, t-digest, all native (directly useful for the Cache Penetration Bloom-filter mitigation described earlier).
- Licensing — Redis relicensed away from BSD in 2024 (RSALv2/SSPL), then returned to open source under AGPLv3 starting with Redis 8.0 in 2025.
None of this changes the fundamentals in §§1.1–1.21 — String/Hash/List/Set/Sorted Set/Streams and the caching/HA/scaling patterns are unchanged — but citing hash-field TTL, native JSON/vector support, and the AGPL relicensing accurately signals that your knowledge is current rather than frozen at Redis 6/7.
1.6 Caching Patterns
There are two ways to implement Cache-Aside in a Spring application: manual (RedisTemplate.opsForValue()) and declarative (Spring's cache abstraction — @Cacheable / @CachePut / @CacheEvict, backed by RedisCacheManager).
Redis sits in front of DB2 as a rebuildable acceleration layer: a hit never reaches the database, a miss loads from DB2 and repopulates Redis.
Best practice for enterprise systems: prefer the declarative cache annotations as the default.
RedisTemplatestill has its place (see the note at the end of this section), but for the standard read/write/delete cache-aside flow, the annotation-driven approach is what current Spring/Redis guidance treats as the production-grade path — it removes hand-written GET/SET/DEL boilerplate, centralizes serialization and TTL policy in oneCacheManagerbean, and is far less error-prone under code review (no risk of a developer forgetting to evict on update).
1.6.1 The manual approach (for comparison only)
@Service
@RequiredArgsConstructor
public class DepartmentServiceManual {
private final RedisTemplate<String, Object> redisTemplate;
private final DepartmentRepository repository;
public Department getDepartment(String id) {
String key = "department:" + id;
Department cached = (Department) redisTemplate.opsForValue().get(key);
if (cached != null) {
return cached;
}
Department fromDb = repository.findById(id)
.orElseThrow(() -> new NotFoundException(id));
redisTemplate.opsForValue().set(key, fromDb, Duration.ofHours(1));
return fromDb;
}
}
This works, but every read/write/delete path repeats the same GET/SET/DEL logic, error handling, and TTL decisions by hand — that logic belongs in framework configuration, not in every service method.
1.6.2 Declarative caching — the recommended default
Three annotations cover the full CRUD lifecycle against the cache:
| Annotation | Use for | Behavior |
|---|---|---|
@Cacheable | Read (SELECT / GET) | Returns cached value on HIT; on MISS, runs the method and caches the result |
@CachePut | Insert/Update (INSERT / UPDATE) | Always runs the method, then writes the result into the cache (unlike @Cacheable, it never skips execution) |
@CacheEvict | Delete / Invalidate (DELETE) | Removes one entry (or allEntries = true to clear a whole cache region) |
@Caching | Combine several of the above | For methods that must, e.g., evict one cache while updating another |
@Service
@RequiredArgsConstructor
public class DepartmentService {
private final DepartmentRepository repository;
// READ — cache-aside GET
@Cacheable(value = "departments", key = "#id")
public Department getDepartment(String id) {
return repository.findById(id)
.orElseThrow(() -> new NotFoundException(id));
}
// INSERT/UPDATE — always writes through to DB, then refreshes the cache
// entry with the DB-confirmed result (avoids caching an optimistic value
// that the DB might reject, e.g. due to a constraint or trigger).
@CachePut(value = "departments", key = "#department.id")
public Department saveDepartment(Department department) {
return repository.save(department);
}
// DELETE — removes the stale entry so the next read is a clean MISS
@CacheEvict(value = "departments", key = "#id")
public void deleteDepartment(String id) {
repository.deleteById(id);
}
// Compound example: renaming an org unit invalidates the old lookup
// key and the ranking cache that embeds the department name.
@Caching(evict = {
@CacheEvict(value = "departments", key = "#department.id"),
@CacheEvict(value = "sales_ranking", allEntries = true)
})
public void renameDepartment(Department department) {
repository.save(department);
}
}
Why @CachePut (not @Cacheable) for writes: @Cacheable short-circuits and skips the method body on a HIT — using it on a save/update method would mean the DB never gets written once the key already exists. @CachePut always executes the method and only uses the annotation to update the cache with the confirmed return value.
1.6.3 Production-grade RedisCacheManager configuration
This is the configuration shape used in real enterprise codebases — per-cache TTL from externalized properties, safe polymorphic JSON serialization, and a fail-open error handler so a Redis outage degrades to "no cache" instead of taking the application down.
@ConfigurationProperties(prefix = "app.cache.redis")
@Getter
@Setter
public class RedisCacheTtlProperties {
private Duration defaultTtl = Duration.ofHours(1);
private Map<String, Duration> ttls = new HashMap<>(); // per-cache-name overrides
}
app:
cache:
redis:
default-ttl: 1h
ttls:
departments: 6h
sales_ranking: 5m
otp: 5m
@Configuration
@EnableCaching
@EnableConfigurationProperties(RedisCacheTtlProperties.class)
@Slf4j
@RequiredArgsConstructor
public class CacheConfiguration {
private final RedisCacheTtlProperties cacheProperties;
@PostConstruct
void validateTtls() {
if (cacheProperties.getDefaultTtl().isNegative()) {
throw new IllegalStateException("Default cache TTL must be > 0");
}
}
@Bean
public CacheManager cacheManager(RedisConnectionFactory connectionFactory,
RedisCacheTtlProperties cacheProperties) {
// Restrict polymorphic (de)serialization to trusted packages only —
// never leave this unbounded, it is a deserialization attack surface.
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.domain")
.allowIfSubType("java.util")
.allowIfSubType("java.lang")
.build();
GenericJacksonJsonRedisSerializer jsonSerializer =
GenericJacksonJsonRedisSerializer.builder()
.typeValidator(ptv)
.enableDefaultTyping(ptv)
.build();
RedisCacheConfiguration defaultConfig = RedisCacheConfiguration.defaultCacheConfig()
.serializeKeysWith(RedisSerializationContext.SerializationPair
.fromSerializer(new StringRedisSerializer()))
.serializeValuesWith(RedisSerializationContext.SerializationPair
.fromSerializer(jsonSerializer))
.disableCachingNullValues()
.entryTtl(cacheProperties.getDefaultTtl());
Map<String, RedisCacheConfiguration> perCacheConfigs = new HashMap<>();
cacheProperties.getTtls().forEach((cacheName, ttl) ->
perCacheConfigs.put(cacheName, defaultConfig.entryTtl(ttl)));
return RedisCacheManager.builder(connectionFactory)
.cacheDefaults(defaultConfig)
.withInitialCacheConfigurations(perCacheConfigs)
.transactionAware()
.build();
}
// Fail-open: a Redis blip logs a warning instead of propagating an
// exception up through @Cacheable/@CachePut/@CacheEvict and breaking
// the request — the method still runs against the DB as if it were a MISS.
@Bean
public CacheErrorHandler cacheErrorHandler() {
return new CacheErrorHandler() {
@Override
public void handleCacheGetError(RuntimeException ex, Cache cache, Object key) {
log.warn("Redis GET error - cache: {}, key: {}, err: {}",
cache != null ? cache.getName() : "null", key, ex.getMessage());
}
@Override
public void handleCachePutError(RuntimeException ex, Cache cache, Object key, Object value) {
log.warn("Redis PUT error - cache: {}, key: {}, err: {}",
cache != null ? cache.getName() : "null", key, ex.getMessage());
}
@Override
public void handleCacheEvictError(RuntimeException ex, Cache cache, Object key) {
log.warn("Redis EVICT error - cache: {}, key: {}, err: {}",
cache != null ? cache.getName() : "null", key, ex.getMessage());
}
@Override
public void handleCacheClearError(RuntimeException ex, Cache cache) {
log.warn("Redis CLEAR error - cache: {}, err: {}",
cache != null ? cache.getName() : "null", ex.getMessage());
}
};
}
}
Design decisions worth being able to defend in an interview:
PolymorphicTypeValidatorscoped to specific packages — unrestricted default typing on a JSON deserializer is a known remote-code-execution vector (untrusted/tampered cache payloads deserializing into arbitrary classes). Always allow-list, never allow-all.disableCachingNullValues()— prevents caching a null lookup result under@Cacheableby default; if you want to cache "not found" (see Cache Penetration), use@Cacheable(unless = "#result == null")deliberately with an explicit short TTL cache region, rather than caching nulls everywhere.transactionAware()— defers cache writes until the surrounding Spring@Transactionalcommits, avoiding a cache entry that reflects a DB write that later rolled back.CacheErrorHandler— without this, a Redis connectivity issue turns into an unhandled exception thrown from inside@Cacheable, which by default fails the whole request. A fail-open handler treats a broken cache as "always MISS," so Redis becomes a pure optimization, not a single point of failure for the DB-backed method itself.- Per-cache-name TTL from configuration, not hardcoded — lets ops tune freshness vs. load per cache region without a redeploy.
1.6.4 When you still need RedisTemplate
The @Cache* annotations only model simple method-level cache-aside (key → serialized return value). They do not cover:
- Native data structures — Sorted Set rankings, Hash partial-field updates, List/Set operations (§1.4)
- Distributed locks (
SET NX EX, Lua compare-and-delete — §1.19) - Pub/Sub and Streams (§1.20)
- Manual TTL/cache-stampede logic (
SETNXlocking, jittered TTL — §1.8)
For those, RedisTemplate/StringRedisTemplate (or the reactive equivalents) remain the correct tool — they are not "the old way," they simply operate at a different layer (raw data structures) than the cache abstraction (method-result caching).
1.7 Cache Invalidation
The classic hard problem: "There are only two hard things in Computer Science: cache invalidation and naming things."
DB (source of truth): "IT Department"
Redis (stale cache): "Technology Department" ← inconsistency
Strategies:
| Strategy | Mechanism | Trade-off |
|---|---|---|
| TTL expiry | Cache auto-expires | Simple, but bounded staleness window |
| Delete-on-update | UPDATE DB → DEL cache key | Strong-ish consistency, extra write path |
| Write-through | App writes cache + DB together | Cache always fresh, added write latency |
| Write-behind | App writes cache first, DB async | Fast writes, risk of data loss / complex consistency |
@Transactional
public void updateDepartment(Department department) {
repository.save(department);
redisTemplate.delete("department:" + department.getId());
}
1.8 Cache Stampede, Penetration, Avalanche
Three related but distinct production failure modes — a favorite senior-interview topic.
1.8.1 Cache Stampede (Thundering Herd)
A hot key expires; thousands of concurrent requests miss simultaneously and hammer the DB.
Key expires
│
▼
10,000 concurrent requests → MISS → all hit DB2 simultaneously → DB overload
Mitigations:
- Distributed lock — only one request rebuilds the cache; others wait or serve stale data.
- Randomized/jittered TTL —
TTL = baseTtl + random(0, jitter)so keys don't expire in lockstep. - Cache warming — pre-populate before traffic spikes.
public Department getDepartmentWithLock(String id) {
String key = "department:" + id;
Department cached = (Department) redisTemplate.opsForValue().get(key);
if (cached != null) return cached;
String lockKey = "lock:" + key;
Boolean acquired = redisTemplate.opsForValue()
.setIfAbsent(lockKey, "1", Duration.ofSeconds(10));
if (Boolean.TRUE.equals(acquired)) {
try {
Department fresh = repository.findById(id).orElseThrow();
redisTemplate.opsForValue().set(key, fresh,
Duration.ofMinutes(60 + new Random().nextInt(10))); // jitter
return fresh;
} finally {
redisTemplate.delete(lockKey);
}
} else {
// brief backoff + retry read, or return stale/fallback value
return retryReadOrFallback(key);
}
}
1.8.2 Cache Penetration
Requests for keys that do not exist in the DB at all — the cache never helps, every request hits the DB.
GET user:999999999
│
▼
Redis MISS → DB lookup → NOT FOUND (repeats every request)
Mitigations:
- Cache the "not found" result with a short TTL (
user:999999999 = NULL, TTL 60s). - Bloom filter in front of the cache to reject impossible keys cheaply.
1.8.3 Cache Avalanche
Many different keys expire around the same time, producing a broad DB spike rather than a single hot key.
Mitigations: randomized TTL, staggered cache warming, gradual refresh.
1.9 Client & Connection Management
Never create a new Redis connection per request. Spring Boot uses Lettuce as the default Redis client for Spring Data Redis.
Precise wording matters here — this is a common interview slip. Don't say "Lettuce is pooled by default." The accurate statement is: Spring Boot defaults to Lettuce for Spring Data Redis; Lettuce supports thread-safe connection sharing and multiplexing over a single connection, while connection pooling (via Apache Commons Pool2) is an opt-in mechanism that you configure when you need it.
Why the distinction matters: Lettuce is built on Netty and supports asynchronous, non-blocking I/O, so a single physical connection can safely be shared by multiple threads and multiplex many commands without requiring a connection pool.
Pooling still has legitimate uses with Lettuce — for example, when you need to isolate a dedicated connection for a long-lived WATCH/MULTI/EXEC sequence or a blocking command such as BLPOP from the shared multiplexed connection. This is why spring.data.redis.lettuce.pool.* exists as an optional configuration rather than because multiplexing is unsafe without pooling.
spring:
data:
redis:
host: redis.internal
port: 6379
timeout: 2000ms
lettuce:
pool: # opt-in; only add this block if you have a concrete reason to pool
max-active: 32
max-idle: 16
min-idle: 4
Lettuce vs Jedis: Jedis is a simpler, blocking client — one connection serves exactly one command at a time, so it requires an explicit connection pool (
JedisPool) sized to your thread count to support concurrency. Lettuce doesn't need a pool for ordinary concurrent request handling because of multiplexing, but can still use one for the specific cases above.
1.10 Threading Model
Historically, Redis command execution runs on a single main thread — this avoids lock contention between commands and keeps the execution model simple and predictable. Newer Redis versions offload some networking/I/O work to background threads, but the command-execution semantics remain effectively single-threaded.
Consequence: a single slow/blocking command (e.g. KEYS * on a large keyspace, or deleting a huge collection) can block all other clients momentarily.
Client A: SET → fast
Client B: SET → fast
Client C: KEYS * (huge dataset) → SLOW
│
▼
Clients A, D, E... wait behind it
Best practice: never run KEYS * in production — use SCAN (cursor-based, non-blocking) instead. Spring Data Redis exposes this via RedisTemplate.scan()/ScanOptions.
1.11 Replication
Replication is asynchronous by default — replicas may lag behind the primary. Design implication: reads from a replica can return stale data; don't route read-after-write-critical paths to replicas without accounting for this.
1.12 High Availability: Sentinel
Sentinel provides monitoring + automatic failover for a primary/replica deployment — it does not shard data.
Spring Data Redis supports Sentinel natively:
spring:
data:
redis:
sentinel:
master: mymaster
nodes:
- sentinel1:26379
- sentinel2:26379
- sentinel3:26379
1.13 Horizontal Scaling: Redis Cluster
Cluster adds sharding on top of HA — data is distributed across nodes via 16,384 hash slots.
Key routing: slot = CRC16(key) % 16384. Multi-key operations (MGET, transactions) only work atomically when all keys hash to the same slot — a common pitfall. Use hash tags ({user:1}:profile, {user:1}:roles) to force co-location when needed.
spring:
data:
redis:
cluster:
nodes:
- node1:6379
- node2:6379
- node3:6379
max-redirects: 3
1.14 Sentinel vs Cluster
| Sentinel | Cluster | |
|---|---|---|
| High availability | ✅ | ✅ |
| Automatic failover | ✅ | ✅ |
| Data sharding | ❌ | ✅ |
| Horizontal scaling | Limited (vertical + read replicas) | ✅ |
| Complexity | Lower | Higher (slot management, hash tags, multi-key limits) |
Rule of thumb: Sentinel when the dataset fits comfortably on one primary and you mainly need HA. Cluster when the dataset or throughput exceeds a single node's capacity.
1.15 Big Key & Hot Key Problems
Big Key
A single key holding an excessively large value (e.g. a Hash with 10M fields, or a huge serialized blob).
Risks: memory pressure, network latency on transfer, slow synchronous deletion (blocks the event loop), replication overhead.
Mitigation: partition into smaller keys (users:shard:1, users:shard:2, ...), use UNLINK (async delete) instead of DEL for large keys.
Hot Key
A single key receiving disproportionate traffic (e.g. config:homepage at 100k req/s), which can bottleneck the single node/slot serving it even though Redis itself is fast.
Mitigation: local (in-process) cache layer in front of Redis for extremely hot, rarely-changing keys; client-side caching (Redis 6+ tracking); key replication strategies for read-heavy hot keys.
Client → Local Cache (in-JVM, short TTL) → Redis → DB
↑
absorbs most hot-key traffic
1.16 Memory & Eviction Policies
maxmemory 4gb
maxmemory-policy allkeys-lru
| Policy | Behavior |
|---|---|
noeviction | Reject writes when full (errors) |
allkeys-lru | Evict least-recently-used key, any key |
allkeys-lfu | Evict least-frequently-used key, any key |
volatile-lru | LRU eviction, only among keys with TTL set |
volatile-lfu | LFU eviction, only among keys with TTL set |
volatile-ttl | Evict the key with the nearest expiry first |
Design implication: if some keys must never be evicted (e.g. distributed lock keys), don't rely on allkeys-* policies alone — separate critical keys logically or use dedicated instances/databases.
1.17 Security
Production checklist:
- Require authentication (
requirepass/ Redis ACLs for per-user permissions). - Enable TLS for data in transit.
- Never expose Redis directly to the public internet — bind to internal network only, enforce firewall rules.
- Apply least-privilege ACLs (e.g. a reporting service should not have
FLUSHALL/CONFIGaccess).
1.18 Transactions: MULTI / EXEC / WATCH
Redis transactions guarantee that queued commands execute sequentially without interleaving from other clients — they do not provide relational-style rollback semantics.
SessionCallback<List<Object>> callback = new SessionCallback<>() {
@Override
public List<Object> execute(RedisOperations operations) {
operations.multi();
operations.opsForValue().set("A", "1");
operations.opsForValue().set("B", "2");
return operations.exec();
}
};
redisTemplate.execute(callback);
Optimistic locking with WATCH
redisTemplate.execute(new SessionCallback<Object>() {
@Override
public Object execute(RedisOperations operations) {
operations.watch("balance");
int current = Integer.parseInt((String) operations.opsForValue().get("balance"));
operations.multi();
operations.opsForValue().set("balance", String.valueOf(current - 100));
return operations.exec(); // returns null/empty if "balance" changed concurrently
}
});
If balance was modified by another client between WATCH and EXEC, the transaction aborts — the caller must retry. This is optimistic concurrency control, conceptually similar to a version-check/CAS pattern.
1.19 Distributed Locking
Basic pattern:
Boolean locked = redisTemplate.opsForValue()
.setIfAbsent("lock:batch-job", instanceId, Duration.ofSeconds(30));
NX (only set if absent) + EX (auto-expire) prevents an indefinite lock if the holder crashes.
Only one instance wins the NX acquisition; the lock must carry an owner token and be released with an atomic compare-and-delete rather than an unconditional DEL.
A senior-level answer must go beyond SET NX EX. Production-grade distributed locking needs to address:
- Owner identity — store a unique token so only the owner can release its own lock (avoid releasing a lock acquired by someone else after expiry + re-acquisition).
- Safe release — check-and-delete via a Lua script (atomic compare-and-delete), not a plain
DEL. - Lock renewal — extend TTL for long-running jobs (watchdog pattern).
- Clock/network assumptions — a single-instance
SET NX EXlock is not provably safe under clock drift, GC pauses, or network partitions.
String script =
"if redis.call('get', KEYS[1]) == ARGV[1] then " +
" return redis.call('del', KEYS[1]) " +
"else return 0 end";
redisTemplate.execute(new DefaultRedisScript<>(script, Long.class),
List.of("lock:batch-job"), instanceId);
Never release a lock with an unconditional DEL lock:resource — the process that thinks it still owns the lock may have already lost it (TTL expired, another process re-acquired it). The Lua script above is the safe pattern: check the owner token, only then delete.
1.19.1 The Redlock debate — don't quote it as gospel
Redlock extends single-instance locking to N independent Redis instances: a client acquires the lock only if it gets NX on a majority (quorum) of instances within a bounded time.
Client attempts lock on 5 independent Redis instances
│
├── Instance 1: NX success
├── Instance 2: NX success
├── Instance 3: NX success ← 3/5 = majority reached → lock considered held
├── Instance 4: timeout
└── Instance 5: timeout
This is often presented as "the" correct way to do distributed Redis locking — a senior/professional answer should instead be able to describe the actual disagreement about it:
- Redis's own documentation presents Redlock as the recommended approach for scenarios needing stronger guarantees than a single instance.
- A well-known critique (originating from distributed-systems research, notably Martin Kleppmann's analysis) argues Redlock is not provably safe under realistic failure assumptions — specifically, it depends on bounded clock drift and bounded process-pause (GC/scheduling) assumptions that a lock built for mutual exclusion shouldn't have to make. A client can believe it holds the lock (having acquired quorum) while, from the point of view of an external observer with a synchronized clock, its lease has already logically expired — e.g. after a long GC pause — allowing two clients to both proceed.
- The core mitigation the critique proposes is a fencing token: a monotonically increasing number issued with each lock acquisition, which the protected resource (not Redis) must check and reject if it has already seen a higher token. Redis itself has no way to enforce this — it has to be implemented at the resource being protected.
The takeaway to actually say in an interview:
"Redis locking (single-instance
SET NX EXor Redlock) gives you a good-enough mutual-exclusion mechanism for reducing duplicate work — it does not, by itself, turn Redis into a coordination system with the strong correctness guarantees of a consensus protocol. Whether that's acceptable depends entirely on the cost of a rare double-execution."
1.19.2 Lock expiration is not a correctness guarantee
A lock being held does not mean the operation it protects is still safe to run by the time it completes. Walk through this timeline:
T0 Process A acquires lock
T1 Process A starts processing
T2 Lock expires (TTL reached, or A paused too long — e.g. a GC pause)
T3 Process B acquires the now-free lock
T4 Process A resumes and continues processing, unaware its lock is gone
At T4, both A and B may believe they are the sole owner of the protected resource. For idempotent or retryable jobs this is usually acceptable — the business operation tolerates being (rarely) run twice. For correctness-critical operations, the lock alone is not enough; combine it with a fencing token (§1.19.1), a database transaction/constraint, or a stronger coordination system (§1.19.3).
Good fit for Redis-only locking:
- Preventing duplicate execution of non-critical background jobs
- Coordinating cache refreshes / stampede protection (§1.8.1)
- Avoiding concurrent execution of expensive maintenance tasks
- Short-lived, best-effort mutual exclusion where an occasional duplicate run is tolerable
Not a good fit for Redis-only locking — operations where duplicate execution would be irreversible or financially incorrect (payment processing, inventory deduction, financial settlement, critical accounting updates). For these, combine (or replace) the lock with:
Idempotency key
+
Database transaction
+
Unique constraint / state transition
+
Optional distributed lock (as an optimization, not the correctness mechanism)
The core principle: a distributed lock is a coordination mechanism, not a substitute for transactional correctness.
1.19.3 Alternatives when Redis locking isn't enough
If the business requirement is strict (e.g. "two processes must never both debit the same account," not just "duplicate batch runs are wasteful but harmless"), consider alternatives built on actual consensus or transactional guarantees:
| Option | Mechanism | Fit |
|---|---|---|
| Database advisory lock / row lock | RDBMS-native locking (e.g. SELECT ... FOR UPDATE, DB2/Postgres advisory locks) | You already have a transactional system of record — reuse its guarantees instead of adding a second source of truth for correctness |
| ZooKeeper | Consensus (ZAB protocol), ephemeral sequential znodes | Mature, strong guarantees, but adds significant operational overhead |
| etcd | Consensus (Raft), lease-based locks | Common in Kubernetes-native environments where etcd is already present |
| Fencing token (paired with any of the above, including Redis) | Monotonic token checked by the protected resource itself | The actual fix for the "lock expired mid-operation" class of bug — worth implementing regardless of which lock manager you choose |
Practical recommendation: use Redis locking (single instance, or Redisson RLock with its watchdog) for the common case — preventing duplicate/overlapping batch jobs, cache-stampede protection, idempotency windows — where the cost of a rare double-execution is low. Reach for ZooKeeper/etcd or DB-native locking, plus a fencing token, only when a lock violation would cause real business or data-integrity damage. For production systems using Redis for locking, prefer a maintained library over hand-rolled scripts: Redisson (RLock, includes watchdog auto-renewal) integrates cleanly with Spring Boot.
1.20 Pub/Sub vs Streams
Pub/Sub is fire-and-forget for real-time fan-out; Streams provide durable, replayable processing with consumer groups and acknowledgments.
Pub/Sub — fire-and-forget
redisTemplate.convertAndSend("employee.updated", employeeId);
@Bean
RedisMessageListenerContainer container(RedisConnectionFactory factory, MessageListener listener) {
RedisMessageListenerContainer container = new RedisMessageListenerContainer();
container.setConnectionFactory(factory);
container.addMessageListener(listener, new ChannelTopic("employee.updated"));
return container;
}
Limitation: if a subscriber is offline, it misses the message permanently — no persistence, no replay.
Streams — durable, replayable event log
StreamOperations<String, Object, Object> streamOps = redisTemplate.opsForStream();
streamOps.add(StreamRecords.newRecord()
.in("employee-events")
.ofObject(Map.of("employeeId", "1", "action", "UPDATED")));
Supports consumer groups, message IDs, and acknowledgment — closer in spirit to a lightweight Kafka topic than to Pub/Sub.
Stream: employee-events
├── event 1 (id: 1690000-0)
├── event 2 (id: 1690001-0)
├── event 3 (id: 1690002-0) ← consumer group tracks last-read offset
└── event 4 (pending, not yet ack'd)
1.21 Redis vs Kafka / RabbitMQ
Do not present Redis as a drop-in replacement for a dedicated message broker in an interview — this is a common trap.
| Redis (Streams/Pub-Sub) | RabbitMQ | Kafka | |
|---|---|---|---|
| Strength | Simplicity, very low latency, reuse existing infra | Routing, flexible delivery guarantees, ack semantics | High-throughput event streaming, durable replay, partitioning |
| Best fit | Lightweight async tasks, notifications | Complex routing / work queues | Event sourcing, high-volume pipelines, replay-heavy systems |
1.22 Serialization
Java objects must be serialized before storage — this is a very concrete, immediate concern when integrating with Java code, and the recommended serializer class has changed with recent Spring Data Redis releases.
Current generation (Spring Data Redis 4.x, Jackson 3): GenericJacksonJsonRedisSerializer
Spring Data Redis 4.0 (aligned with the Spring Boot 4 / Jackson 3 line) introduces GenericJacksonJsonRedisSerializer, built on Jackson 3's tools.jackson.databind package, and deprecates the older GenericJackson2JsonRedisSerializer (Jackson 2, com.fasterxml.jackson). New code should target the new serializer:
PolymorphicTypeValidator ptv = BasicPolymorphicTypeValidator.builder()
.allowIfSubType("com.example.domain") // allow-list only — never allowIfBaseType(Object.class)
.allowIfSubType("java.util")
.allowIfSubType("java.lang")
.build();
GenericJacksonJsonRedisSerializer jsonSerializer = GenericJacksonJsonRedisSerializer.builder()
.typeValidator(ptv)
.enableDefaultTyping(ptv) // embeds type info so polymorphic reads deserialize correctly
.build();
This is the same serializer used in the CacheConfiguration example in §1.6.3 — for both RedisCacheManager (annotation-based caching) and any remaining RedisTemplate beans, use one shared, package-scoped PolymorphicTypeValidator so cache entries and raw data-structure entries are serialized consistently.
Legacy (Spring Data Redis ≤3.x, Jackson 2): GenericJackson2JsonRedisSerializer
If the project is still on the Jackson 2 line, the older serializer is the equivalent:
@Bean
public RedisTemplate<String, Object> redisTemplate(RedisConnectionFactory factory) {
RedisTemplate<String, Object> template = new RedisTemplate<>();
template.setConnectionFactory(factory);
template.setKeySerializer(new StringRedisSerializer());
template.setValueSerializer(new GenericJackson2JsonRedisSerializer());
return template;
}
Format options
- JSON (Jackson) — interoperable across languages/services, human-readable for debugging. Default recommendation for most enterprise systems.
- Protobuf/Avro — more compact, faster, but adds schema management overhead.
- Java native serialization — avoid: poor interoperability, larger payloads, and historically a security liability if not tightly controlled.
Security note (applies to any JSON-with-type-info serializer)
Enabling polymorphic/default typing so Redis can deserialize back into the correct concrete class is convenient, but if the PolymorphicTypeValidator allow-list is too broad (or absent), a tampered or attacker-controlled cache payload can trigger deserialization into an arbitrary class on the classpath — a known gadget-chain RCE pattern. Always scope allowIfSubType(...) to your own domain packages plus a minimal set of JDK types, never to Object or an unbounded wildcard.
1.23 Cache Consistency Strategies
| Strategy | Who manages the cache | Typical use |
|---|---|---|
| Cache-Aside | Application code | Most common default (see §1.6.1) |
| Read-Through | Caching layer/library fetches on miss transparently | When using a caching abstraction (e.g. @Cacheable) |
| Write-Through | App writes to cache and DB synchronously | Strong freshness requirement, write-latency tolerant |
| Write-Behind | App writes to cache, DB updated asynchronously | Write-heavy, latency-sensitive, tolerant of eventual consistency risk |
Each strategy trades off consistency, latency, complexity, and failure-mode blast radius — there is no universally "best" choice; it depends on the specific field's tolerance for staleness.
1.24 Reference Architecture
A representative enterprise layout combining caching, session, and locking use cases with a relational system of record (e.g. DB2):
Design notes:
- Redis is an acceleration layer, DB2 remains the source of truth.
- App servers are stateless; session state lives in Redis, enabling horizontal scaling behind the load balancer.
- Batch servers use Redis distributed locks to prevent duplicate job execution across instances.
1.25 Interview Question Bank
Foundational
- What is Redis, and how does it differ from a relational database?
- Why is Redis fast?
- Is Redis "just RAM"? What about persistence?
- Compare RDB and AOF.
- What data structures does Redis support, and when would you use each?
Intermediate
6. Walk through the Cache-Aside pattern.
7. How do you handle cache invalidation?
8. Why use a connection pool with Jedis/Lettuce?
9. What guarantees does MULTI/EXEC actually provide?
10. How does WATCH implement optimistic locking?
11. Difference between Pub/Sub and Streams?
Senior 12. Explain Cache Stampede, Penetration, and Avalanche — and mitigations for each. 13. What is a Big Key vs a Hot Key, and how do you mitigate each? 14. Explain eviction policies and when you'd choose each. 15. Is Redis single-threaded? What are the practical implications? 16. Explain replication and its consistency implications. 17. What does Sentinel solve? What does Cluster solve? How do they differ?
Professional / Architecture
18. Design a highly-available Redis architecture for a 100K-employee HR system.
19. How does Redis Cluster partition data? What is a hash slot?
20. What happens if a Cluster primary node fails? What about a network partition?
21. How do you design a production-grade distributed lock?
22. How do you prevent stale cache in a multi-service system?
23. How would you monitor Redis in production (latency, memory, replication lag, slow log)?
24. When should you not use Redis?
25. used_memory looks normal but the OS reports high RSS for the Redis process — how do you investigate, and what does a fragmentation ratio below 1.0 tell you?
26. Walk through what happens, step by step, when a Redis Cluster master becomes unreachable from part of the cluster.
27. Design a rate limiter for a public API — which algorithm, and why?
1.26 Common Interview Traps
"Redis is fast because it's single-threaded." Incomplete. It's fast due to in-memory storage, efficient data structures, and low protocol overhead; single-threaded execution mainly avoids lock contention — it isn't the root cause of speed, and it can even become a liability for long-running commands.
"It's just a cache, so losing data is fine." Depends entirely on the use case:
Cache → losing data is usually rebuildable
Queue/Stream → losing data can be serious
Session → depends on business requirement
Business state → potentially critical — reconsider using Redis as sole store
"Redis Cluster is basically a load balancer." No. A load balancer only distributes requests. Cluster distributes and owns data (via hash slots), plus provides HA/failover for each shard.
"RDB or AOF — which is objectively better?" There's no universal answer; it depends on RPO/RTO requirements. In practice, hybrid is the common production default.
"Redis can replace Kafka/RabbitMQ." Only for a narrow set of lightweight use cases. Each tool has distinct strengths (see §1.21) — presenting Redis as a general broker replacement signals shallow understanding.
"A Redis lock (or Redlock) guarantees correctness." No. It guarantees good-enough mutual exclusion under normal conditions — not a provable safety guarantee under clock drift, GC pauses, or network partitions. See §1.19.
1.27 Learning Roadmap
LEVEL 1 — Fundamentals
In-memory model · Commands · Data structures · TTL
LEVEL 2 — Caching
Cache-Aside · Invalidation · Stampede · Penetration · Avalanche
LEVEL 3 — Java/Spring Integration
Lettuce/Jedis · Connection pooling · RedisTemplate · Serialization
LEVEL 4 — Reliability
RDB · AOF · Replication · Sentinel · Recovery
LEVEL 5 — Scaling
Cluster · Hash slots · Sharding · Hot Key · Big Key
LEVEL 6 — Advanced Patterns
Distributed Locking · Pub/Sub · Streams · Transactions · WATCH
LEVEL 7 — Production Operations
Memory Internals · Monitoring · Latency · Security · HA · DR · Rate Limiting
Practical exercise recommendation: build a small proof-of-concept with Spring Boot + Spring Data Redis in front of an existing relational system (e.g. DB2): implement Cache-Aside first, benchmark before/after, then progressively add distributed locking, session sharing, and stampede protection. This produces both interview-ready talking points and a demonstrable artifact.
The sections below (§2.1–§3.2) go deeper into the areas that separate a Senior answer from a Professional/Architect one: what's actually happening inside Redis's memory, how to debug it live in production, what really happens during a network partition, a complete worked pattern (rate limiting) that's a strong live-demo candidate, and — just as important — when Redis is not the right tool.
Chapter 2. Professional & Architect Deep Dives
2.1 Redis Memory Internals
used_memory (what Redis thinks it's using) and the memory the OS reports for the Redis process are two different numbers, and the gap between them is one of the most misunderstood areas of Redis operations.
Why used_memory ≠ OS/process memory
used_memory— bytes the Redis allocator (jemalloc by default) reports as allocated for Redis's own use: dataset + internal overhead.used_memory_rss— Resident Set Size, i.e. what the operating system says the process actually occupies in physical RAM. This includesused_memoryplus allocator fragmentation, allocator-reserved-but-idle pages, and other process overhead (code, stack, shared libs).mem_fragmentation_ratio=used_memory_rss/used_memory.
| Ratio | Meaning |
|---|---|
| ~1.0–1.1 | Healthy — RSS closely tracks actual data |
| 1.1–1.5 | Normal range, some fragmentation |
| > 1.5 | Meaningful fragmentation — investigate; consider active defrag |
| < 1.0 | Worse than fragmentation: Redis memory is being swapped to disk by the OS. Expect severe latency. Urgent to fix (add RAM, reduce dataset, or fix maxmemory sizing). |
// Reading these via Spring Data Redis
Properties memoryInfo = redisTemplate.getConnectionFactory()
.getConnection()
.serverCommands()
.info("memory");
Diagnostic commands and what they're for
| Command | What it tells you |
|---|---|
INFO memory | Quick overview: used_memory, used_memory_rss, mem_fragmentation_ratio, maxmemory, mem_allocator |
MEMORY STATS | Granular breakdown — allocator-level detail (allocator.allocated, allocator.active, allocator.resident), dataset vs overhead split, replication backlog size, AOF buffer size. The right tool when INFO memory alone isn't specific enough to explain a growth pattern. |
MEMORY USAGE <key> | Estimated bytes a specific key consumes — the direct tool for hunting down a suspected Big Key (§1.15) |
MEMORY DOCTOR | Built-in heuristic sanity check with plain-language suggestions |
CONFIG SET activedefrag yes | Enables active (background) defragmentation when mem_fragmentation_ratio is persistently high |
Common causes of high fragmentation: heavy churn of variable-sized keys (frequent updates to Hash fields, short-lived keys with different value sizes), large swings in dataset size (big deletes followed by big inserts), and allocator behavior under certain workload shapes. The fix is rarely "restart Redis" (that's a blunt, disruptive workaround) — prefer activedefrag first, and address the underlying key-churn pattern if the ratio keeps climbing back up.
2.2 Production Troubleshooting & Observability
A senior/architect-level answer to "Redis latency suddenly increased — what do you do?" is a diagnostic sequence, not a single command.
Key diagnostic commands
| Command | Purpose |
|---|---|
LATENCY HISTORY <event> / LATENCY LATEST | Redis's built-in latency monitor — buckets latency spikes by event type (command, fork, expire-cycle, etc.) without needing external tooling |
SLOWLOG GET [n] | Log of commands that exceeded slowlog-log-slower-than (microseconds) — the first place to look for a specific offending command/key pattern |
INFO | Everything: Server, Clients, Memory, Persistence, Stats, Replication, CPU, Keyspace sections |
CLIENT LIST | Per-connection detail — idle time, buffer sizes, last command; useful for spotting a client holding an old/blocked connection |
MONITOR | Streams every command in real time — extremely useful for a live debug session, but adds real overhead; never run it unattended in production |
SCAN (not KEYS) | Safe, cursor-based iteration for ad-hoc key inspection without blocking the event loop (§1.10) |
Metrics worth alerting on in production
| Category | Metric | Why it matters |
|---|---|---|
| Memory | used_memory, mem_fragmentation_ratio, evicted_keys | Capacity planning, fragmentation, unwanted eviction under a maxmemory policy |
| Hit ratio | keyspace_hits / (keyspace_hits + keyspace_misses) | A dropping hit ratio is often the earliest signal of a Stampede/Penetration/Avalanche issue (§1.8) before latency visibly worsens |
| Expiry | expired_keys | Sudden spikes can correlate with an Avalanche event (§1.8) |
| Connections | connected_clients, blocked_clients, rejected_connections | Connection-pool misconfiguration, client leaks, or hitting maxclients |
| Throughput | instantaneous_ops_per_sec | Baseline load, useful to correlate with app-side traffic |
| Latency | Command latency via LATENCY HISTORY, or app-side timing around Redis calls | Direct user-facing impact |
| Replication | master_repl_offset vs each replica's applied offset (replication lag), connected_slaves | Read-from-replica staleness risk (§1.11), early warning before a failover makes lag visible the hard way |
| System | CPU, network throughput/bandwidth on the Redis host | Redis is usually memory/network-bound, not CPU-bound (given the single-threaded command loop) — sustained high CPU is itself worth investigating |
Practical workflow to describe in an interview: "I'd start from the hit ratio and evicted_keys to rule out a capacity/eviction problem, check SLOWLOG for a specific offending command or Big Key, check mem_fragmentation_ratio and swap status, and check replication lag if reads are involved — only reaching for MONITOR as a last resort, for a short attended session."
2.3 Cluster Failure Scenarios & the Consistency Trade-off
§§1.12–1.14 covered Sentinel and Cluster mechanics at a component level. This section covers what actually happens during a failure — the scenario a Professional/Architect interview is really probing for.
The core failure-detection sequence (Cluster)
Two config parameters drive this, and both are worth knowing by name:
cluster-node-timeout— how long a node can be unreachable before being considered failed. Shorter = faster failover but more false positives from brief network blips; longer = fewer false positives but slower real recovery. (Default 15s in Redis Cluster.)cluster-replica-validity-factor— a replica that has been disconnected from its master for longer thancluster-node-timeout × cluster-replica-validity-factorrefuses to participate in a failover election, because its data is considered too stale to safely promote. Setting this to0maximizes availability (a replica will always try to fail over) at the cost of a higher risk of promoting a replica with meaningfully stale data.
This node-timeout / validity-factor pair is the availability-vs-consistency knob in Redis Cluster — an architect should be able to say explicitly which side a given configuration favors.
Quorum and cluster-wide availability
A Cluster master that cannot reach a majority of master nodes (not just its own replicas) stops accepting writes entirely, even if it can still see its own replicas fine. This is a deliberate design choice: a minority partition refusing writes is what prevents split-brain, i.e. two sides of a partition both accepting writes for the same hash slot and later conflicting irreconcilably.
Sentinel: the same idea, different mechanics
Sentinel uses its own quorum setting (sentinel monitor mymaster <ip> <port> <quorum>) — the number of Sentinels that must agree a primary is "objectively down" (ODOWN) before starting a failover, on top of each individual Sentinel's own subjective "down" detection (down-after-milliseconds). A too-low quorum relative to your Sentinel count risks a failover being triggered by a single Sentinel's flaky network view; a too-high quorum risks a slow or stalled failover during a real outage.
Replication offset — how a stale replica is identified
Every write increments the primary's replication offset; each replica tracks how far it has applied. During failover, Sentinel/Cluster prefer promoting the replica with the highest replication offset (least data behind). This is why replication lag monitoring (§2.2) isn't just a "read freshness" concern — it directly affects how much data can be lost during an unplanned failover (writes accepted by the old primary but never replicated before it died are simply gone).
The trade-off to name explicitly
Redis replication is asynchronous by default. This means Redis Cluster/Sentinel favor availability and partition tolerance over strict consistency during a failover — a promoted replica may be missing the last few writes the old primary accepted right before it failed. This is a conscious design trade-off (roughly the "AP" side of the CAP framing, though Redis's actual behavior is more nuanced than a strict CAP label), not a bug. If your workload cannot tolerate losing the last few writes on failover, that requirement needs to be handled above Redis (idempotent writes, a durable queue/outbox pattern, or
WAITfor synchronous-ish acknowledgment from N replicas before considering a write "safe" — with the caveat thatWAITstill doesn't make Redis a strongly consistent system).
2.4 Rate Limiting Patterns
Rate limiting is one of the most demonstrable real-world Redis use cases — small, self-contained, and it touches atomicity, TTL, and Lua scripting in one example.
2.4.1 Fixed Window — the simplest version
INCR request:user:123
EXPIRE request:user:123 60 (only set on first request in the window)
public boolean allowRequest(String userId, int limit, Duration window) {
String key = "rate:" + userId;
Long count = redisTemplate.opsForValue().increment(key);
if (count != null && count == 1L) {
redisTemplate.expire(key, window);
}
return count != null && count <= limit;
}
Problem: boundary burst — a client can send limit requests just before the window resets and another limit just after, getting up to 2× the intended rate in a short span around the boundary.
|--- window 1 ---|--- window 2 ---|
99,100 requests 1,2...100 requests
(right before (right after
reset) reset)
→ up to 2x burst around the boundary
2.4.2 Sliding Window — smooths the boundary problem
Using a Sorted Set, with each request stored as a member scored by its timestamp:
public boolean allowRequestSlidingWindow(String userId, int limit, Duration window) {
String key = "rate:sliding:" + userId;
long now = System.currentTimeMillis();
long windowStart = now - window.toMillis();
redisTemplate.opsForZSet().removeRangeByScore(key, 0, windowStart); // drop old entries
Long count = redisTemplate.opsForZSet().zCard(key);
if (count != null && count < limit) {
redisTemplate.opsForZSet().add(key, UUID.randomUUID().toString(), now);
redisTemplate.expire(key, window);
return true;
}
return false;
}
More accurate than Fixed Window, at the cost of higher memory (one Sorted Set entry per request in the window) and an extra round trip — for high-throughput endpoints, wrap the check in a Lua script to make it atomic in one round trip.
2.4.3 Token Bucket — allows controlled bursts
Bucket capacity: 100 tokens
Refill rate: 10 tokens/sec
Best implemented as a Lua script for atomicity (read-modify-write must not race across concurrent requests):
-- KEYS[1] = bucket key, ARGV[1] = capacity, ARGV[2] = refill_rate_per_sec, ARGV[3] = now_ms
local bucket = redis.call('HMGET', KEYS[1], 'tokens', 'ts')
local tokens = tonumber(bucket[1]) or tonumber(ARGV[1])
local ts = tonumber(bucket[2]) or tonumber(ARGV[3])
local elapsed = (tonumber(ARGV[3]) - ts) / 1000
tokens = math.min(tonumber(ARGV[1]), tokens + elapsed * tonumber(ARGV[2]))
if tokens >= 1 then
tokens = tokens - 1
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
redis.call('EXPIRE', KEYS[1], 3600)
return 1
else
redis.call('HMSET', KEYS[1], 'tokens', tokens, 'ts', ARGV[3])
return 0
end
Token Bucket is the closest fit for typical API rate limiting — it allows short legitimate bursts (a client that's been idle can "spend" saved-up tokens) while still enforcing a steady-state average rate.
2.4.4 Leaky Bucket — smooths bursts into a constant output rate
Conceptually the inverse of Token Bucket: requests fill a queue (the "bucket"); a worker processes/"leaks" them at a fixed rate regardless of how fast they arrived. Typically implemented with a Redis List as the queue plus a separate consumer processing at a fixed cadence — best suited when you need to shape traffic to a downstream system's fixed capacity (e.g. protecting a legacy DB2 batch endpoint from bursty callers), rather than simply rejecting excess requests.
Choosing an algorithm
| Algorithm | Allows bursts? | Precision | Complexity | Typical fit |
|---|---|---|---|---|
| Fixed Window | Yes (at boundaries — usually unwanted) | Low | Very low | Quick/simple internal throttling |
| Sliding Window | No | High | Medium | Public APIs needing accurate limits |
| Token Bucket | Yes (bounded, intentional) | High | Medium | Client-facing API rate limiting (most common production choice) |
| Leaky Bucket | No (smooths to constant rate) | High | Medium–High | Shaping traffic into a fixed-capacity downstream system |
Chapter 3. Architecture Decisions & Reference
3.1 When NOT to Use Redis
Redis is extremely versatile, but using it everywhere increases architectural complexity without necessarily providing meaningful benefit. The right question is not "Can Redis solve this?" but:
"Does Redis provide the right consistency, durability, data model, and operational characteristics for this problem?"
3.1.1 Don't use Redis as the default source of truth for relational data
If the application needs complex relationships, joins, referential integrity, multi-row transactions, strong transactional guarantees, or complex analytical queries, a relational database is the better system of record.
Employee
│
├── Department
├── Position
├── Organization
└── Contract
This kind of relational business data should normally stay in the primary relational database:
Application
│
├── Redis → Fast access / cache
│
└── DB2 → Source of truth
3.1.2 Don't use Redis when complex relational queries are the main requirement
Redis is optimized around key/data-structure access. If the primary workload looks like a multi-table JOIN ... GROUP BY ... HAVING query, forcing it into Redis usually makes the system more complicated rather than faster — keep the query in the database that's already optimized for it.
3.1.3 Don't replace a durable event log with Redis Pub/Sub
Pub/Sub is useful for real-time fan-out, but a subscriber that's offline never receives messages published while it was down — there's no persistence or replay. If the system needs durable messages, consumer groups, replay, acknowledgment, or long-term retention, use Redis Streams (§1.20) or a dedicated event-streaming platform such as Kafka.
3.1.4 Don't choose Redis when message routing is the primary requirement
Complex routing, exchanges, dead-lettering, fine-grained delivery semantics, and traditional enterprise broker patterns are RabbitMQ's strength, not Redis's (§1.21).
3.1.5 Don't add Redis simply because "it's faster"
Adding Redis in front of a database introduces real ongoing costs: cache invalidation, serialization, TTL management, memory sizing, failover, monitoring, and consistency edge cases. If the database already satisfies the latency and throughput requirements, Redis may add operational surface area without adding value.
3.1.6 Don't use Redis as a cache for data with no rebuild strategy
Before caching something, ask: "What happens if Redis loses everything?" If the honest answer is "the application cannot recover," that data is actually primary state, not a cache, and belongs in a durable store.
Database → source of truth
Redis → rebuildable acceleration layer
Decision matrix
| Requirement | Redis fit | Better / primary choice |
|---|---|---|
| Low-latency cache | ✅ | Redis |
| Session store | ✅ | Redis |
| Counters | ✅ | Redis |
| Rate limiting | ✅ | Redis |
| Leaderboard | ✅ | Redis Sorted Set |
| Distributed coordination | ✅/⚠️ | Redis, or a coordination system for strict cases (§1.19.3) |
| Complex relational queries | ❌ | RDBMS |
| Strong transactional source of truth | ⚠️ | RDBMS |
| Durable event streaming | ✅/⚠️ | Redis Streams / Kafka |
| Complex message routing | ⚠️ | RabbitMQ |
| Large analytical workloads | ❌/⚠️ | OLAP / data warehouse |
| Long-term archival storage | ❌ | Object storage / database |
| Search-heavy workloads | Depends | Search engine, or Redis Query Engine (§1.5) if data already lives in Redis |
Core principle: Redis should be introduced because it solves a measurable problem — not because it is fast or popular. A good architecture minimizes unnecessary moving parts while meeting the required latency, throughput, consistency, durability, and availability targets.
3.2 Redis System Design Cheat Sheet
A quick decision guide for design sessions or architecture interview questions — most of this simply cross-references the detailed sections above.
3.2.1 Start with the problem
3.2.2 Choose the data structure
| Requirement | Redis structure |
|---|---|
| Simple value / cache | String |
| Object fields | Hash |
| Queue / deque | List |
| Unique membership | Set |
| Ranking / score | Sorted Set |
| Event stream | Stream |
| Bit-level flags | Bitmap |
| Approximate cardinality | HyperLogLog |
| Probabilistic membership | Bloom Filter / related structures (§1.5) |
3.2.3 Choose the caching approach
Default to Cache-Aside via @Cacheable / @CachePut / @CacheEvict and RedisCacheManager (§1.6.2–6.3). Reach for RedisTemplate only for what the annotations don't cover — native data structures, locks, Pub/Sub/Streams, custom stampede logic (§1.6.4).
3.2.4 Handle cache failure modes
| Failure mode | Symptom | Mitigation |
|---|---|---|
| Penetration (§1.8.2) | Non-existent key, every request hits DB | Cache null with short TTL, Bloom filter |
| Stampede (§1.8.1) | One hot key expires, DB overloaded | Distributed lock, TTL jitter, cache warming |
| Avalanche (§1.8.3) | Many keys expire together, DB spike | Randomized TTL, staggered expiration, gradual refresh |
3.2.5 Need a distributed lock?
SET lock:key <unique-token> NX EX <ttl>
Then ask: is duplicate execution acceptable? If yes, a Redis lock (ideally via Redisson RLock) is likely sufficient. If no, add a fencing token, idempotency key, and/or a database transaction — see §1.19.2–19.3. Never release with a plain DEL; always check the owner token first.
3.2.6 Need high availability or more capacity?
Identify the actual bottleneck (§2.1–2.2) before adding nodes.
3.2.7 Need rate limiting or messaging?
Rate limiting: pick an algorithm from §2.4's comparison table based on whether bursts should be allowed and how strict the accuracy requirement is.
Messaging: Pub/Sub for real-time fan-out with no durability need; Streams for durable async processing within Redis; Kafka for large-scale durable event streaming; RabbitMQ for complex routing (§1.20–1.21).
3.2.8 The senior-level mental model
When designing with Redis, work in this order — not "which Redis feature should I use?":
Start from: "What guarantees does the system require, and what is the simplest architecture that provides them?" — not from a specific Redis feature.
3.3 References
- Redis official docs — Persistence (RDB/AOF): https://redis.io/docs/latest/operate/oss_and_stack/management/persistence/
- Redis official docs — Spring Framework integration: https://redis.io/docs/latest/integrate/spring-framework-cache/
- Redis official docs —
INFOcommand / memory metrics reference: https://redis.io/docs/latest/commands/info/ - Redis official docs — Cluster specification (failure detection, quorum, split-brain handling): https://redis.io/docs/latest/operate/oss_and_stack/reference/cluster-spec/
- Redis official docs — Scaling with Redis Cluster (
cluster-node-timeout,cluster-slave-validity-factor): https://redis.io/docs/latest/operate/oss_and_stack/management/scaling/ - Redis official blog — Redis 8 GA / What's new in Redis 8 (hash field TTL, JSON, Query Engine, Vector Sets): https://redis.io/blog/redis-8-ga/
- Spring Data Redis — official reference documentation: https://docs.spring.io/spring-data/redis/reference/index.html
- Spring Data Redis — project page: https://spring.io/projects/spring-data-redis/
- Spring Data Redis 4.0 API —
GenericJacksonJsonRedisSerializer: https://docs.spring.io/spring-data-redis/reference/api/java/org/springframework/data/redis/serializer/GenericJacksonJsonRedisSerializer.html - Redisson (distributed locking library for Java/Spring): https://github.com/redisson/redisson
Document prepared as an interview and architecture reference. Diagrams are simplified for conceptual clarity — validate exact behavior against the Redis version and deployment mode in use before making production decisions.