---
id: 1c72ae6efee059c2c727ea53b56bf6c5
author: snowmerak
title: Meningkatkan Responsivitas dengan Cache Sisi Klien Redis
description: Peningkatan Responsivitas API Menggunakan Redis Server Assisted Client Side Cache
language: id
date: 2024-11-29T00:51:27.525566863Z
path: /blog/posts/improving-responsiveness-with-redis-client-side-caching-zb711e502
category: database
---

## Apa itu Redis?

Saya rasa tidak banyak orang yang tidak mengenal Redis. Namun, untuk menyebutkannya secara singkat dengan beberapa karakteristik, dapat dirangkum sebagai berikut.

- Operasi dieksekusi pada **single thread**, sehingga semua operasi memiliki sifat **atomisitas**.
- Data disimpan dan diolah di **In-Memory**, sehingga semua operasi berlangsung **cepat**.
- Redis dapat menyimpan **WAL** sesuai opsi, sehingga status terbaru dapat di-**backup** dan **dipulihkan** dengan cepat.
- Redis mendukung **berbagai tipe** data seperti Set, Hash, Bit, List, sehingga memiliki **produktivitas** yang tinggi.
- Redis memiliki **komunitas yang besar**, sehingga pengalaman, isu, dan solusi yang beragam dapat **dibagikan**.
- Redis **dikembangkan dan dioperasikan** dalam waktu yang lama, sehingga memiliki **stabilitas** yang dapat diandalkan.

## Langsung ke Pokok Bahasan

### Bayangkan?

Bagaimana jika cache layanan Anda memenuhi dua kondisi berikut?

1. Data yang sering diakses harus disajikan kepada pengguna dalam keadaan terbaru, namun pembaruannya tidak teratur sehingga pembaruan cache harus dilakukan secara sering
2. Data tidak diperbarui, tetapi data cache yang sama sering diakses dan di-query

Kasus pertama dapat dicontohkan dengan peringkat popularitas real-time pada toko daring. Ketika peringkat popularitas real-time toko daring disimpan dalam sorted set, akan tidak efisien jika Redis dibaca setiap kali pengguna mengakses halaman utama.  
Kasus kedua berkaitan dengan data kurs valuta asing. Meskipun data kurs ditetapkan sekitar setiap 10 menit, pembacaannya terjadi sangat sering. Terutama untuk won-dolar, won-yen, dan won-yuan, cache dibaca dengan sangat sering.  
Dalam kasus-kasus seperti ini, akan lebih efisien jika server API memiliki cache lokal tersendiri, lalu ketika data berubah, Redis dibaca kembali untuk melakukan pembaruan.

Lalu, bagaimana cara mewujudkan perilaku ini dalam arsitektur database - Redis - server API?

### Bagaimana dengan Redis PubSub?

> Saat menggunakan cache, mari berlangganan ke channel yang dapat menerima informasi apakah terjadi pembaruan!

- Maka perlu dibuat logika yang mengirimkan pesan saat terjadi pembaruan.
- Karena ada operasi tambahan akibat PubSub, performa akan terpengaruh.

![pubsub-write](/assets/images/redis-client-side-cache/01-pubsub-write.png)

![pubsub-read](/assets/images/redis-client-side-cache/01-pubsub-read.png)

### Bagaimana jika Redis yang mendeteksi perubahan?

> Bagaimana jika kita menerima notifikasi perintah untuk key tertentu menggunakan Keyspace Notification?

- Diperlukan usaha untuk menyimpan dan membagikan key dan perintah yang digunakan untuk pembaruan sebelumnya.
- Misalnya, untuk suatu key, perintah pembaruannya adalah Set biasa, sementara untuk key lain perintah pembaruannya adalah LPush, RPush, SAdd, atau SRem, sehingga menjadi rumit.
- Hal ini sangat meningkatkan kemungkinan terjadinya miskomunikasi dalam pengembangan dan kesalahan manusia dalam pengodean.

> Bagaimana jika kita menerima notifikasi per perintah menggunakan Keyevent Notification?

- Diperlukan langganan ke semua perintah yang digunakan untuk pembaruan. Perlu dilakukan penyaringan yang tepat terhadap key yang masuk.
- Misalnya, di antara semua key yang masuk melalui Del, untuk sebagian key, klien tersebut kemungkinan besar tidak memiliki cache lokal.
- Hal ini dapat menyebabkan pemborosan sumber daya yang tidak perlu.

### Maka yang dibutuhkan adalah Invalidation Message!

#### Apa itu Invalidation Message?

Invalidation Messages adalah konsep yang disediakan sebagai bagian dari Server Assisted Client-Side Cache yang ditambahkan sejak Redis 6.0. Invalidation Message disampaikan melalui alur berikut.

1. Misalkan ClientB sudah pernah membaca key tersebut sekali.
2. ClientA menetapkan ulang key tersebut.
3. Redis mendeteksi perubahan dan menerbitkan Invalidation Message ke ClientB untuk memberitahukan agar ClientB menghapus cache-nya.
4. ClientB menerima pesan tersebut dan mengambil tindakan yang sesuai.

![invalidation-message](/assets/images/redis-client-side-cache/02-invalidation-message.png)

### Bagaimana cara menggunakannya?

#### Struktur dasar operasi

Klien yang terhubung ke Redis menjalankan `CLIENT TRACKING ON REDIRECT <client-id>` agar dapat menerima invalidation message. Lalu, klien yang harus menerima pesan tersebut berlangganan ke `SUBSCRIBE __redis__:invalidate` untuk menerima invalidation message.

#### default tracking

```shell
# client 1
> SET a 100
```

```shell
# client 3
> CLIENT ID
12
> SUBSCRIBE __redis__:invalidate
1) "subscribe"
2) "__redis__:invalidate"
3) (integer) 1
```

```shell
# client 2
> CLIENT TRACKING ON REDIRECT 12
> GET a # tracking
```

```shell
# client 1
> SET a 200
```

```shell
# client 3
1) "message"
2) "__redis__:invalidate"
3) 1) "a"
```

#### broadcasting tracking

```shell
# client 3
> CLIENT ID
12
> SUBSCRIBE __redis__:invalidate
1) "subscribe"
2) "__redis__:invalidate"
3) (integer) 1
```

```shell
# client 2
CLIENT TRACKING ON BCAST PREFIX cache: REDIRECT 12
```

```shell
# client 1
> SET cache:name "Alice"
> SET cache:age 26
```

```shell
# client 3
1) "message"
2) "__redis__:invalidate"
3) 1) "cache:name"
1) "message"
2) "__redis__:invalidate"
3) 1) "cache:age"
```

## Implementasi! Implementasi! Implementasi!

### Redigo + Ristretto

Jika hanya dijelaskan seperti itu, cara penggunaannya dalam kode sebenarnya menjadi kurang jelas. Oleh karena itu, kita akan terlebih dahulu membangunnya secara sederhana dengan `redigo` dan `ristretto`.

Pertama, instal kedua dependensi berikut.

- `github.com/gomodule/redigo`
- `github.com/dgraph-io/ristretto`

```go
package main

import (
	"context"
	"fmt"
	"log/slog"
	"time"

	"github.com/dgraph-io/ristretto"
	"github.com/gomodule/redigo/redis"
)

type RedisClient struct {
	conn  redis.Conn
	cache *ristretto.Cache[string, any]
	addr  string
}

func NewRedisClient(addr string) (*RedisClient, error) {
	cache, err := ristretto.NewCache(&ristretto.Config[string, any]{
		NumCounters: 1e7,     // jumlah key untuk melacak frekuensi (10M).
		MaxCost:     1 << 30, // biaya maksimum cache (1GB).
		BufferItems: 64,      // jumlah key per buffer Get.
	})
	if err != nil {
		return nil, fmt.Errorf("failed to generate cache: %w", err)
	}

	conn, err := redis.Dial("tcp", addr)
	if err != nil {
		return nil, fmt.Errorf("failed to connect to redis: %w", err)
	}

	return &RedisClient{
		conn:  conn,
		cache: cache,
		addr:  addr,
	}, nil
}

func (r *RedisClient) Close() error {
	err := r.conn.Close()
	if err != nil {
		return fmt.Errorf("failed to close redis connection: %w", err)
	}

	return nil
}
```

Pertama, kita membuat `RedisClient` sederhana yang berisi ristretto dan redigo.

```go
func (r *RedisClient) Tracking(ctx context.Context) error {
	psc, err := redis.Dial("tcp", r.addr)
	if err != nil {
		return fmt.Errorf("failed to connect to redis: %w", err)
	}

	clientId, err := redis.Int64(psc.Do("CLIENT", "ID"))
	if err != nil {
		return fmt.Errorf("failed to get client id: %w", err)
	}
	slog.Info("client id", "id", clientId)

	subscriptionResult, err := redis.String(r.conn.Do("CLIENT", "TRACKING", "ON", "REDIRECT", clientId))
	if err != nil {
		return fmt.Errorf("failed to enable tracking: %w", err)
	}
	slog.Info("subscription result", "result", subscriptionResult)

	if err := psc.Send("SUBSCRIBE", "__redis__:invalidate"); err != nil {
		return fmt.Errorf("failed to subscribe: %w", err)
	}
	psc.Flush()

	for {
		msg, err := psc.Receive()
		if err != nil {
			return fmt.Errorf("failed to receive message: %w", err)
		}

		switch msg := msg.(type) {
		case redis.Message:
			slog.Info("received message", "channel", msg.Channel, "data", msg.Data)
			key := string(msg.Data)
			r.cache.Del(key)
		case redis.Subscription:
			slog.Info("subscription", "kind", msg.Kind, "channel", msg.Channel, "count", msg.Count)
		case error:
			return fmt.Errorf("error: %w", msg)
		case []interface{}:
			if len(msg) != 3 || string(msg[0].([]byte)) != "message" || string(msg[1].([]byte)) != "__redis__:invalidate" {
				slog.Warn("unexpected message", "message", msg)
				continue
			}

			contents := msg[2].([]interface{})
			keys := make([]string, len(contents))
			for i, key := range contents {
				keys[i] = string(key.([]byte))
				r.cache.Del(keys[i])
			}
			slog.Info("received invalidation message", "keys", keys)
		default:
			slog.Warn("unexpected message", "type", fmt.Sprintf("%T", msg))
		}
	}
}
```

Kodenya agak rumit.

- Untuk melakukan Tracking, satu koneksi tambahan dibuat. Ini dilakukan untuk mempertimbangkan agar PubSub tidak mengganggu operasi lainnya.
- ID dari koneksi tambahan tersebut diambil, lalu Tracking pada koneksi yang digunakan untuk membaca data dialihkan (redirect) ke koneksi tersebut.
- Setelah itu, invalidation message dilanggan.
- Kode untuk memproses langganan cukup rumit. Karena redigo tidak mem-parsing invalidation message, respons harus diterima dan diproses sebelum di-parsing.

```go
func (r *RedisClient) Get(key string) (any, error) {
	val, found := r.cache.Get(key)
	if found {
		switch v := val.(type) {
		case int64:
			slog.Info("cache hit", "key", key)
			return v, nil
		default:
			slog.Warn("unexpected type", "type", fmt.Sprintf("%T", v))
		}
	}
	slog.Info("cache miss", "key", key)

	val, err := redis.Int64(r.conn.Do("GET", key))
	if err != nil {
		return nil, fmt.Errorf("failed to get key: %w", err)
	}

	r.cache.SetWithTTL(key, val, 1, 10*time.Second)
	return val, nil
}
```

Method `Get` mencari di ristretto terlebih dahulu seperti berikut, dan jika tidak ditemukan, data diambil dari Redis.

```go
package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"time"
)

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
	defer cancel()

	client, err := NewRedisClient("localhost:6379")
	if err != nil {
		panic(err)
	}
	defer client.Close()

	go func() {
		if err := client.Tracking(ctx); err != nil {
			slog.Error("failed to track invalidation message", "error", err)
		}
	}()

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	done := ctx.Done()

	for {
		select {
		case <-done:
			slog.Info("shutting down")
			return
		case <-ticker.C:
			v, err := client.Get("key")
			if err != nil {
				slog.Error("failed to get key", "error", err)
				return
			}
			slog.Info("got key", "value", v)
		}
	}
}
```

Kode untuk pengujian adalah seperti di atas. Jika Anda mencobanya, Anda akan melihat bahwa nilai diperbarui setiap kali data di Redis berubah.

Namun, pendekatan ini terlalu rumit. Terlebih lagi, untuk melakukan skala pada cluster, perlu mengaktifkan Tracking pada semua master atau replika.

### Rueidis

Selama menggunakan bahasa Go, kita memiliki `rueidis` yang paling modern dan berkembang. Mari kita tulis kode yang menggunakan server assisted client side cache dalam lingkungan Redis cluster dengan rueidis.

Pertama, instal dependensi berikut.

- `github.com/redis/rueidis`

Kemudian, tulis kode untuk membaca data dari Redis.

```go
package main

import (
	"context"
	"log/slog"
	"os"
	"os/signal"
	"time"

	"github.com/redis/rueidis"
)

func main() {
	ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
	defer cancel()

	client, err := rueidis.NewClient(rueidis.ClientOption{
		InitAddress: []string{"localhost:6379"},
	})
	if err != nil {
		panic(err)
	}

	ticker := time.NewTicker(1 * time.Second)
	defer ticker.Stop()
	done := ctx.Done()

	for {
		select {
		case <-done:
			slog.Info("shutting down")
			return
		case <-ticker.C:
			const key = "key"
			resp := client.DoCache(ctx, client.B().Get().Key(key).Cache(), 10*time.Second)
			if resp.Error() != nil {
				slog.Error("failed to get key", "error", resp.Error())
				continue
			}
			i, err := resp.AsInt64()
			if err != nil {
				slog.Error("failed to convert response to int64", "error", err)
				continue
			}
			switch resp.IsCacheHit() {
			case true:
				slog.Info("cache hit", "key", key)
			case false:
				slog.Info("missed key", "key", key)
			}
			slog.Info("got key", "value", i)
		}
	}
}
```

Untuk menggunakan client side cache di rueidis, cukup panggil `DoCache`. Dengan demikian, data ditambahkan ke cache lokal dengan pengaturan seperti berapa lama data akan dipertahankan di cache lokal, dan jika `DoCache` dipanggil kembali, data akan diambil dari dalam cache lokal. Tentu saja, invalidation message juga ditangani dengan benar.

### Mengapa tidak redis-go?

Sayangnya, `redis-go` tidak mendukung server assisted client side cache melalui API resmi. Selain itu, saat membuat PubSub, koneksi baru dibuat dan tidak ada API untuk mengakses koneksi tersebut secara langsung, sehingga client id pun tidak dapat diketahui. Oleh karena itu, kami menilai bahwa konfigurasi dengan `redis-go` tidak mungkin dilakukan, dan kami melewatkannya.

## Keren Juga

### Melalui struktur client side cache

- Jika data dapat disiapkan sebelumnya, melalui struktur ini kueri dan lalu lintas ke Redis dapat diminimalkan sambil tetap menyediakan data terbaru setiap saat.
- Dengan ini, dapat dibangun semacam struktur CQRS sehingga performa membaca dapat meningkat secara signifikan.

![cqrs](/assets/images/redis-client-side-cache/003-cqrs.jpg)

### Seberapa keren hasilnya?

Kebetulan di lapangan, struktur seperti ini sedang digunakan, sehingga kami mencari latensi sederhana untuk dua API. Mohon maklum karena hanya dapat dituliskan secara sangat abstrak.

1. API pertama
   1. Saat pertama kali diakses: rata-rata 14,63 ms
   2. Saat diakses berikutnya: rata-rata 2,82 ms
   3. Selisih rata-rata: 10,98 ms
2. API kedua
   1. Saat pertama kali diakses: rata-rata 14,05 ms
   2. Saat diakses berikutnya: rata-rata 1,60 ms
   3. Selisih rata-rata: 11,57 ms

Peningkatan latensi tambahan mencapai sekitar 82% pada yang terbesar!

Kami memperkirakan terdapat peningkatan berikut.

- Penghapusan proses komunikasi jaringan antara klien dan Redis serta penghematan lalu lintas
- Penurunan jumlah perintah baca yang harus dijalankan oleh Redis itu sendiri
  - Hal ini juga memberikan efek peningkatan performa penulisan.
- Minimalisasi parsing protokol Redis
  - Parsing protokol Redis pun tidak tanpa biaya. Mengurangi hal ini merupakan peluang besar.

Namun, semua hal merupakan trade-off. Untuk ini, kami setidaknya mengorbankan dua hal berikut.

- Diperlukan implementasi, operasi, dan pemeliharaan elemen pengelolaan client side cache
- Peningkatan penggunaan CPU dan memori pada klien akibat hal tersebut

## Kesimpulan

Secara pribadi, ini merupakan komponen arsitektur yang memuaskan, dan stres pada latensi serta server API juga sangat berkurang. Ke depannya, saya berpikir akan baik jika arsitektur dibangun dengan struktur seperti ini bila memungkinkan.
