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, orpexpire.- 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
ZINCRBYto increment scores atomically.- SCAN not KEYS in production —
KEYS patternblocks Redis during execution. For large data, useSCANwhich 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 EX —
SET kunci nilai NX EX 30is 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.