Logo
HomeAbout MeProjectsBlogsMemoriesContact
HomeAbout MeProjectsBlogsMemoriesContact

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.6.1 The manual approach (for comparison only)
    • 1.6.2 Declarative caching — the recommended default
    • 1.6.3 Production-grade RedisCacheManager configuration
    • 1.6.4 When you still need RedisTemplate
  • 1.7 Cache Invalidation
  • 1.8 Cache Stampede, Penetration, Avalanche
    • 1.8.1 Cache Stampede (Thundering Herd)
    • 1.8.2 Cache Penetration
    • 1.8.3 Cache 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.19.1 The Redlock debate — don't quote it as gospel
    • 1.19.2 Lock expiration is not a correctness guarantee
    • 1.19.3 Alternatives when Redis locking isn't enough
  • 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
    • 2.4.1 Fixed Window — the simplest version
    • 2.4.2 Sliding Window — smooths the boundary problem
    • 2.4.3 Token Bucket — allows controlled bursts
    • 2.4.4 Leaky Bucket — smooths bursts into a constant output rate

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.2.1 Start with the problem
    • 3.2.2 Choose the data structure
    • 3.2.3 Choose the caching approach
    • 3.2.4 Handle cache failure modes
    • 3.2.5 Need a distributed lock?
    • 3.2.6 Need high availability or more capacity?
    • 3.2.7 Need rate limiting or messaging?
    • 3.2.8 The senior-level mental model
  • 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:

RoleExample
CacheMaster data (departments, product catalog)
Session storeShared HTTP session across app instances
Distributed lockPrevent duplicate batch job execution
Counter / rate limiterAPI throttling, OTP attempts
LeaderboardSales ranking via Sorted Set
Pub/SubReal-time notification fan-out
Durable event logRedis 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:

  1. In-memory data path — most operations never touch disk on the critical path.
  2. 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.
  3. Simple access pattern — GET user:100 has no query planning, no join resolution, no execution plan — unlike a relational SELECT ... JOIN ... WHERE ... ORDER BY.
  4. Low protocol overhead — RESP is a compact binary-safe protocol, cheaper to parse than SQL wire protocols for simple key operations.
  5. 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 fsync policy
  • ❌ 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.GET and 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. RedisTemplate still 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 one CacheManager bean, 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:

AnnotationUse forBehavior
@CacheableRead (SELECT / GET)Returns cached value on HIT; on MISS, runs the method and caches the result
@CachePutInsert/Update (INSERT / UPDATE)Always runs the method, then writes the result into the cache (unlike @Cacheable, it never skips execution)
@CacheEvictDelete / Invalidate (DELETE)Removes one entry (or allEntries = true to clear a whole cache region)
@CachingCombine several of the aboveFor 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:

  • PolymorphicTypeValidator scoped 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 @Cacheable by 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 @Transactional commits, 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 (SETNX locking, 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:

StrategyMechanismTrade-off
TTL expiryCache auto-expiresSimple, but bounded staleness window
Delete-on-updateUPDATE DB → DEL cache keyStrong-ish consistency, extra write path
Write-throughApp writes cache + DB togetherCache always fresh, added write latency
Write-behindApp writes cache first, DB asyncFast 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

SentinelCluster
High availability✅✅
Automatic failover✅✅
Data sharding❌✅
Horizontal scalingLimited (vertical + read replicas)✅
ComplexityLowerHigher (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
PolicyBehavior
noevictionReject writes when full (errors)
allkeys-lruEvict least-recently-used key, any key
allkeys-lfuEvict least-frequently-used key, any key
volatile-lruLRU eviction, only among keys with TTL set
volatile-lfuLFU eviction, only among keys with TTL set
volatile-ttlEvict 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/CONFIG access).

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 EX lock 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 EX or 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:

OptionMechanismFit
Database advisory lock / row lockRDBMS-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
ZooKeeperConsensus (ZAB protocol), ephemeral sequential znodesMature, strong guarantees, but adds significant operational overhead
etcdConsensus (Raft), lease-based locksCommon 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 itselfThe 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)RabbitMQKafka
StrengthSimplicity, very low latency, reuse existing infraRouting, flexible delivery guarantees, ack semanticsHigh-throughput event streaming, durable replay, partitioning
Best fitLightweight async tasks, notificationsComplex routing / work queuesEvent 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

StrategyWho manages the cacheTypical use
Cache-AsideApplication codeMost common default (see §1.6.1)
Read-ThroughCaching layer/library fetches on miss transparentlyWhen using a caching abstraction (e.g. @Cacheable)
Write-ThroughApp writes to cache and DB synchronouslyStrong freshness requirement, write-latency tolerant
Write-BehindApp writes to cache, DB updated asynchronouslyWrite-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

  1. What is Redis, and how does it differ from a relational database?
  2. Why is Redis fast?
  3. Is Redis "just RAM"? What about persistence?
  4. Compare RDB and AOF.
  5. 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 includes used_memory plus allocator fragmentation, allocator-reserved-but-idle pages, and other process overhead (code, stack, shared libs).
  • mem_fragmentation_ratio = used_memory_rss / used_memory.
RatioMeaning
~1.0–1.1Healthy — RSS closely tracks actual data
1.1–1.5Normal range, some fragmentation
> 1.5Meaningful fragmentation — investigate; consider active defrag
< 1.0Worse 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

CommandWhat it tells you
INFO memoryQuick overview: used_memory, used_memory_rss, mem_fragmentation_ratio, maxmemory, mem_allocator
MEMORY STATSGranular 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 DOCTORBuilt-in heuristic sanity check with plain-language suggestions
CONFIG SET activedefrag yesEnables 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

CommandPurpose
LATENCY HISTORY <event> / LATENCY LATESTRedis'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
INFOEverything: Server, Clients, Memory, Persistence, Stats, Replication, CPU, Keyspace sections
CLIENT LISTPer-connection detail — idle time, buffer sizes, last command; useful for spotting a client holding an old/blocked connection
MONITORStreams 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

CategoryMetricWhy it matters
Memoryused_memory, mem_fragmentation_ratio, evicted_keysCapacity planning, fragmentation, unwanted eviction under a maxmemory policy
Hit ratiokeyspace_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
Expiryexpired_keysSudden spikes can correlate with an Avalanche event (§1.8)
Connectionsconnected_clients, blocked_clients, rejected_connectionsConnection-pool misconfiguration, client leaks, or hitting maxclients
Throughputinstantaneous_ops_per_secBaseline load, useful to correlate with app-side traffic
LatencyCommand latency via LATENCY HISTORY, or app-side timing around Redis callsDirect user-facing impact
Replicationmaster_repl_offset vs each replica's applied offset (replication lag), connected_slavesRead-from-replica staleness risk (§1.11), early warning before a failover makes lag visible the hard way
SystemCPU, network throughput/bandwidth on the Redis hostRedis 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 than cluster-node-timeout × cluster-replica-validity-factor refuses to participate in a failover election, because its data is considered too stale to safely promote. Setting this to 0 maximizes 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 WAIT for synchronous-ish acknowledgment from N replicas before considering a write "safe" — with the caveat that WAIT still 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

AlgorithmAllows bursts?PrecisionComplexityTypical fit
Fixed WindowYes (at boundaries — usually unwanted)LowVery lowQuick/simple internal throttling
Sliding WindowNoHighMediumPublic APIs needing accurate limits
Token BucketYes (bounded, intentional)HighMediumClient-facing API rate limiting (most common production choice)
Leaky BucketNo (smooths to constant rate)HighMedium–HighShaping 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

RequirementRedis fitBetter / 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 workloadsDependsSearch 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

RequirementRedis structure
Simple value / cacheString
Object fieldsHash
Queue / dequeList
Unique membershipSet
Ranking / scoreSorted Set
Event streamStream
Bit-level flagsBitmap
Approximate cardinalityHyperLogLog
Probabilistic membershipBloom 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 modeSymptomMitigation
Penetration (§1.8.2)Non-existent key, every request hits DBCache null with short TTL, Bloom filter
Stampede (§1.8.1)One hot key expires, DB overloadedDistributed lock, TTL jitter, cache warming
Avalanche (§1.8.3)Many keys expire together, DB spikeRandomized 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 — INFO command / 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.

© 2026 thisisduykhanh. All rights reserved.