Redis #

Databases are almost always the first bottleneck when traffic increases. Queries that used to be fast start feeling slow when executed hundreds of times per second, and schemas that seemed efficient start struggling with ever-growing data volumes. Redis steps in as the layer between the application and the database — an in-memory data store capable of serving millions of operations per second with sub-millisecond latency. But Redis isn’t just a simple cache. It’s a data structure server supporting String, Hash, List, Set, Sorted Set, and much more — each with rich operation semantics that can be used to solve problems far beyond simple caching, from rate limiting and session management to distributed locks, real-time leaderboards, and lightweight message queues.

Choosing a Client Library #

In the Java ecosystem, there are two most widely used Redis client libraries: Jedis and Lettuce. Both are mature and production-tested, but have different characteristics.

JedisLettuce
I/O modelSynchronous (blocking)Asynchronous & Reactive
Thread safetyNo — needs a connection poolYes — one connection can be used by many threads
Spring Boot defaultNo (used to be, now Lettuce)Yes (since Spring Boot 2)
Ease of useVery easy, intuitive APISlightly more complex
High throughputNeeds a large poolEfficient with few connections
Redis ClusterSupportedSupported, more mature

For most projects, Jedis with a connection pool is the easiest place to start. Lettuce fits better if you need reactive programming or Spring WebFlux integration.


Setting Up Dependencies #

Jedis #

<!-- Maven -->
<dependency>
    <groupId>redis.clients</groupId>
    <artifactId>jedis</artifactId>
    <version>5.1.3</version>
</dependency>
// Gradle
implementation 'redis.clients:jedis:5.1.3'

Lettuce #

<!-- Maven -->
<dependency>
    <groupId>io.lettuce</groupId>
    <artifactId>lettuce-core</artifactId>
    <version>6.3.2.RELEASE</version>
</dependency>
// Gradle
implementation 'io.lettuce:lettuce-core:6.3.2.RELEASE'

To run Redis locally:

# Docker — the fastest way
docker run -d \
  --name redis \
  -p 6379:6379 \
  redis:7.2-alpine

# With a password
docker run -d \
  --name redis \
  -p 6379:6379 \
  redis:7.2-alpine \
  redis-server --requirepass "secret123"

# Access the Redis CLI
docker exec -it redis redis-cli

Connections and Connection Pools #

Jedis with JedisPool #

Jedis isn’t thread-safe, so you need a connection pool. Each thread takes a connection from the pool, uses it, then returns it to the pool.

import redis.clients.jedis.Jedis;
import redis.clients.jedis.JedisPool;
import redis.clients.jedis.JedisPoolConfig;

public class RedisConnectionFactory {

    // ✗ ANTI-PATTERN: creating a new Jedis every time you need it
    // Every new Jedis() opens a new TCP connection — very expensive
    public static Jedis createInsecure() {
        return new Jedis("localhost", 6379); // don't use this in production
    }

    // ✓ CORRECT: use a JedisPool — one pool, many reused connections
    public static JedisPool createPool() {
        JedisPoolConfig config = new JedisPoolConfig();
        config.setMaxTotal(20);          // max 20 active connections
        config.setMaxIdle(10);           // max 10 idle connections
        config.setMinIdle(2);            // keep at least 2 connections
        config.setTestOnBorrow(true);    // validate connections before use
        config.setTestOnReturn(true);    // validate connections when returned
        config.setBlockWhenExhausted(true);   // wait if the pool is full (don't throw immediately)
        config.setMaxWait(java.time.Duration.ofSeconds(5)); // wait max 5 seconds

        // Without a password
        return new JedisPool(config, "localhost", 6379);

        // With a password
        // return new JedisPool(config, "localhost", 6379, 2000, "secret123");
    }

    // The correct usage — try-with-resources returns it to the pool automatically
    public static void usageExample(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {
            jedis.set("key", "value");
            String value = jedis.get("key");
            System.out.println("Value: " + value);
        } // jedis is automatically returned to the pool here
    }
}

Lettuce — A Single Connection for All Threads #

import io.lettuce.core.RedisClient;
import io.lettuce.core.RedisURI;
import io.lettuce.core.api.StatefulRedisConnection;
import io.lettuce.core.api.sync.RedisCommands;

public class LettuceConnectionFactory {

    public static RedisClient createClient() {
        RedisURI redisUri = RedisURI.builder()
            .withHost("localhost")
            .withPort(6379)
            // .withPassword("secret123".toCharArray())
            .withDatabase(0)
            .withTimeout(java.time.Duration.ofSeconds(5))
            .build();

        return RedisClient.create(redisUri);
    }

    public static void usageExample(RedisClient client) {
        // One StatefulRedisConnection can be used from many threads
        try (StatefulRedisConnection<String, String> connection = client.connect()) {
            RedisCommands<String, String> commands = connection.sync();

            commands.set("key", "value");
            String value = commands.get("key");
            System.out.println("Value: " + value);
        }
        // Close the client when the application shuts down
        // client.shutdown();
    }
}

The String Data Structure #

String is Redis’s most basic data type. Despite the name, it can store text, numbers, or binary data (up to 512 MB).

public class RedisStringDemo {

    private final JedisPool pool;

    public RedisStringDemo(JedisPool pool) {
        this.pool = pool;
    }

    public void demo() {
        try (Jedis jedis = pool.getResource()) {

            // Basic SET and GET
            jedis.set("name", "Budi");
            String name = jedis.get("name");
            System.out.println("name: " + name); // "Budi"

            // SET with TTL (expire) — the key is automatically deleted after N seconds
            jedis.setex("session:abc123", 3600, "{\"userId\":42,\"role\":\"admin\"}");
            // or use SET with the EX option
            jedis.set("token:xyz", "bearer-token", redis.clients.jedis.params.SetParams.setParams().ex(1800));

            // Check the remaining TTL
            long ttl = jedis.ttl("session:abc123");
            System.out.println("Remaining TTL: " + ttl + " seconds");

            // INCR / DECR — atomic increment for counters
            jedis.set("counter:views", "0");
            jedis.incr("counter:views");   // 1
            jedis.incr("counter:views");   // 2
            jedis.incrBy("counter:views", 10); // 12
            System.out.println("Views: " + jedis.get("counter:views")); // "12"

            // SETNX — set only if the key doesn't exist (for simple distributed locks)
            boolean set = jedis.setnx("lock:resource", "1") == 1;
            System.out.println("Lock acquired: " + set);

            // GETSET — get the old value, set the new value atomically
            String oldValue = jedis.getSet("name", "Andi");
            System.out.println("Old value: " + oldValue + ", new value: " + jedis.get("name"));

            // MGET / MSET — batch operations
            jedis.mset("city", "Jakarta", "country", "Indonesia", "continent", "Asia");
            java.util.List<String> results = jedis.mget("city", "country", "continent");
            System.out.println("Batch: " + results); // [Jakarta, Indonesia, Asia]

            // EXISTS and DELETE
            boolean exists = jedis.exists("name");
            jedis.del("name", "city"); // delete multiple keys at once
        }
    }
}

The Hash Data Structure #

Hashes store field-value pairs under a single key. They’re perfect for representing objects — more efficient than storing a JSON string because fields can be accessed or updated individually.

public class RedisHashDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // HSET — set one or more fields
            jedis.hset("user:1001", "name", "Sari");
            jedis.hset("user:1001", "email", "[email protected]");
            jedis.hset("user:1001", "age", "28");

            // Or set many fields at once
            jedis.hset("user:1002", java.util.Map.of(
                "name", "Budi",
                "email", "[email protected]",
                "age", "35",
                "city", "Bandung"
            ));

            // HGET — get a single field
            String email = jedis.hget("user:1001", "email");
            System.out.println("Email: " + email);

            // HGETALL — get all fields and values
            java.util.Map<String, String> user = jedis.hgetAll("user:1001");
            System.out.println("User: " + user);

            // HMGET — get several fields at once
            java.util.List<String> fields = jedis.hmget("user:1002", "name", "city");
            System.out.println("Name and city: " + fields);

            // HINCRBY — increment a numeric field atomically
            jedis.hincrBy("user:1001", "age", 1); // birthday!

            // HEXISTS — check whether a field exists
            boolean hasCity = jedis.hexists("user:1001", "city");
            System.out.println("Has a city field: " + hasCity); // false

            // HDEL — delete specific fields
            jedis.hdel("user:1002", "city");

            // HKEYS / HVALS / HLEN
            java.util.Set<String> keys = jedis.hkeys("user:1002");
            java.util.List<String> values = jedis.hvals("user:1002");
            long fieldCount = jedis.hlen("user:1002");
            System.out.println("Fields: " + keys + " | Count: " + fieldCount);
        }
    }
}

The List Data Structure #

Lists are linked lists supporting push and pop operations from both ends. Suitable for queues, stacks, activity feeds, and bounded logs.

public class RedisListDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // LPUSH / RPUSH — add elements to the left / right
            jedis.rpush("queue:email", "email-1", "email-2", "email-3");
            jedis.lpush("queue:email", "priority-email"); // goes to the front

            // LRANGE — get elements in a range (0 = first, -1 = last)
            java.util.List<String> all = jedis.lrange("queue:email", 0, -1);
            System.out.println("Queue contents: " + all);
            // [priority-email, email-1, email-2, email-3]

            // LLEN — list length
            long length = jedis.llen("queue:email");
            System.out.println("Count: " + length);

            // LPOP / RPOP — get and remove from the left / right
            String first = jedis.lpop("queue:email"); // dequeue from the front
            System.out.println("Processed: " + first); // priority-email

            // BLPOP — blocking pop — waits until an element is available (ideal for workers)
            // If the list is empty, blocks until an element arrives or the timeout (seconds)
            java.util.List<String> result = jedis.blpop(5, "queue:email", "queue:sms");
            if (result != null) {
                System.out.println("Queue: " + result.get(0) + " | Message: " + result.get(1));
            }

            // LINSERT — insert an element before / after a specific element
            jedis.linsert("queue:email", redis.clients.jedis.args.ListDirection.BEFORE,
                "email-2", "email-1.5");

            // LTRIM — trim the list, keep only elements in the range
            // Useful for keeping a list from growing unbounded
            jedis.rpush("log:activity", "login", "open-page", "logout");
            jedis.ltrim("log:activity", 0, 99); // keep only the last 100
        }
    }
}

Pattern: A Simple Task Queue with Lists #

public class RedisTaskQueue {

    private static final String QUEUE_KEY = "tasks:pending";
    private static final String PROCESSING_KEY = "tasks:processing";
    private final JedisPool pool;

    public RedisTaskQueue(JedisPool pool) {
        this.pool = pool;
    }

    // Producer: add a task to the queue
    public void enqueue(String task) {
        try (Jedis jedis = pool.getResource()) {
            jedis.rpush(QUEUE_KEY, task);
        }
    }

    // Consumer: take and process a task (blocking)
    public void startWorker() {
        System.out.println("Worker ready to process tasks...");
        while (true) {
            try (Jedis jedis = pool.getResource()) {
                // BRPOPLPUSH — atomic: take from the queue, move to the processing list
                // If a worker crashes, the task is still in the processing list
                String task = jedis.brpoplpush(QUEUE_KEY, PROCESSING_KEY, 5);
                if (task == null) continue; // timeout, try again

                try {
                    System.out.println("Processing: " + task);
                    doWork(task);
                    // Remove from the processing list after finishing
                    jedis.lrem(PROCESSING_KEY, 1, task);
                } catch (Exception e) {
                    System.err.println("Failed to process task: " + e.getMessage());
                    // The task stays in the processing list for manual recovery
                }
            }
        }
    }

    private void doWork(String task) throws Exception {
        Thread.sleep(100); // simulate work
    }
}

The Set Data Structure #

Sets are unordered collections of unique strings. Suitable for tags, membership checks, and set operations (union, intersection, difference).

public class RedisSetDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // SADD — add one or more members
            jedis.sadd("tag:article:1", "java", "redis", "backend", "tutorial");
            jedis.sadd("tag:article:2", "java", "spring", "backend", "microservice");

            // SMEMBERS — get all members
            java.util.Set<String> tags = jedis.smembers("tag:article:1");
            System.out.println("Tags: " + tags);

            // SISMEMBER — membership check — O(1)
            boolean hasJava = jedis.sismember("tag:article:1", "java");
            System.out.println("Has the java tag: " + hasJava);

            // SCARD — member count
            long count = jedis.scard("tag:article:1");
            System.out.println("Tag count: " + count);

            // Set operations
            // SINTER — intersection (tags in BOTH articles)
            java.util.Set<String> intersection = jedis.sinter("tag:article:1", "tag:article:2");
            System.out.println("Shared tags: " + intersection); // [java, backend]

            // SUNION — union (all tags from all articles)
            java.util.Set<String> union = jedis.sunion("tag:article:1", "tag:article:2");
            System.out.println("All tags: " + union);

            // SDIFF — difference (tags in article 1 that aren't in article 2)
            java.util.Set<String> difference = jedis.sdiff("tag:article:1", "tag:article:2");
            System.out.println("Unique tags in article 1: " + difference); // [redis, tutorial]

            // SREM — remove members
            jedis.srem("tag:article:1", "tutorial");

            // SRANDMEMBER — get random members (for recommendations, sampling)
            String random = jedis.srandmember("tag:article:1");
            java.util.List<String> someRandom = jedis.srandmember("tag:article:1", 2);
        }
    }
}

The Sorted Set Data Structure #

Sorted Sets are like Sets, but each member has a score (a float). Members are ordered by score. This is Redis’s most versatile data structure — suitable for leaderboards, priority queues, and score-based range queries.

public class RedisSortedSetDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // ZADD — add members with scores
            jedis.zadd("leaderboard:game1", 1500.0, "player:alice");
            jedis.zadd("leaderboard:game1", 2300.0, "player:budi");
            jedis.zadd("leaderboard:game1", 1800.0, "player:citra");
            jedis.zadd("leaderboard:game1", 3100.0, "player:dani");
            jedis.zadd("leaderboard:game1", 2100.0, "player:eka");

            // ZRANGE — get members by rank (ascending, index 0 = lowest score)
            java.util.List<String> lowest = jedis.zrange("leaderboard:game1", 0, -1);
            System.out.println("Lowest to highest: " + lowest);

            // ZREVRANGE — highest to lowest order (top players)
            java.util.List<String> top3 = jedis.zrevrange("leaderboard:game1", 0, 2);
            System.out.println("Top 3: " + top3); // [dani, budi, eka]

            // ZRANGEBYSCORE — get members with scores in a certain range
            java.util.List<String> middle = jedis.zrangeByScore(
                "leaderboard:game1", 1800, 2300);
            System.out.println("Score 1800-2300: " + middle);

            // ZSCORE — get one member's score
            Double score = jedis.zscore("leaderboard:game1", "player:alice");
            System.out.println("Alice's score: " + score);

            // ZRANK / ZREVRANK — position in the ranking (0-based)
            Long rankAsc = jedis.zrank("leaderboard:game1", "player:budi");
            Long rankDesc = jedis.zrevrank("leaderboard:game1", "player:budi");
            System.out.println("Budi's rank (asc): " + rankAsc + " | (desc): " + rankDesc);

            // ZINCRBY — increment a score atomically (for real-time score updates)
            jedis.zincrBy("leaderboard:game1", 500.0, "player:alice");

            // ZCARD — member count
            long total = jedis.zcard("leaderboard:game1");

            // ZCOUNT — count members within a score range
            long count = jedis.zcount("leaderboard:game1", 2000, 3000);
            System.out.println("Players with scores 2000-3000: " + count);

            // ZREM — remove members
            jedis.zrem("leaderboard:game1", "player:eka");
        }
    }
}

TTL and Eviction Policies #

Redis in production must be configured correctly to avoid running out of memory.

Setting TTLs #

public class RedisTTLDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // Set a TTL when creating the key
            jedis.setex("session:user123", 1800, "session-data"); // 30 minutes

            // Add a TTL to an existing key
            jedis.set("cache:product:456", "{...}");
            jedis.expire("cache:product:456", 300); // 5 minutes

            // Remove the TTL — make the key permanent again
            jedis.persist("cache:product:456");

            // Check the TTL
            long ttl = jedis.ttl("session:user123"); // in seconds, -1 = no TTL, -2 = no key
            long pttl = jedis.pttl("session:user123"); // in milliseconds

            // Check when a key expires (Unix timestamp in milliseconds)
            jedis.expireAt("token:abc", System.currentTimeMillis() / 1000 + 3600); // expires 1 hour from now
        }
    }
}

Eviction Policies #

When Redis reaches its memory limit (maxmemory), it runs an eviction policy to automatically remove keys. Choose the right policy:

noeviction         → reject new writes when memory is full (default, bad for caches)
allkeys-lru        → evict the least recently used (LRU) keys from all keys
volatile-lru       → evict the least recently used keys with a TTL
allkeys-lfu        → evict the least frequently used (LFU) keys from all keys
volatile-lfu       → evict the least frequently used keys with a TTL
allkeys-random     → evict keys randomly from all keys
volatile-random    → evict keys with a TTL randomly
volatile-ttl       → evict the keys closest to expiring

For general caching use cases, allkeys-lru or allkeys-lfu are the best choices:

# In redis.conf
maxmemory 512mb
maxmemory-policy allkeys-lru

# Or via the CLI
redis-cli CONFIG SET maxmemory 512mb
redis-cli CONFIG SET maxmemory-policy allkeys-lru

Caching Patterns #

Cache-Aside (Lazy Loading) #

The most common pattern — data is loaded into the cache only when needed:

public class CacheAsidePattern {

    private final JedisPool pool;
    private final DataRepository repository; // simulates database access

    public CacheAsidePattern(JedisPool pool, DataRepository repository) {
        this.pool = pool;
        this.repository = repository;
    }

    public String getProduct(String productId) {
        String cacheKey = "cache:product:" + productId;

        try (Jedis jedis = pool.getResource()) {
            // 1. Check the cache first
            String cached = jedis.get(cacheKey);
            if (cached != null) {
                System.out.println("Cache HIT for product " + productId);
                return cached;
            }

            // 2. Cache MISS — fetch from the database
            System.out.println("Cache MISS for product " + productId);
            String data = repository.findProductById(productId);

            if (data != null) {
                // 3. Store in the cache with a TTL
                jedis.setex(cacheKey, 300, data); // cache for 5 minutes
            }

            return data;
        }
    }

    // Invalidate the cache when data changes
    public void updateProduct(String productId, String data) {
        repository.updateProduct(productId, data);

        try (Jedis jedis = pool.getResource()) {
            // Delete the old cache — it will be reloaded on the next access
            jedis.del("cache:product:" + productId);
        }
    }

    interface DataRepository {
        String findProductById(String id);
        void updateProduct(String id, String data);
    }
}

Write-Through #

Data is written to the cache and the database simultaneously:

public class WriteThroughPattern {

    private final JedisPool pool;
    private final DataRepository repository;
    private static final int CACHE_TTL = 600; // 10 minutes

    public WriteThroughPattern(JedisPool pool, DataRepository repository) {
        this.pool = pool;
        this.repository = repository;
    }

    public void saveProduct(String productId, String data) {
        // Write to the database
        repository.updateProduct(productId, data);

        // Update the cache immediately — no stale data window
        try (Jedis jedis = pool.getResource()) {
            jedis.setex("cache:product:" + productId, CACHE_TTL, data);
        }
    }

    public String getProduct(String productId) {
        try (Jedis jedis = pool.getResource()) {
            String cached = jedis.get("cache:product:" + productId);
            if (cached != null) return cached;
            return repository.findProductById(productId);
        }
    }

    interface DataRepository {
        String findProductById(String id);
        void updateProduct(String id, String data);
    }
}

Distributed Locks #

Redis is often used to implement distributed locks — ensuring only one process executes a critical section at a time in a distributed environment.

import java.util.UUID;

public class RedisDistributedLock {

    private final JedisPool pool;
    private static final String LOCK_PREFIX = "lock:";
    private static final int DEFAULT_TIMEOUT_MS = 5000; // 5 seconds

    public RedisDistributedLock(JedisPool pool) {
        this.pool = pool;
    }

    // Try to acquire the lock
    // Returns a lockToken on success, null if the lock is held by another process
    public String tryAcquire(String resourceName, int ttlSeconds) {
        String lockKey = LOCK_PREFIX + resourceName;
        String lockToken = UUID.randomUUID().toString(); // unique token to identify the owner

        try (Jedis jedis = pool.getResource()) {
            // SET NX EX — set only if it doesn't exist, with a TTL
            // This is atomic — no race condition between the check and the set
            String result = jedis.set(
                lockKey,
                lockToken,
                redis.clients.jedis.params.SetParams.setParams()
                    .nx()            // only set if the key does NOT exist
                    .ex(ttlSeconds)  // automatic TTL — the lock never hangs forever
            );

            if ("OK".equals(result)) {
                System.out.println("Lock acquired: " + resourceName + " (token: " + lockToken + ")");
                return lockToken;
            }

            System.out.println("Lock unavailable: " + resourceName);
            return null;
        }
    }

    // Release the lock — only if the token matches (you actually hold the lock)
    public boolean release(String resourceName, String lockToken) {
        String lockKey = LOCK_PREFIX + resourceName;

        try (Jedis jedis = pool.getResource()) {
            // ANTI-PATTERN: GET then DEL — there's a race condition between the two
            // String current = jedis.get(lockKey);
            // if (lockToken.equals(current)) jedis.del(lockKey); // ← not atomic!

            // ✓ CORRECT: use a Lua script for an atomic GET + DEL operation
            String luaScript =
                "if redis.call('GET', KEYS[1]) == ARGV[1] then " +
                "    return redis.call('DEL', KEYS[1]) " +
                "else " +
                "    return 0 " +
                "end";

            Object result = jedis.eval(luaScript,
                java.util.List.of(lockKey),
                java.util.List.of(lockToken)
            );

            boolean released = Long.valueOf(1).equals(result);
            if (released) {
                System.out.println("Lock released: " + resourceName);
            } else {
                System.out.println("Failed to release the lock — not the owner or already expired: " + resourceName);
            }
            return released;
        }
    }

    // Execute a block of code with a lock
    public <T> T withLock(String resourceName, int ttlSeconds,
                           java.util.concurrent.Callable<T> action) throws Exception {
        String token = null;
        long deadline = System.currentTimeMillis() + DEFAULT_TIMEOUT_MS;

        // Retry until the lock is available or the timeout
        while (System.currentTimeMillis() < deadline) {
            token = tryAcquire(resourceName, ttlSeconds);
            if (token != null) break;
            Thread.sleep(50 + (long)(Math.random() * 50)); // jitter to avoid a thundering herd
        }

        if (token == null) {
            throw new RuntimeException("Failed to acquire the lock for: " + resourceName);
        }

        try {
            return action.call();
        } finally {
            release(resourceName, token);
        }
    }
}
sequenceDiagram
    participant A as Service A
    participant B as Service B
    participant R as Redis

    A->>R: SET lock:order NX EX 30 "token-A"
    R-->>A: OK (lock acquired)

    B->>R: SET lock:order NX EX 30 "token-B"
    R-->>B: nil (lock already exists)

    Note over A: execute critical logic
    A->>R: EVAL lua — DEL if the token matches
    R-->>A: 1 (successfully released)

    B->>R: SET lock:order NX EX 30 "token-B"
    R-->>B: OK (can now acquire the lock)

Pipelines — Reducing Round Trips #

Every Redis command needs one round trip to the server. Pipelines send many commands at once and read all the responses at the end — very effective for batch operations.

public class RedisPipelineDemo {

    public void demo(JedisPool pool) {
        try (Jedis jedis = pool.getResource()) {

            // ✗ ANTI-PATTERN: sending one by one — N round trips for N commands
            for (int i = 0; i < 100; i++) {
                jedis.set("key:" + i, "value:" + i);
            }

            // ✓ CORRECT: pipeline — 1 round trip for 100 commands
            redis.clients.jedis.Pipeline pipeline = jedis.pipelined();

            for (int i = 0; i < 100; i++) {
                pipeline.set("key:" + i, "value:" + i);
                pipeline.expire("key:" + i, 300);
            }

            // Send everything at once and wait for the responses
            java.util.List<Object> responses = pipeline.syncAndReturnAll();
            System.out.println("Pipeline done: " + responses.size() + " responses received");
        }
    }

    // Pipeline for batch reads
    public java.util.List<String> batchGet(JedisPool pool, java.util.List<String> keys) {
        try (Jedis jedis = pool.getResource()) {
            redis.clients.jedis.Pipeline pipeline = jedis.pipelined();

            java.util.List<redis.clients.jedis.Response<String>> futures = new java.util.ArrayList<>();
            for (String key : keys) {
                futures.add(pipeline.get(key));
            }

            pipeline.sync();

            java.util.List<String> results = new java.util.ArrayList<>();
            for (redis.clients.jedis.Response<String> future : futures) {
                results.add(future.get());
            }
            return results;
        }
    }
}

Pub/Sub in Redis #

Redis supports a simple publish/subscribe pattern. Unlike Kafka or RabbitMQ, Redis Pub/Sub doesn’t store messages — if there’s no subscriber when a message is sent, the message is lost. Suitable for real-time notifications, cache invalidation, and simple events.

import redis.clients.jedis.JedisPubSub;

public class RedisPubSubDemo {

    // Subscriber — runs on a separate thread
    public static class NotificationSubscriber extends JedisPubSub {

        @Override
        public void onMessage(String channel, String message) {
            System.out.printf("[%s] Message received: %s%n", channel, message);
            // Process the message
        }

        @Override
        public void onSubscribe(String channel, int subscribedChannels) {
            System.out.println("Subscribed to channel: " + channel);
        }

        @Override
        public void onUnsubscribe(String channel, int subscribedChannels) {
            System.out.println("Unsubscribed from channel: " + channel);
        }
    }

    public static void startSubscriber(JedisPool pool) {
        // Subscribing must run on a separate thread because the operation is blocking
        Thread subscriberThread = new Thread(() -> {
            try (Jedis jedis = pool.getResource()) {
                NotificationSubscriber subscriber = new NotificationSubscriber();
                // Subscribe to one or more channels
                jedis.subscribe(subscriber, "notif:order", "notif:payment");
                // or subscribe using a pattern
                // jedis.psubscribe(subscriber, "notif:*");
            }
        });
        subscriberThread.setDaemon(true);
        subscriberThread.start();
    }

    public static void publish(JedisPool pool, String channel, String message) {
        try (Jedis jedis = pool.getResource()) {
            long subscriberCount = jedis.publish(channel, message);
            System.out.printf("Message sent to %d subscribers on channel '%s'%n",
                subscriberCount, channel);
        }
    }
}

When to Use Redis and When Not To #

USE REDIS WHEN:
  ✓ A cache layer to reduce database load (sessions, query results, pages)
  ✓ Rate limiting — atomic counters per IP/user with TTLs
  ✓ Distributed locks — coordination between instances/pods
  ✓ Leaderboards and rankings — Sorted Sets with ZINCRBY
  ✓ Session stores — Hashes per session with TTLs
  ✓ Simple task queues — Lists with BRPOPLPUSH
  ✓ Lightweight Pub/Sub — real-time notifications, cache invalidation
  ✓ Simple counting and analytics — INCR, HINCRBY

CONSIDER ALTERNATIVES WHEN:
  ✗ Data must never be lost → a relational database
  ✗ Very limited memory and large data → Memcached (more memory efficient)
  ✗ You need complex queries over cached data → store it in the DB, not Redis
  ✗ You need a reliable message queue with a DLQ → RabbitMQ, Kafka, or SQS
  ✗ Data larger than the available RAM → Redis isn't suitable as a primary store

Summary #

  • Use a JedisPool, not a new connection per request — open the Jedis in try-with-resources so it’s automatically returned to the pool when done.
  • Strings suit atomic counters (INCR), session tokens, and simple caches. Hashes suit objects with many individually updated fields.
  • Lists support queues with RPUSH/BLPOP and stacks with LPUSH/LPOP. BLPOP is the efficient way to run worker queues without busy loops.
  • Sets for O(1) membership checks and set operations (union, intersection, difference). Sorted Sets for leaderboards, priority queues, and score-based range queries.
  • Always set TTLs on cache keys — use SETEX or SET ... EX. Configure maxmemory-policy allkeys-lru for caches that manage memory automatically.
  • Cache-aside is the most common pattern — read from the cache, go to the database on a miss, then store in the cache. Always invalidate the cache when data changes.
  • Distributed locks use SET NX EX (atomic) and a Lua script for safe release — don’t use separate GET + DEL because of the race condition.
  • Pipelines drastically reduce round trips for batch operations — use them when you need to set or get many keys at once.
  • Redis Pub/Sub suits lightweight real-time notifications, not as a replacement for a reliable message broker — messages are lost if there’s no subscriber.

← Previous: Google Pub/Sub   Next: Memcached →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact