Redis #

Redis (Remote Dictionary Server) is an extremely fast in-memory data store — with sub-millisecond latency for most operations. It supports various data structures: String, Hash, List, Set, Sorted Set, and more. Redis is used for caching database query results, storing user sessions, rate limiting, distributed locks, real-time leaderboards, and even as a simple message broker. In Kotlin, there are two popular libraries: Lettuce (async, thread-safe, Netty-based, recommended) and Jedis (synchronous, simpler). This article covers both with a focus on Lettuce, spanning all Redis data types, common caching patterns, and advanced usage.

When to Use Redis #

USE Redis for:
  ✓ Caching expensive database query results (reduce DB load)
  ✓ Session stores (more scalable than in-memory sessions)
  ✓ Rate limiting (prevent API abuse)
  ✓ Distributed locks (coordination between application instances)
  ✓ Leaderboards/rankings (Sorted Sets)
  ✓ Simple queues (Lists with LPUSH/BRPOP)
  ✓ Real-time counters (INCR, DECR)
  ✓ Simple Pub/Sub between services

DON'T use Redis for:
  ✗ Primary data (Redis is in-memory, data is lost if the server dies without persistence)
  ✗ Complex queries with joins between entities
  ✗ Data that must not be lost without a backup strategy
  ✗ Data exceeding the server's RAM capacity

Setup and Dependencies #

// build.gradle.kts
dependencies {
    // Lettuce — recommended (async, thread-safe)
    implementation("io.lettuce:lettuce-core:6.3.2.RELEASE")

    // Jedis — a simpler alternative (sync)
    // implementation("redis.clients:jedis:5.1.2")

    // kotlinx.serialization for serializing values
    implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")

    // Coroutines
    implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}

Connecting with Lettuce #

import io.lettuce.core.RedisClient
import io.lettuce.core.RedisURI
import io.lettuce.core.api.StatefulRedisConnection
import io.lettuce.core.api.sync.RedisCommands
import io.lettuce.core.support.ConnectionPoolSupport
import org.apache.commons.pool2.impl.GenericObjectPool
import org.apache.commons.pool2.impl.GenericObjectPoolConfig
import java.time.Duration

object RedisConnection {
    private val uri = RedisURI.builder()
        .withHost(System.getenv("REDIS_HOST") ?: "localhost")
        .withPort(System.getenv("REDIS_PORT")?.toInt() ?: 6379)
        .also { builder ->
            System.getenv("REDIS_PASSWORD")?.let { builder.withPassword(it.toCharArray()) }
        }
        .withDatabase(System.getenv("REDIS_DB")?.toInt() ?: 0)
        .withTimeout(Duration.ofSeconds(10))
        .build()

    private val client = RedisClient.create(uri)

    // One connection can be used from one thread — use a pool for multi-threading
    val connection: StatefulRedisConnection<String, String> by lazy {
        client.connect()
    }

    // Connection pool for multi-threaded environments
    val pool: GenericObjectPool<StatefulRedisConnection<String, String>> by lazy {
        val config = GenericObjectPoolConfig<StatefulRedisConnection<String, String>>().apply {
            maxTotal = 10      // max 10 connections
            maxIdle = 5        // max 5 idle connections
            minIdle = 2        // min 2 connections always ready
            testOnBorrow = true
        }
        ConnectionPoolSupport.createGenericObjectPool({ client.connect() }, config)
    }

    // Safely use a connection from the pool
    fun <T> use(block: (RedisCommands<String, String>) -> T): T {
        return pool.borrowObject().use { conn ->
            block(conn.sync())
        }
    }

    fun close() {
        pool.close()
        client.shutdown()
    }
}

fun main() {
    val info = RedisConnection.use { redis ->
        redis.ping()
    }
    println("Redis OK: $info")  // PONG
}

The String Data Type — The Most Basic #

String is Redis’s most common data type. It can store text, numbers, or binary data:

fun stringExample() {
    RedisConnection.use { redis ->
        // SET and GET
        redis.set("nama", "Budi Santoso")
        println(redis.get("nama"))  // Budi Santoso

        // SET with TTL (expires in seconds)
        redis.setex("sesi:usr123", 3600, "user session data")  // expires in 1 hour
        println(redis.ttl("sesi:usr123"))  // ~3600 seconds remaining

        // SET only if the key doesn't exist (atomic)
        val success = redis.setnx("kunci.unik", "nilai")
        println(success)  // true if successful, false if the key already exists

        // SETEX with NX or XX conditions
        redis.set("kunci", "nilai", io.lettuce.core.SetArgs.Builder.nx().ex(60))
        // NX = only if not present, EX = expire in 60 seconds

        // INCR and DECR — atomic, safe for counters
        redis.set("pengunjung", "0")
        redis.incr("pengunjung")          // 1
        redis.incrby("pengunjung", 5)     // 6
        redis.decr("pengunjung")          // 5
        println(redis.get("pengunjung"))  // 5

        // DEL — delete a key
        redis.del("nama", "pengunjung")

        // EXISTS — check whether a key exists
        println(redis.exists("nama"))  // 0 (doesn't exist)

        // KEYS with a pattern (be careful in production!)
        redis.set("produk:1", "Laptop")
        redis.set("produk:2", "Mouse")
        redis.set("produk:3", "Keyboard")
        println(redis.keys("produk:*"))  // [produk:1, produk:2, produk:3]
        // DON'T use KEYS in production with lots of data — use SCAN
    }
}

The Hash Data Type — Multi-Field Objects #

Hashes are great for storing objects with many fields, like user profiles:

fun hashExample() {
    RedisConnection.use { redis ->
        val key = "pengguna:1001"

        // HSET — set one or many fields
        redis.hset(key, mapOf(
            "nama"    to "Budi Santoso",
            "email"   to "[email protected]",
            "umur"    to "28",
            "kota"    to "Jakarta"
        ))

        // HGET — get one field
        println(redis.hget(key, "nama"))     // Budi Santoso

        // HMGET — get many fields at once
        val result = redis.hmget(key, "nama", "email", "kota")
        println(result)  // [Budi Santoso, [email protected], Jakarta]

        // HGETALL — get all fields as a Map
        val allFields = redis.hgetall(key)
        allFields.forEach { (field, value) -> println("$field: $value") }

        // HDEL — delete specific fields
        redis.hdel(key, "umur")

        // HEXISTS — check whether a field exists
        println(redis.hexists(key, "email"))   // true
        println(redis.hexists(key, "umur"))    // false

        // HLEN — the number of fields
        println(redis.hlen(key))  // 3

        // HINCRBY — increment a numeric value in a hash
        redis.hset(key, "loginCount", "0")
        redis.hincrby(key, "loginCount", 1)
        println(redis.hget(key, "loginCount"))  // 1

        // Set a TTL on the hash
        redis.expire(key, 3600)
    }
}

The List Data Type — Queues and Stacks #

A List is a linked list supporting operations on both ends:

fun listExample() {
    RedisConnection.use { redis ->
        val key = "riwayat:pengguna:1001"

        // RPUSH — add to the right (tail)
        redis.rpush(key, "login-2024-01-01", "beli-produk", "logout")

        // LPUSH — add to the left (head) — for stacks
        redis.lpush("notifikasi:inbox", "Order confirmed", "Payment successful")

        // LRANGE — get elements within a range (0 = first, -1 = last)
        val all = redis.lrange(key, 0, -1)
        println(all)  // [login-2024-01-01, beli-produk, logout]

        // LLEN — list length
        println(redis.llen(key))  // 3

        // LPOP / RPOP — get and remove from the left/right
        val first = redis.lpop(key)  // login-2024-01-01
        val last = redis.rpop(key) // logout

        // LINDEX — access an element at a specific index
        println(redis.lindex(key, 0))  // beli-produk (remaining after 2 pops)

        // BRPOP — blocking pop: wait until an element is available (for task queues)
        // redis.brpop(5, "antrian:tugas")  // wait max 5 seconds

        // LTRIM — keep only elements within a range (trim the list)
        redis.rpush("log:recent", "event1", "event2", "event3", "event4", "event5")
        redis.ltrim("log:recent", 0, 2)  // keep only the first 3 elements
        println(redis.lrange("log:recent", 0, -1))  // [event1, event2, event3]
    }
}

The Set Data Type — Unique Collections #

Sets store unique elements without order:

fun setExample() {
    RedisConnection.use { redis ->
        // SADD — add elements
        redis.sadd("tag:produk:1", "elektronik", "laptop", "gaming")
        redis.sadd("tag:produk:2", "elektronik", "mouse", "wireless")

        // SMEMBERS — get all elements
        println(redis.smembers("tag:produk:1"))  // [elektronik, laptop, gaming]

        // SISMEMBER — check membership
        println(redis.sismember("tag:produk:1", "laptop"))     // true
        println(redis.sismember("tag:produk:1", "keyboard"))   // false

        // SCARD — the number of elements
        println(redis.scard("tag:produk:1"))  // 3

        // Set operations
        val intersection = redis.sinter("tag:produk:1", "tag:produk:2")  // {elektronik}
        val union = redis.sunion("tag:produk:1", "tag:produk:2") // all tags
        val difference = redis.sdiff("tag:produk:1", "tag:produk:2")   // {laptop, gaming}

        println("Intersection: $intersection")
        println("Union: $union")
        println("Difference: $difference")

        // SREM — remove elements
        redis.srem("tag:produk:1", "gaming")

        // SPOP — get and remove a random element
        val random = redis.spop("tag:produk:1")
        println("Random element: $random")
    }
}

The Sorted Set Data Type — Rankings and Leaderboards #

A Sorted Set is a Set with a numeric score for each element, always ordered:

fun sortedSetExample() {
    RedisConnection.use { redis ->
        val key = "leaderboard:game"

        // ZADD — add with a score
        redis.zadd(key, 1500.0, "Budi")
        redis.zadd(key, 2300.0, "Sari")
        redis.zadd(key, 1800.0, "Ahmad")
        redis.zadd(key, 2800.0, "Rina")
        redis.zadd(key, 1200.0, "Doni")

        // ZRANGE — get in score order (ascending)
        println(redis.zrange(key, 0, -1))
        // [Doni, Budi, Ahmad, Sari, Rina]

        // ZREVRANGE — descending order (highest score first)
        val top3 = redis.zrevrange(key, 0, 2)
        println("Top 3: $top3")  // [Rina, Sari, Ahmad]

        // ZRANGEBYSCORE — filter by score range
        val middle = redis.zrangebyscore(key,
            io.lettuce.core.Range.create(1500.0, 2500.0)
        )
        println("Scores 1500-2500: $middle")  // [Budi, Ahmad, Sari]

        // ZSCORE — get a specific element's score
        println(redis.zscore(key, "Rina"))  // 2800.0

        // ZRANK / ZREVRANK — position in the order (0-based)
        println(redis.zrevrank(key, "Rina"))  // 0 (highest)
        println(redis.zrevrank(key, "Budi"))  // 3

        // ZINCRBY — increment a score atomically
        redis.zincrby(key, 500.0, "Budi")
        println(redis.zscore(key, "Budi"))  // 2000.0

        // ZCARD — the number of elements
        println(redis.zcard(key))  // 5

        // A leaderboard example with scores and ranks
        println("\n=== LEADERBOARD ===")
        redis.zrevrangeWithScores(key, 0, 4).forEachIndexed { i, scored ->
            println("${i + 1}. ${scored.value}: ${scored.score.toInt()} points")
        }
    }
}

Common Caching Patterns #

Cache-Aside (Lazy Loading) #

import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json

@Serializable
data class Product(val id: Int, val name: String, val price: Double, val stock: Int)

class ProductServiceWithCache(private val repo: ProductRepository) {
    private val json = Json { ignoreUnknownKeys = true }
    private val TTL_SECONDS = 300L  // 5 minute cache

    fun getProduct(id: Int): Product? {
        val cacheKey = "produk:$id"

        // 1. Check the cache first
        val inCache = RedisConnection.use { redis -> redis.get(cacheKey) }
        if (inCache != null) {
            println("Cache HIT for product $id")
            return json.decodeFromString(Product.serializer(), inCache)
        }

        // 2. Cache MISS — get from the database
        println("Cache MISS for product $id — getting from DB")
        val product = repo.findById(id) ?: return null

        // 3. Store in the cache
        val dataJson = json.encodeToString(Product.serializer(), product)
        RedisConnection.use { redis ->
            redis.setex(cacheKey, TTL_SECONDS, dataJson)
        }

        return product
    }

    // Invalidate the cache when data changes
    fun updateProduct(product: Product) {
        repo.update(product)

        // Delete the cache — it will be repopulated on the next request
        RedisConnection.use { redis ->
            redis.del("produk:${product.id}")
        }
        println("Cache invalidated for product ${product.id}")
    }

    // Cache many products at once with a pipeline
    fun cacheAllProducts(products: List<Product>) {
        RedisConnection.use { redis ->
            products.forEach { p ->
                val dataJson = json.encodeToString(Product.serializer(), p)
                redis.setex("produk:${p.id}", TTL_SECONDS, dataJson)
            }
        }
        println("${products.size} products cached")
    }
}

Rate Limiting #

fun checkRateLimit(userId: String, max: Int = 100, windowSeconds: Long = 60): Boolean {
    val key = "rate:$userId:${System.currentTimeMillis() / (windowSeconds * 1000)}"

    return RedisConnection.use { redis ->
        val count = redis.incr(key)

        if (count == 1L) {
            // Set the TTL only the first time (atomic-ish)
            redis.expire(key, windowSeconds + 1)
        }

        if (count > max) {
            val secondsLeft = redis.ttl(key)
            println("Rate limit exceeded for $userId. Reset in $secondsLeft seconds")
            false
        } else {
            println("$userId: $count/$max requests in this window")
            true
        }
    }
}

Distributed Locks #

import java.util.UUID

class DistributedLock(private val lockName: String, private val ttlSeconds: Long = 30) {
    private val lockKey = "lock:$lockName"
    private val lockValue = UUID.randomUUID().toString()  // unique value per instance

    fun acquire(): Boolean {
        return RedisConnection.use { redis ->
            val result = redis.set(
                lockKey,
                lockValue,
                io.lettuce.core.SetArgs.Builder.nx().ex(ttlSeconds)
            )
            result == "OK"
        }
    }

    fun release() {
        // Only delete if we own the lock (check lockValue)
        // Ideally use a Lua script for atomicity
        RedisConnection.use { redis ->
            val value = redis.get(lockKey)
            if (value == lockValue) {
                redis.del(lockKey)
                println("Lock '$lockName' released")
            } else {
                println("Lock '$lockName' already expired or owned by someone else")
            }
        }
    }
}

fun <T> withLock(lockName: String, block: () -> T): T? {
    val lock = DistributedLock(lockName)
    return if (lock.acquire()) {
        try {
            block()
        } finally {
            lock.release()
        }
    } else {
        println("Failed to acquire lock '$lockName' — another process is running")
        null
    }
}

// Usage
fun main() {
    withLock("proses-laporan-bulanan") {
        println("Processing the monthly report...")
        Thread.sleep(2000)
        println("Report done")
    }
}

Redis Pub/Sub #

Redis also supports simple publish-subscribe:

import io.lettuce.core.pubsub.StatefulRedisPubSubConnection
import io.lettuce.core.pubsub.RedisPubSubListener

fun pubSubExample() {
    val client = io.lettuce.core.RedisClient.create("redis://localhost:6379")

    // The publisher uses a regular connection
    val publisherConn = client.connect()
    val publisher = publisherConn.sync()

    // The subscriber uses a dedicated Pub/Sub connection
    val subscriberConn: StatefulRedisPubSubConnection<String, String> = client.connectPubSub()

    subscriberConn.addListener(object : RedisPubSubListener<String, String> {
        override fun message(channel: String, message: String) {
            println("[$channel] $message")
        }
        override fun message(pattern: String, channel: String, message: String) {}
        override fun subscribed(channel: String, count: Long) {
            println("Subscribed to '$channel' (total: $count)")
        }
        override fun psubscribed(pattern: String, count: Long) {}
        override fun unsubscribed(channel: String, count: Long) {}
        override fun punsubscribed(pattern: String, count: Long) {}
    })

    // Subscribe to channels
    val subscriberAsync = subscriberConn.async()
    subscriberAsync.subscribe("notifikasi", "sistem.alert").get()

    // Publish messages
    Thread.sleep(100)
    publisher.publish("notifikasi", "New order arrived!")
    publisher.publish("sistem.alert", "High CPU usage!")

    Thread.sleep(500)
    subscriberConn.close()
    publisherConn.close()
    client.shutdown()
}

Summary #

  • Lettuce for production, Jedis for prototypes — Lettuce is thread-safe and async; one instance can be shared across the whole application. Jedis needs a connection pool for multi-threading.
  • Always set a TTL — almost every Redis key should have an expiration time. Without TTL, Redis can run out of memory. Use setex, expire, or pexpire.
  • Hashes for objects — instead of storing one JSON object per key (String), use a Hash when you need to update individual fields without rewriting the whole object.
  • Sorted Sets for rankings — leaderboards, top-N, or priority queues are perfect use cases for Sorted Sets. Use ZINCRBY to increment scores atomically.
  • SCAN not KEYS in productionKEYS pattern blocks Redis during execution. For large data, use SCAN which is iterative and non-blocking.
  • Pipelines for bulk operations — instead of sending 1000 commands separately (1000 round-trips), use a pipeline to send them all at once and read the responses at once.
  • Distributed locks with NX and EXSET kunci nilai NX EX 30 is an atomic operation perfect for distributed locks. NX = only if not present, EX = auto-expiry prevents deadlocks.
  • Short TTLs for caches, long TTLs for state — query result caches: 5-15 minutes. User sessions: match the app’s session timeout. Distributed locks: slightly longer than the estimated processing time.

← Previous: Google Pub/Sub   Next: Memcached →

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