Home Engineering

Redis for Beginners: The Cache That Does More Than You Think

14 June 2026 · 21 min read
Table of contents

Most Redis tutorials start with “Redis is an in-memory key-value store.” That sentence is accurate and tells you almost nothing useful. This post starts from the problem Redis was built to solve, then explains each data structure and pattern from the problem it solves, not from what it is called.


Part 1: Why Redis Exists

The latency gap nobody talks about

You build a web app. At first, everything is fast. Then traffic grows. You look at your query logs and see the same queries running thousands of times per minute: “get user profile,” “get product details,” “get homepage feed.” The data barely changes, but you are hitting the database every single time.

Every storage system has a latency profile:

StorageTypical read latency
Redis (in-memory)0.1 to 0.5 ms
PostgreSQL (indexed, local)1 to 5 ms
PostgreSQL (complex join)10 to 100 ms
DynamoDB (single item)1 to 10 ms
S3 (small object)20 to 100 ms

Reading from RAM is measured in nanoseconds. Reading from an SSD is measured in microseconds. Reading from a network database adds milliseconds. At small scale, this does not matter. At large scale, it compounds. If your homepage requires 20 database queries and each takes 5ms, you are already at 100ms just for the data layer, before any rendering.

Without a cache

At 1,000 requests per second, a popular product page that is not cached hammers your database with the same SELECT every millisecond. The database becomes the bottleneck. Query queues grow. Connection pools exhaust. Other queries slow down. Eventually requests time out.

This is called a thundering herd: a large number of concurrent clients all asking for the same thing at the same time, with no shared layer to absorb the load.

A concrete before/after

An e-commerce site has a “featured products” carousel on the homepage. The list changes once per hour. Without caching, every page load triggers a complex query joining products, inventory, and pricing tables. With Redis, the result is cached for 60 minutes. 10,000 homepage visits per minute become 1 database query per hour.

The trade-off you need to understand upfront

Redis is fast because it keeps everything in RAM. RAM is expensive and finite. A Redis instance with 64GB of RAM costs significantly more than a database that stores the same data on SSD. You are trading money for speed, and you are trading simplicity for a new layer to operate and reason about.


Part 2: Core Data Structures

Redis is not just a key-value store that holds strings. It has five primary data structures, each designed for a specific class of problems. Choosing the wrong one leads to inefficient memory use and awkward code.


2.1 String

The problem it solves

The simplest building block. A String in Redis can hold text, numbers, serialized JSON, or binary data up to 512MB. For most caching use cases, this is all you need.

Redis also gives you INCR and DECR: atomic integer operations on String values. This matters more than it sounds.

Without it

If you implement a page view counter in your application layer, two concurrent requests can both read “1000,” both add 1, and both write “1001.” You lose a count. Redis INCR solves this atomically: there is no race condition because the increment is a single server-side operation.

For structured data, if you try to cache a user object without understanding the limitations, you may serialize the whole thing to JSON, cache it, and then find that updating a single field (the user’s email) requires reading the whole blob, deserializing it, changing one field, serializing again, and writing it back. That is a read-modify-write cycle with a race window. The fix is to use a Hash, covered in section 2.2.

Production example

Session tokens:

SET session:abc123 '{"userId": 42, "role": "admin"}' EX 86400
GET session:abc123

Page view counter:

INCR views:post:1234
# Returns 1, 2, 3 ... atomically, no race condition

Code

<!-- pom.xml -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

JedisPool pool = new JedisPool("localhost", 6379);

public Optional<User> getUserProfile(long userId) {
    String cacheKey = "user:" + userId;
    try (Jedis jedis = pool.getResource()) {
        String cached = jedis.get(cacheKey);
        if (cached != null) {
            return Optional.of(objectMapper.readValue(cached, User.class));
        }
        User user = db.findById(userId);
        jedis.setex(cacheKey, 600, objectMapper.writeValueAsString(user));
        return Optional.of(user);
    }
}

public long incrementPageViews(long postId) {
    try (Jedis jedis = pool.getResource()) {
        return jedis.incr("views:post:" + postId);
    }
}

Trade-offs

Storing JSON as a String means deserializing the entire object to read any single field. If you update one field frequently, a Hash is more appropriate. INCR only works on integer values: if the stored value is not a valid integer, Redis returns an error.


2.2 Hash

The problem it solves

A Hash maps field names to values, similar to a Java Map or a JavaScript object. It is designed for objects with multiple attributes that you might read or update individually.

Without it

If you store a user object as a JSON String and you want to update the user’s email, you must GET the whole object, parse it, update the field, serialize it, and SET it back. This is slow and introduces a window for concurrent writes to overwrite each other.

With HSET, you update a single field atomically without touching the others.

Production example

User profile:

HSET user:42 name "Alice" email "alice@example.com" role "admin" login_count 0
HGET user:42 email                    # "alice@example.com"
HINCRBY user:42 login_count 1         # atomic increment of a single field
HMGET user:42 name email              # read multiple fields at once

Feature flags per user:

HSET flags:user:42 dark_mode 1 beta_search 0 new_checkout 1
HGET flags:user:42 new_checkout       # "1"

Code

public void updateUserEmail(long userId, String newEmail) {
    try (Jedis jedis = pool.getResource()) {
        jedis.hset("user:" + userId, "email", newEmail);
    }
}

public Map<String, String> getUserProfile(long userId) {
    try (Jedis jedis = pool.getResource()) {
        return jedis.hgetAll("user:" + userId);
    }
}

public boolean isFeatureEnabled(long userId, String feature) {
    try (Jedis jedis = pool.getResource()) {
        return "1".equals(jedis.hget("flags:user:" + userId, feature));
    }
}

Trade-offs

Hashes are memory-efficient for small objects. Redis uses a compact encoding called listpack when the hash has fewer than 128 fields and values are under 64 bytes. Once you exceed those thresholds, Redis switches to a hash table, which uses more memory. If your object has only one or two fields, a String is simpler and equally fast.


2.3 List

The problem it solves

A List is an ordered sequence of strings with efficient push and pop from both ends. It is backed by a linked list (for large lists) or a compact listpack (for small lists). This makes it the right structure for queues, stacks, and time-ordered feeds.

Without it

Implementing a job queue in a relational database is painful. You need a jobs table, a status column, polling loops with SELECT FOR UPDATE to avoid double-processing, and cleanup logic for stale locks. It works, but it is operationally heavy. Redis Lists give you a queue primitive out of the box, with blocking pop so workers do not spin-loop.

Production example

Background job queue:

RPUSH jobs:email 101 102 103    # enqueue jobs
LPOP jobs:email                 # dequeue: returns "101"
LLEN jobs:email                 # queue depth: 2

# Blocking pop for workers (waits up to 30 seconds for a job)
BLPOP jobs:email 30

Activity feed (keep last 100 items):

LPUSH feed:user:42 "post:567"
LTRIM feed:user:42 0 99         # keep only the 100 most recent
LRANGE feed:user:42 0 9         # read the 10 most recent

Code

public void enqueueEmailJob(String jobId) {
    try (Jedis jedis = pool.getResource()) {
        jedis.rpush("jobs:email", jobId);
    }
}

public void processJobs() {
    try (Jedis jedis = pool.getResource()) {
        while (true) {
            // blocks up to 30 seconds waiting for a job
            List<String> result = jedis.blpop(30, "jobs:email");
            if (result != null) {
                String jobId = result.get(1);
                handleJob(jobId);
            }
        }
    }
}

public void addToFeed(long userId, String postId) {
    try (Jedis jedis = pool.getResource()) {
        String key = "feed:user:" + userId;
        jedis.lpush(key, postId);
        jedis.ltrim(key, 0, 99);    // keep last 100
    }
}

Trade-offs

Redis Lists are not durable job queues by default. If Redis restarts without persistence configured, unprocessed jobs are lost. For high-durability queues, use Redis Streams (which log all messages and support consumer groups) or a dedicated broker like RabbitMQ or SQS. Also, LRANGE on a long list is O(n): avoid ranging over tens of thousands of items in a single call.


2.4 Set

The problem it solves

A Set is an unordered collection of unique strings. Redis enforces uniqueness automatically. It also supports set operations: union, intersection, and difference across multiple sets.

Without it

Tracking unique visitors in a relational database requires either a DISTINCT query over a large table or a separate table with a unique index. Checking “has this user seen this notification” requires a SELECT with a WHERE clause. In Redis, these become O(1) SADD and SISMEMBER operations.

Production example

Unique daily active users:

SADD dau:2026-06-15 user:42 user:99 user:100
SCARD dau:2026-06-15                        # 3
SISMEMBER dau:2026-06-15 user:42            # "1" (yes)
SISMEMBER dau:2026-06-15 user:500           # "0" (no)

Mutual friends:

SADD friends:alice bob charlie dave
SADD friends:bob alice charlie eve
SINTER friends:alice friends:bob            # {"charlie"}

Code

public void trackActiveUser(long userId) {
    String key = "dau:" + LocalDate.now();
    try (Jedis jedis = pool.getResource()) {
        jedis.sadd(key, String.valueOf(userId));
        jedis.expire(key, 86400 * 7);
    }
}

public long dailyActiveCount() {
    try (Jedis jedis = pool.getResource()) {
        return jedis.scard("dau:" + LocalDate.now());
    }
}

public boolean hasSeenNotification(long userId, long notificationId) {
    try (Jedis jedis = pool.getResource()) {
        return jedis.sismember("seen:user:" + userId, String.valueOf(notificationId));
    }
}

Trade-offs

Sets are unordered. If you need insertion order or need to sort by a score, use a Sorted Set. Very large sets with millions of members consume significant memory. For approximate unique counting at massive scale, consider Redis HyperLogLog, which estimates cardinality using a fraction of the memory with a typical error rate of 0.81%.


2.5 Sorted Set

The problem it solves

A Sorted Set is like a Set, but each member has a floating-point score. Members are always kept in ascending score order. You can query ranges by score or by rank. This makes it the natural structure for leaderboards, priority queues, and sliding-window rate limiting.

Without it

A leaderboard in a relational database requires ORDER BY on a frequently updated column, which means an index scan on every read. At high write frequency (thousands of score updates per second), this becomes a bottleneck. Updating and reading a Sorted Set in Redis is O(log n).

Production example

Game leaderboard:

ZADD leaderboard 5000 player:alice
ZADD leaderboard 8200 player:bob
ZADD leaderboard 3100 player:charlie

ZREVRANGE leaderboard 0 9 WITHSCORES    # top 10 (highest score first)
ZREVRANK leaderboard player:alice       # rank from top (0-indexed)
ZINCRBY leaderboard 200 player:alice    # alice scores 200 more points

Resulting order:

Rank  Player           Score
 1    player:bob       8200
 2    player:alice     5200
 3    player:charlie   3100

Code

public List<Map.Entry<String, Double>> getTopPlayers(int count) {
    try (Jedis jedis = pool.getResource()) {
        return new ArrayList<>(
            jedis.zrevrangeWithScores("leaderboard", 0, count - 1)
        );
    }
}

public double updateScore(String playerId, double delta) {
    try (Jedis jedis = pool.getResource()) {
        return jedis.zincrby("leaderboard", delta, playerId);
    }
}

Trade-offs

Sorted Sets use more memory than plain Sets because they store both the member and its score. They maintain a skip list internally for O(log n) range queries, which adds memory overhead. If you only need membership testing without ordering, use a plain Set. If you need to sort by a complex multi-field key, you can encode multiple values into a single score (for example, by combining them into a compound float), but this gets brittle quickly.


Part 3: Expiry and Eviction

The problem it solves

Redis lives in RAM. RAM is finite. If you add keys forever without removing them, you will eventually run out of memory. Two mechanisms handle this: TTL (time-to-live) for planned expiry, and eviction policies for when memory runs out unexpectedly.

Without it

A cache without TTLs grows until the server runs out of memory. Worse, cached data goes stale. A user’s profile cached without an expiry will show the old email address even after the user updates it.

A cache that hits its memory limit without a configured eviction policy will reject all new writes with an OOM error. In production, this means your application starts throwing errors instead of degrading gracefully.

Production example

Cache a product catalog with a 5-minute TTL:

SET catalog:electronics '[ ... ]' EX 300
TTL catalog:electronics                 # remaining seconds

Extend a session TTL on each request (sliding expiry):

GET session:abc123
EXPIRE session:abc123 86400             # reset to 24 hours on activity

Code

public List<Product> getProductCatalog(String category) {
    String key = "catalog:" + category;
    try (Jedis jedis = pool.getResource()) {
        String cached = jedis.get(key);
        if (cached != null) {
            jedis.expire(key, 300);
            return objectMapper.readValue(cached, new TypeReference<>() {});
        }
        List<Product> data = db.fetchCatalog(category);
        jedis.setex(key, 300, objectMapper.writeValueAsString(data));
        return data;
    }
}

Eviction policies

When Redis reaches its maxmemory limit, it evicts keys according to the configured policy. Set it in redis.conf or at runtime:

CONFIG SET maxmemory 2gb
CONFIG SET maxmemory-policy allkeys-lru
PolicyBehaviorWhen to use
noevictionReject new writesPrimary stores; dangerous for caches
allkeys-lruEvict least recently used across all keysGeneral-purpose cache
allkeys-lfuEvict least frequently used across all keysWorkloads with persistent hot keys
volatile-lruEvict LRU only among keys with a TTLMixed cache and permanent data
volatile-ttlEvict keys closest to expiry firstWhen short-lived keys are cheapest to lose

For a cache-only Redis instance, allkeys-lru is the standard choice. If you mix cached data and permanent data (session tokens you never want evicted alongside cached catalog data), use volatile-lru and make sure permanent keys have no TTL.

Trade-offs

LRU eviction does not guarantee that business-critical keys are never evicted. A low-traffic key that is expensive to regenerate (data from a slow external API) can be evicted even if it was recently accessed. The solution is to set explicit TTLs, monitor eviction rate with INFO stats (evicted_keys), and consider a dedicated Redis instance with noeviction for keys that must never disappear.


Part 4: Persistence

The problem it solves

“In-memory” sounds like “data is gone on restart.” That is not true by default. Redis has two persistence mechanisms: RDB snapshots and AOF (append-only file). You can use one, both, or neither depending on your durability requirements.

Without it

If you use Redis as a session store and Redis restarts without persistence, every user is logged out. If you use Redis as a job queue and it restarts mid-job, jobs are lost. “Redis is a cache so it does not need persistence” is a dangerous assumption when Redis is doing more than caching.

RDB snapshots

RDB takes a point-in-time snapshot and writes it to disk as a compact binary file. Configure when snapshots are triggered:

# redis.conf
save 3600 1       # every hour if at least 1 key changed
save 300 100      # every 5 minutes if at least 100 keys changed
save 60 10000     # every minute if at least 10,000 keys changed

BGSAVE            # trigger a background save manually

Advantage: fast restarts, small files, good for backups.

Disadvantage: you can lose up to N minutes of data, where N is your snapshot interval.

AOF (Append-Only File)

AOF logs every write command. On restart, Redis replays the log to reconstruct the dataset:

# redis.conf
appendonly yes
appendfsync everysec    # sync to disk once per second (default; at most 1s of data loss)
# appendfsync always    # sync every write (slowest, most durable)
# appendfsync no        # let the OS decide (fastest, least durable)

Advantage: at most 1 second of data loss with everysec.

Disadvantage: AOF files grow large. Redis compacts the log periodically via BGREWRITEAOF.

What to use

Use caseRecommendation
Pure cache, data fully reconstructableDisable persistence
Session store, job queueAOF with everysec
Business data in RedisBoth RDB and AOF

Trade-offs

Persistence adds I/O overhead. AOF with always is significantly slower than no persistence. RDB snapshots can cause latency spikes: on large datasets, the fork() call copies the process memory map and temporarily doubles memory usage. On memory-constrained servers, this can trigger the OOM killer. Monitor latest_fork_usec in INFO stats to track fork latency.


Part 5: Common Patterns in Production

5.1 Cache-Aside (Lazy Loading)

The problem it solves

Cache-aside is the most common caching pattern. The application is responsible for loading data into the cache on a cache miss. The cache does not automatically populate itself.

Without it

Without any caching, every request hits the database. With a bad implementation of cache-aside (forgetting to write back after a miss, or forgetting to invalidate after a write), you get stale data or no benefit from the cache at all.

How it works

Drag · Scroll to zoom

Production example

public Optional<Post> getPost(long postId) {
    String cacheKey = "post:" + postId;
    try (Jedis jedis = pool.getResource()) {
        String cached = jedis.get(cacheKey);
        if (cached != null) {
            return Optional.of(objectMapper.readValue(cached, Post.class));
        }
        Optional<Post> post = db.findPostById(postId);
        post.ifPresent(p -> jedis.setex(cacheKey, 3600,
            objectMapper.writeValueAsString(p)));
        return post;
    }
}

public void updatePost(long postId, PostUpdate data) {
    db.updatePost(postId, data);
    try (Jedis jedis = pool.getResource()) {
        jedis.del("post:" + postId);    // always invalidate on write
    }
}

Trade-offs

Cache-aside has a consistency window: between a write to the database and the deletion of the cache key, any concurrent reader gets stale data. Always delete the cache key on writes, not just update it (deleting is safer under concurrency). Another risk: if a large number of cache misses happen simultaneously after a Redis restart, all requests hit the database at once. Mitigations include a short per-key lock during the miss, or probabilistic early refresh before the TTL expires.


5.2 Write-Through Cache

The problem it solves

Write-through keeps the cache always up to date by writing to both the cache and the database on every write. The application never reads stale data because the cache is updated before the database write completes.

Without it

Cache-aside has a manual invalidation step that can be forgotten or fail silently (if the DELETE call errors after the database UPDATE succeeds). Write-through removes this asymmetry: the write path always updates the cache.

How it works

Drag · Scroll to zoom

Trade-offs

Write-through means every write touches both Redis and the database, so write latency includes both operations. If you write data that is rarely or never read again, you waste cache space on it. Write-through is most appropriate when your read-to-write ratio is high and you cannot afford to serve stale data even briefly.


5.3 Rate Limiting with Sorted Sets

The problem it solves

Rate limiting protects your API from abuse. A single client should not be able to make 10,000 requests per second. Redis is ideal for distributed rate limiting because all your application servers share a single Redis instance, so limits apply globally across all instances.

Without it

Without rate limiting, a single misbehaving client can exhaust your database connections, CPU, or external API quotas. In-process rate limiting (a counter in application memory) breaks the moment you run more than one server instance: each instance has its own counter and they do not coordinate.

Production example: sliding window

public boolean isRateLimited(String userId, int limit, int windowSeconds) {
    double now = System.currentTimeMillis() / 1000.0;
    String key = "ratelimit:" + userId;
    double windowStart = now - windowSeconds;

    try (Jedis jedis = pool.getResource()) {
        Pipeline pipe = jedis.pipelined();
        pipe.zremrangeByScore(key, 0, windowStart);
        pipe.zadd(key, now, String.valueOf(now));
        Response<Long> count = pipe.zcard(key);
        pipe.expire(key, windowSeconds);
        pipe.sync();
        return count.get() > limit;
    }
}

The score is the Unix timestamp. Each request adds itself to the sorted set. Before counting, you remove all entries older than the window. The count remaining tells you how many requests happened in the last N seconds.

Trade-offs

The sliding window with Sorted Sets is accurate but uses more memory than a fixed window counter (INCR + EXPIRE). Each request is a member in the set. For high-traffic APIs with millions of requests per second, the set grows large. A token bucket or leaky bucket algorithm is more memory-efficient. There is also a subtle issue with the pipeline above: it is not atomic. Under extreme concurrency, two requests can both read the same count and both be allowed through before either write completes. For strict enforcement, use a Lua script to make the read-write cycle atomic.


5.4 Distributed Lock with SET NX

The problem it solves

When multiple instances of your application run concurrently, some operations must only execute once at a time: processing a payment, running a scheduled job, updating a shared counter. A distributed lock in Redis ensures only one instance holds the lock at a time.

Without it

Without a distributed lock, two servers can both start processing the same payment simultaneously. The user gets charged twice. Or two instances run the same cron job, doubling the work and potentially corrupting the output.

Production example

public Optional<String> acquireLock(String resource, int ttlSeconds) {
    String lockKey = "lock:" + resource;
    String lockValue = UUID.randomUUID().toString();

    try (Jedis jedis = pool.getResource()) {
        SetParams params = SetParams.setParams().nx().ex(ttlSeconds);
        String result = jedis.set(lockKey, lockValue, params);
        return "OK".equals(result) ? Optional.of(lockValue) : Optional.empty();
    }
}

public boolean releaseLock(String resource, String lockValue) {
    String lockKey = "lock:" + resource;
    try (Jedis jedis = pool.getResource()) {
        String current = jedis.get(lockKey);
        if (lockValue.equals(current)) {
            jedis.del(lockKey);
            return true;
        }
        return false;
    }
}

// Usage
Optional<String> token = acquireLock("payment:order:987", 30);
if (token.isPresent()) {
    try {
        processPayment(987);
    } finally {
        releaseLock("payment:order:987", token.get());
    }
} else {
    throw new RetryLaterException("Lock not available");
}

Key details:

  • NX means “set only if not exists” (atomic check-and-set, no race condition)
  • EX sets a TTL so locks do not stay forever if the holder crashes
  • The unique value prevents another instance from releasing a lock it does not own

Trade-offs

This implementation has a subtle failure mode in a Redis primary-replica setup: if the primary fails after setting the lock but before replicating to the replica, the replica that becomes the new primary will not have the lock, and another client can acquire it. For the highest durability, the Redis team designed Redlock: acquiring locks on multiple independent Redis nodes simultaneously. For most applications, single-node locking is sufficient. Understand your fault tolerance requirement before adding Redlock complexity.


Part 6: What Redis Is Not

After covering what Redis does well, it is worth being explicit about its limits.

Redis is not a relational database. There are no JOINs, no schema enforcement, and no foreign key constraints. If your data has complex relationships and you need ad-hoc queries, PostgreSQL is the right tool.

Redis is not a durable primary store for financial data. Even with AOF always, there is a small window for data loss. For financial records, an ACID-compliant relational database with synchronous replication is the correct choice. Redis belongs alongside it as a cache or a rate limiter, not instead of it.

Redis is bounded by RAM. If your dataset grows larger than available RAM, Redis will either evict data or reject writes. Redis Cluster lets you distribute data across multiple nodes, each with their own RAM, but it adds operational and query complexity.

Redis Pub/Sub does not persist messages. If a subscriber is offline when a message is published, it misses that message permanently. If you need durable message delivery with consumer groups and replay, use Redis Streams, RabbitMQ, or Kafka.


Part 7: Getting Started

Run locally with Docker

docker run -d --name redis -p 6379:6379 redis:7-alpine
docker exec -it redis redis-cli

Essential redis-cli commands

# Strings
SET hello "world"
GET hello
INCR counter
EXPIRE hello 60
TTL hello

# Hashes
HSET user:1 name "Alice" email "alice@example.com"
HGET user:1 name
HGETALL user:1

# Lists
RPUSH queue job1 job2 job3
LPOP queue
LLEN queue

# Sets
SADD tags "redis" "cache" "backend"
SMEMBERS tags
SISMEMBER tags "redis"

# Sorted Sets
ZADD scores 8200 bob 5000 alice
ZREVRANGE scores 0 -1 WITHSCORES

# Inspection
SCAN 0 MATCH user:* COUNT 100    # prefer over KEYS * in production
TYPE user:1
MEMORY USAGE user:1              # bytes used by a key

Avoid KEYS * in production. On a large keyspace it blocks the server for seconds. Use SCAN instead.

Connecting from Java

<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.0</version>
</dependency>
import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;

JedisPool pool = new JedisPool("localhost", 6379);

// Always use try-with-resources to return the connection to the pool
try (Jedis jedis = pool.getResource()) {
    jedis.set("hello", "world");
    String value = jedis.get("hello");
}

Summary

Redis earns its place in almost every production backend, not because it is magic, but because it solves specific problems precisely:

ProblemRedis tool
Frequently read, rarely changing dataString or Hash with a TTL
Ordered feeds and background job queuesList
Unique membership and set operationsSet
Rankings, leaderboards, rate limitingSorted Set
Mutual exclusion across server instancesDistributed lock (SET NX)

The habits of engineers who use Redis well: always set TTLs, always configure an eviction policy, always know what happens when Redis is unavailable (graceful degradation, not hard failure), and never store data in Redis that cannot be reconstructed from another source.

From here, the natural next topics are Redis Streams for durable message queues with consumer groups, Redis Cluster for horizontal scaling, and Redis Sentinel for automatic failover and high availability.