Redis #
Redis (Remote Dictionary Server) adalah penyimpanan data in-memory yang sangat cepat — latensi di bawah 1 milidetik untuk sebagian besar operasi. Ia mendukung berbagai struktur data: String, Hash, List, Set, Sorted Set, dan lebih. Redis digunakan untuk caching hasil query database, menyimpan sesi pengguna, rate limiting, distributed lock, leaderboard real-time, dan bahkan sebagai message broker sederhana. Di Kotlin, ada dua library populer: Lettuce (async, thread-safe, berbasis Netty, direkomendasikan) dan Jedis (synchronous, lebih sederhana). Artikel ini membahas keduanya dengan fokus pada Lettuce, mencakup semua tipe data Redis, pola caching yang umum, dan penggunaan lanjutan.
Kapan Menggunakan Redis #
GUNAKAN Redis untuk:
✓ Cache hasil query database yang mahal (reduce DB load)
✓ Session store (lebih skalabel dari session in-memory)
✓ Rate limiting (mencegah abuse API)
✓ Distributed lock (koordinasi antar instance aplikasi)
✓ Leaderboard/ranking (Sorted Set)
✓ Queue sederhana (List dengan LPUSH/BRPOP)
✓ Real-time counter (INCR, DECR)
✓ Pub/Sub sederhana antar service
JANGAN pakai Redis untuk:
✗ Data primer (Redis in-memory, data hilang jika server mati tanpa persistence)
✗ Query kompleks dengan join antar entitas
✗ Data yang tidak boleh hilang tanpa backup strategy
✗ Data yang melebihi kapasitas RAM server
Setup dan Dependensi #
// build.gradle.kts
dependencies {
// Lettuce — direkomendasikan (async, thread-safe)
implementation("io.lettuce:lettuce-core:6.3.2.RELEASE")
// Jedis — alternatif yang lebih sederhana (sync)
// implementation("redis.clients:jedis:5.1.2")
// kotlinx.serialization untuk serialisasi nilai
implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.6.3")
// Coroutine
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.8.0")
}
Koneksi dengan 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 KoneksiRedis {
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)
// Satu koneksi bisa digunakan dari satu thread — gunakan pool untuk multi-thread
val koneksi: StatefulRedisConnection<String, String> by lazy {
client.connect()
}
// Pool koneksi untuk lingkungan multi-thread
val pool: GenericObjectPool<StatefulRedisConnection<String, String>> by lazy {
val config = GenericObjectPoolConfig<StatefulRedisConnection<String, String>>().apply {
maxTotal = 10 // maks 10 koneksi
maxIdle = 5 // maks 5 koneksi idle
minIdle = 2 // min 2 koneksi selalu siap
testOnBorrow = true
}
ConnectionPoolSupport.createGenericObjectPool({ client.connect() }, config)
}
// Gunakan koneksi dari pool secara aman
fun <T> gunakan(blok: (RedisCommands<String, String>) -> T): T {
return pool.borrowObject().use { conn ->
blok(conn.sync())
}
}
fun tutup() {
pool.close()
client.shutdown()
}
}
fun main() {
val info = KoneksiRedis.gunakan { redis ->
redis.ping()
}
println("Redis OK: $info") // PONG
}
Tipe Data String — Paling Dasar #
String adalah tipe data Redis yang paling umum. Bisa menyimpan teks, angka, atau data biner:
fun contohString() {
KoneksiRedis.gunakan { redis ->
// SET dan GET
redis.set("nama", "Budi Santoso")
println(redis.get("nama")) // Budi Santoso
// SET dengan TTL (expire dalam detik)
redis.setex("sesi:usr123", 3600, "data sesi pengguna") // expire 1 jam
println(redis.ttl("sesi:usr123")) // ~3600 detik tersisa
// SET hanya jika key belum ada (atomic)
val berhasil = redis.setnx("kunci.unik", "nilai")
println(berhasil) // true jika berhasil, false jika key sudah ada
// SETEX dengan kondisi NX atau XX
redis.set("kunci", "nilai", io.lettuce.core.SetArgs.Builder.nx().ex(60))
// NX = hanya jika tidak ada, EX = expire 60 detik
// INCR dan DECR — atomik, aman untuk counter
redis.set("pengunjung", "0")
redis.incr("pengunjung") // 1
redis.incrby("pengunjung", 5) // 6
redis.decr("pengunjung") // 5
println(redis.get("pengunjung")) // 5
// DEL — hapus key
redis.del("nama", "pengunjung")
// EXISTS — cek apakah key ada
println(redis.exists("nama")) // 0 (tidak ada)
// KEYS dengan pattern (hati-hati di 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]
// JANGAN gunakan KEYS di production dengan data banyak — gunakan SCAN
}
}
Tipe Data Hash — Objek Multi-Field #
Hash cocok untuk menyimpan objek dengan banyak field, seperti profil pengguna:
fun contohHash() {
KoneksiRedis.gunakan { redis ->
val key = "pengguna:1001"
// HSET — set satu atau banyak field
redis.hset(key, mapOf(
"nama" to "Budi Santoso",
"email" to "[email protected]",
"umur" to "28",
"kota" to "Jakarta"
))
// HGET — ambil satu field
println(redis.hget(key, "nama")) // Budi Santoso
// HMGET — ambil banyak field sekaligus
val hasil = redis.hmget(key, "nama", "email", "kota")
println(hasil) // [Budi Santoso, [email protected], Jakarta]
// HGETALL — ambil semua field sebagai Map
val semuaField = redis.hgetall(key)
semuaField.forEach { (field, nilai) -> println("$field: $nilai") }
// HDEL — hapus field tertentu
redis.hdel(key, "umur")
// HEXISTS — cek apakah field ada
println(redis.hexists(key, "email")) // true
println(redis.hexists(key, "umur")) // false
// HLEN — jumlah field
println(redis.hlen(key)) // 3
// HINCRBY — increment nilai numerik dalam hash
redis.hset(key, "loginCount", "0")
redis.hincrby(key, "loginCount", 1)
println(redis.hget(key, "loginCount")) // 1
// Set TTL pada hash
redis.expire(key, 3600)
}
}
Tipe Data List — Antrian dan Tumpukan #
List adalah linked list yang mendukung operasi di kedua ujung:
fun contohList() {
KoneksiRedis.gunakan { redis ->
val key = "riwayat:pengguna:1001"
// RPUSH — tambahkan ke kanan (tail)
redis.rpush(key, "login-2024-01-01", "beli-produk", "logout")
// LPUSH — tambahkan ke kiri (head) — untuk stack
redis.lpush("notifikasi:inbox", "Pesanan terkonfirmasi", "Pembayaran berhasil")
// LRANGE — ambil elemen dalam rentang (0 = pertama, -1 = terakhir)
val semua = redis.lrange(key, 0, -1)
println(semua) // [login-2024-01-01, beli-produk, logout]
// LLEN — panjang list
println(redis.llen(key)) // 3
// LPOP / RPOP — ambil dan hapus dari kiri/kanan
val pertama = redis.lpop(key) // login-2024-01-01
val terakhir = redis.rpop(key) // logout
// LINDEX — akses elemen di indeks tertentu
println(redis.lindex(key, 0)) // beli-produk (sisa setelah 2 pop)
// BRPOP — blocking pop: tunggu sampai ada elemen (untuk task queue)
// redis.brpop(5, "antrian:tugas") // tunggu maks 5 detik
// LTRIM — pertahankan hanya elemen dalam rentang (trim list)
redis.rpush("log:recent", "event1", "event2", "event3", "event4", "event5")
redis.ltrim("log:recent", 0, 2) // pertahankan hanya 3 elemen pertama
println(redis.lrange("log:recent", 0, -1)) // [event1, event2, event3]
}
}
Tipe Data Set — Koleksi Unik #
Set menyimpan elemen unik tanpa urutan:
fun contohSet() {
KoneksiRedis.gunakan { redis ->
// SADD — tambahkan elemen
redis.sadd("tag:produk:1", "elektronik", "laptop", "gaming")
redis.sadd("tag:produk:2", "elektronik", "mouse", "wireless")
// SMEMBERS — ambil semua elemen
println(redis.smembers("tag:produk:1")) // [elektronik, laptop, gaming]
// SISMEMBER — cek keanggotaan
println(redis.sismember("tag:produk:1", "laptop")) // true
println(redis.sismember("tag:produk:1", "keyboard")) // false
// SCARD — jumlah elemen
println(redis.scard("tag:produk:1")) // 3
// Operasi himpunan
val irisan = redis.sinter("tag:produk:1", "tag:produk:2") // {elektronik}
val gabungan = redis.sunion("tag:produk:1", "tag:produk:2") // semua tag
val selisih = redis.sdiff("tag:produk:1", "tag:produk:2") // {laptop, gaming}
println("Irisan: $irisan")
println("Gabungan: $gabungan")
println("Selisih: $selisih")
// SREM — hapus elemen
redis.srem("tag:produk:1", "gaming")
// SPOP — ambil dan hapus elemen acak
val acak = redis.spop("tag:produk:1")
println("Elemen acak: $acak")
}
}
Tipe Data Sorted Set — Ranking dan Leaderboard #
Sorted Set adalah Set dengan skor numerik untuk setiap elemen, selalu terurut:
fun contohSortedSet() {
KoneksiRedis.gunakan { redis ->
val key = "leaderboard:game"
// ZADD — tambahkan dengan skor
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 — ambil dalam urutan skor (ascending)
println(redis.zrange(key, 0, -1))
// [Doni, Budi, Ahmad, Sari, Rina]
// ZREVRANGE — urutan descending (skor tertinggi duluan)
val top3 = redis.zrevrange(key, 0, 2)
println("Top 3: $top3") // [Rina, Sari, Ahmad]
// ZRANGEBYSCORE — filter berdasarkan rentang skor
val menengah = redis.zrangebyscore(key,
io.lettuce.core.Range.create(1500.0, 2500.0)
)
println("Skor 1500-2500: $menengah") // [Budi, Ahmad, Sari]
// ZSCORE — ambil skor elemen tertentu
println(redis.zscore(key, "Rina")) // 2800.0
// ZRANK / ZREVRANK — posisi dalam urutan (0-based)
println(redis.zrevrank(key, "Rina")) // 0 (tertinggi)
println(redis.zrevrank(key, "Budi")) // 3
// ZINCRBY — tambah skor secara atomik
redis.zincrby(key, 500.0, "Budi")
println(redis.zscore(key, "Budi")) // 2000.0
// ZCARD — jumlah elemen
println(redis.zcard(key)) // 5
// Contoh leaderboard dengan skor dan rank
println("\n=== LEADERBOARD ===")
redis.zrevrangeWithScores(key, 0, 4).forEachIndexed { i, scored ->
println("${i + 1}. ${scored.value}: ${scored.score.toInt()} poin")
}
}
}
Pola Caching Umum #
Cache-Aside (Lazy Loading) #
import kotlinx.serialization.Serializable
import kotlinx.serialization.json.Json
@Serializable
data class Produk(val id: Int, val nama: String, val harga: Double, val stok: Int)
class LayananProdukDenganCache(private val repo: ProdukRepository) {
private val json = Json { ignoreUnknownKeys = true }
private val TTL_DETIK = 300L // cache 5 menit
fun ambilProduk(id: Int): Produk? {
val kunciCache = "produk:$id"
// 1. Cek cache dulu
val dalamCache = KoneksiRedis.gunakan { redis -> redis.get(kunciCache) }
if (dalamCache != null) {
println("Cache HIT untuk produk $id")
return json.decodeFromString(Produk.serializer(), dalamCache)
}
// 2. Cache MISS — ambil dari database
println("Cache MISS untuk produk $id — ambil dari DB")
val produk = repo.cariById(id) ?: return null
// 3. Simpan ke cache
val dataJson = json.encodeToString(Produk.serializer(), produk)
KoneksiRedis.gunakan { redis ->
redis.setex(kunciCache, TTL_DETIK, dataJson)
}
return produk
}
// Invalidasi cache saat data berubah
fun perbaruiProduk(produk: Produk) {
repo.perbarui(produk)
// Hapus cache — akan di-populate ulang di request berikutnya
KoneksiRedis.gunakan { redis ->
redis.del("produk:${produk.id}")
}
println("Cache invalidasi untuk produk ${produk.id}")
}
// Cache banyak produk sekaligus dengan pipeline
fun cachekanSemuaProduk(produk: List<Produk>) {
KoneksiRedis.gunakan { redis ->
// Pipeline: kirim banyak perintah sekaligus tanpa round-trip per perintah
val pipeline = (redis as io.lettuce.core.api.sync.RedisCommands<String, String>)
produk.forEach { p ->
val dataJson = json.encodeToString(Produk.serializer(), p)
redis.setex("produk:${p.id}", TTL_DETIK, dataJson)
}
}
println("${produk.size} produk di-cache")
}
}
Rate Limiting #
fun cekRateLimit(penggunaId: String, maks: Int = 100, windowDetik: Long = 60): Boolean {
val key = "rate:$penggunaId:${System.currentTimeMillis() / (windowDetik * 1000)}"
return KoneksiRedis.gunakan { redis ->
val hitungan = redis.incr(key)
if (hitungan == 1L) {
// Set TTL hanya saat pertama kali (atomic-ish)
redis.expire(key, windowDetik + 1)
}
if (hitungan > maks) {
val sisaDetik = redis.ttl(key)
println("Rate limit terlampaui untuk $penggunaId. Reset dalam $sisaDetik detik")
false
} else {
println("$penggunaId: $hitungan/$maks request dalam window ini")
true
}
}
}
Distributed Lock #
import java.util.UUID
class DistributedLock(private val lockName: String, private val ttlDetik: Long = 30) {
private val lockKey = "lock:$lockName"
private val lockValue = UUID.randomUUID().toString() // nilai unik per instance
fun acquire(): Boolean {
return KoneksiRedis.gunakan { redis ->
val hasil = redis.set(
lockKey,
lockValue,
io.lettuce.core.SetArgs.Builder.nx().ex(ttlDetik)
)
hasil == "OK"
}
}
fun release() {
// Hanya hapus jika kita yang punya lock (cek lockValue)
// Idealnya gunakan Lua script untuk atomisitas
KoneksiRedis.gunakan { redis ->
val nilai = redis.get(lockKey)
if (nilai == lockValue) {
redis.del(lockKey)
println("Lock '$lockName' dilepas")
} else {
println("Lock '$lockName' sudah expired atau dimiliki pihak lain")
}
}
}
}
fun <T> denganLock(namaLock: String, blok: () -> T): T? {
val lock = DistributedLock(namaLock)
return if (lock.acquire()) {
try {
blok()
} finally {
lock.release()
}
} else {
println("Gagal mendapatkan lock '$namaLock' — proses lain sedang berjalan")
null
}
}
// Penggunaan
fun main() {
denganLock("proses-laporan-bulanan") {
println("Memproses laporan bulanan...")
Thread.sleep(2000)
println("Laporan selesai")
}
}
Pub/Sub Redis #
Redis juga mendukung publish-subscribe sederhana:
import io.lettuce.core.pubsub.StatefulRedisPubSubConnection
import io.lettuce.core.pubsub.RedisPubSubListener
fun contohPubSub() {
val client = io.lettuce.core.RedisClient.create("redis://localhost:6379")
// Publisher menggunakan koneksi biasa
val publisherConn = client.connect()
val publisher = publisherConn.sync()
// Subscriber menggunakan koneksi Pub/Sub khusus
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("Berlangganan ke '$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 ke channel
val subscriberAsync = subscriberConn.async()
subscriberAsync.subscribe("notifikasi", "sistem.alert").get()
// Publish pesan
Thread.sleep(100)
publisher.publish("notifikasi", "Pesanan baru masuk!")
publisher.publish("sistem.alert", "CPU usage tinggi!")
Thread.sleep(500)
subscriberConn.close()
publisherConn.close()
client.shutdown()
}
Ringkasan #
- Lettuce untuk production, Jedis untuk prototipe — Lettuce thread-safe dan async, satu instance bisa dibagikan ke seluruh aplikasi. Jedis perlu connection pool untuk multi-thread.
- Selalu set TTL — hampir semua key Redis harus punya waktu kedaluwarsa. Tanpa TTL, Redis bisa kehabisan memori. Gunakan
setex,expire, ataupexpire.- Hash untuk objek — daripada menyimpan satu objek JSON per key (String), gunakan Hash jika perlu memperbarui field individual tanpa menulis ulang seluruh objek.
- Sorted Set untuk ranking — leaderboard, top-N, atau antrian berprioritas adalah use case sempurna untuk Sorted Set.
ZINCRBYuntuk increment skor secara atomik.- SCAN bukan KEYS di production —
KEYS patternmemblokir Redis selama eksekusi. Untuk data besar, gunakanSCANyang iteratif dan non-blocking.- Pipeline untuk operasi massal — daripada mengirim 1000 perintah secara terpisah (1000 round-trip), gunakan pipeline untuk mengirim semuanya sekaligus dan baca respons sekaligus.
- Distributed lock dengan NX dan EX —
SET kunci nilai NX EX 30adalah operasi atomik yang sempurna untuk distributed lock. NX = hanya jika tidak ada, EX = expire otomatis mencegah deadlock.- TTL pendek untuk cache, TTL panjang untuk state — cache hasil query: 5-15 menit. Session pengguna: sesuai session timeout aplikasi. Distributed lock: sedikit lebih lama dari estimasi waktu proses.