---
id: 1c72ae6efee059c2c727ea53b56bf6c5
author: snowmerak
title: Reagoivuuden parantaminen Redis client-side cache -välimuistin avulla
description: Redis-palvelimen avustama asiakaspuolen välimuisti API:n reagointikyvyn parantamiseksi
language: fi
date: 2024-11-29T00:51:27.525566863Z
path: /blog/posts/improving-responsiveness-with-redis-client-side-caching-zb711e502
category: database
---

## Mikä on Redis?

Uskon, että harva lukija on tutustumatta Redisiin. Mainitsen kuitenkin lyhyesti muutamia sen ominaisuuksia ennen kuin etenen, ja ne voidaan tiivistää seuraavasti.

- Operaatiot suoritetaan **yksisäikeisessä** ympäristössä, joten kaikilla operaatioilla on **atomisuus**.
- Data tallennetaan ja käsitellään **muistissa** (**In-Memory**), joten kaikki operaatiot ovat **nopeita**.
- Redis voi tallentaa **WAL**-lokin valinnasta riippuen, joten viimeisimmän tilan voi nopeasti **varmuuskopioida** ja **palauttaa**.
- Redis tukee useita **tietotyyppejä**, kuten Setiä, Hashia, Bitiä ja Listaa, mikä tarjoaa korkean **tuottavuuden**.
- Redisillä on **laaja yhteisö**, jonka kautta kokemuksia, ongelmia ja ratkaisuja voi **jakaa** ja saada.
- Redistä on kehitetty ja ylläpidetty **pitkään**, joten sen **luotettavuus** on osoitettu.

## Asiaan

### Kuvitelkaa

Entä jos palvelunne välimuisti täyttäisi seuraavat kaksi ehtoa?

1. Usein haettava data on esitettävä käyttäjälle ajantasaisena, mutta päivitykset tapahtuvat epsäännöllisesti, jolloin välimuistia on päivitettävä toistuvasti.
2. Data ei muutu, mutta samaa välimuistidataa haetaan usein.

Ensimmäinen tapaus voi olla verkkokaupan reaaliaikainen suosituimmuusjärjestys. Kun suosituimmuusjärjestys tallennetaan sorted setinä, on tehotonta lukea sitä Redisistä joka kerta, kun käyttäjä avaa pääsivun.
Toinen tapaus voi olla valuuttakurssidata. Vaikka kurssit julkaistaan karkeasti 10 minuutin välein, todellinen kysyntä on hyvin suurta. Erityisesti USD/KRW-, JPY/KRW- ja CNY/KRW-kursseja haetaan välimuistista erittäin tiheästi.
Tällaisissa tapauksissa on tehokkaampaa, että API-palvelimella on oma paikallinen välimuisti, joka päivitetään Redisistä aina, kun data muuttuu.

Miten tämä toiminta voidaan toteuttaa tietokanta - Redis - API-palvelin -arkkitehtuurissa?

### Eikö Redis PubSub riitä?

> Kun käytetään välimuistia, tilaa kanava, josta saa tiedon päivityksistä!

- Tällöin on rakennettava logiikka, joka lähettää viestin päivityksen yhteydessä.
- PubSubin lisätoiminnot vaikuttavat suorituskykyyn.

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

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

### Entä jos Redis havaitsee muutokset itse?

> Entä jos käytetään Keyspace Notificationia ja saadaan ilmoitus tietyn avaimen komennoista?

- Päivityksiin käytettävät avaimet ja komennot on tallennettava etukäteen ja jaettava, mikä on hankalaa.
- Esimerkiksi joillakin avaimilla päivityskomento on yksinkertainen Set, kun taas toisilla se on LPush, RPush, SAdd tai SRem. Tämä tekee järjestelystä monimutkaisen.
- Tämä lisää merkittävästi todennäköisyyttä kommunikaatiovirheisiin kehitystyössä ja inhimillisiin virheisiin koodauksessa.

> Entä jos käytetään Keyevent Notificationia ja saadaan ilmoitus komentotasolla?

- Tarvitaan tilaus kaikkiin päivityksiin käytettäviin komentoihin, ja saapuvat avaimet on suodatettava asianmukaisesti.
- Esimerkiksi osalle Del-komennolla tulevista avaimista asiakkaalla ei todennäköisesti ole paikallista välimuistia.
- Tämä voi johtaa tarpeettomaan resurssien kulutukseen.

### Siksi tarvitaan Invalidation Message!

#### Mikä on Invalidation Message?

Invalidation Messages on Redis 6.0:sta lähtien tarjottu käsite, joka kuuluu Server Assisted Client-Side Cache -ominaisuuteen. Invalidation Message välitetään seuraavassa kulussa.

1. Oletetaan, että ClientB on lukenut avaimen jo kerran.
2. ClientA asettaa tälle avaimelle uuden arvon.
3. Redis havaitsee muutoksen ja lähettää ClientB:lle Invalidation Messagen, joka pyytää tyhjentämään välimuistin.
4. ClientB vastaanottaa viestin ja ryhtyy asianmukaisiin toimenpiteisiin.

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

### Miten tätä käytetään

#### Perusrakenne

Redisiin yhdistetty asiakas suorittaa komennon `CLIENT TRACKING ON REDIRECT <client-id>`, jotta se saa invalidation messagen. Viestejä vastaanottavan asiakkaan on tilattava ne komennolla `SUBSCRIBE __redis__:invalidate`.

#### Oletusseuranta (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"
```

#### Lähetysseuranta (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"
```

## Toteutus! Toteutus! Toteutus!

### Redigo + Ristretto

Pelkkä teoreettinen selitys ei kerro, miten tätä käytetään koodissa. Rakennetaan siksi ensin yksinkertainen toteutus `redigo`- ja `ristretto`-kirjastoilla.

Asenna ensin kaksi riippuvuutta:

- `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,     // number of keys to track frequency of (10M). // seurattavien avainten lukumäärä taajuuden seurantaan (10M).
		MaxCost:     1 << 30, // maximum cost of cache (1GB). // välimuistin enimmäiskustannus (1GB).
		BufferItems: 64,      // number of keys per Get buffer. // avainten määrä Get-puskuria kohden.
	})
	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
}
```

Ensin luodaan `RedisClient`, joka sisältää ristretton ja redigon.

```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))
		}
	}
}
```

Koodi on hieman monimutkainen.

- Seurantaa varten avataan yksi ylimääräinen yhteys. Tämä on tehty siksi, että PubSub ei saa häiritä muita toimintoja.
- Lisätyn yhteyden tunnus haetaan, ja datan lukemiseen käytettävä yhteys ohjataan uudelleen tähän yhteyteen seurannan osalta.
- Tämän jälkeen tilataan invalidation message.
- Tilauksen käsittelykoodi on hieman monimutkainen. Koska redigo ei jäsennä invalidation-viestejä, vastaus on vastaanotettava ennen jäsentämistä ja käsiteltävä raakana.

```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
}
```

`Get`-metodi hakee ensin arvon ristrettosta ja hakee sen Redisistä, jos sitä ei ole välimuistissa.

```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)
		}
	}
}
```

Yllä on testikoodi. Kun kokeilet sitä, näet, että arvo päivittyy uudelleen aina, kun data muuttuu Redisissä.

Tämä on kuitenkin liian monimutkaista. Lisäksi klusteriin skaalaaminen edellyttää, että seuranta otetaan käyttöön kaikille masterille ja replikoille.

### Rueidis

Go-kielen käyttäjälle tarjolla on moderni ja kehittynyt `rueidis`. Kirjoitetaan seuraavaksi koodi, joka käyttää server assisted client side cachea Redis-klusterissa rueidisin avulla.

Asenna ensin riippuvuus:

- `github.com/redis/rueidis`

Kirjoitetaan sen jälkeen koodi, joka hakee dataa Redisistä.

```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)
		}
	}
}
```

Rueidisissä client side cachea käytetään yksinkertaisesti kutsumalla `DoCache`-metodia. Voit määrittää, kuinka kauan data säilyy paikallisessa välimuistissa. Kun `DoCache` kutsutaan uudelleen, data haetaan paikallisesta välimuistista. Myös invalidation message käsitellään luotettavasti.

### Miksi ei redis-go?

Valitettavasti `redis-go` ei tue server assisted client side cachea virallisen API:n kautta. Lisäksi PubSubia luotaessa luodaan uusi yhteys, eikä siihen ole suoraa API-pääsyä, joten client id ei ole saatavilla. Tästä syystä päätimme, että `redis-go`:lla rakentaminen ei ole mahdollista, ja jätimme sen pois.

## Hienoa

### Client side cache -rakenteen avulla

- Jos data voidaan valmistella etukäteen, tämän rakenteen avulla Redis-kyselyjä ja liikennettä voidaan minimoida, ja data on aina ajantasaista.
- Näin voidaan luoda eräänlainen CQRS-rakenne, joka nostaa lukusuorituskykyä merkittävästi.

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

### Kuinka paljon parempi tilanne on?

Käytämme tällaista rakennetta tuotannossa, joten mittasimme kahden API:n viiveen. Pahoittelemme, että esitys on hyvin yleistasoinen.

1. Ensimmäinen API
   1. Ensimmäinen haku: keskimäärin 14,63 ms
   2. Seuraavat haut: keskimäärin 2,82 ms
   3. Keskimääräinen ero: 10,98 ms
2. Toinen API
   1. Ensimmäinen haku: keskimäärin 14,05 ms
   2. Seuraavat haut: keskimäärin 1,60 ms
   3. Keskimääräinen ero: 11,57 ms

Viiveen parannus oli jopa noin 82 % lisää.

Odotamme seuraavien parannusten syntyneen:

- Asiakkaan ja Redisin välisen verkkoliikenteen ohittaminen ja liikenteen säästö
- Redisin itsensä suorittamien lukukomentojen määrän väheneminen
  - Tämä parantaa myös kirjoitussuorituskykyä.
- Redis-protokollan jäsentämisen vähentyminen
  - Redis-protokollan jäsentäminen ei ole kustannuksetonta. Sen vähentäminen on merkittävä mahdollisuus.

Kaikki on kuitenkin kompromissi. Tämän vuoksi uhrasimme vähintään seuraavat kaksi asiaa.

- Client side cache -hallintakomponentin toteutus, käyttö ja ylläpito vaativat työtä
- Asiakkaan suorittimen ja muistin käytön kasvu

## Johtopäätös

Henkilökohtaisesti pidin tätä arkkitehtuurikomponenttia tyydyttävänä: viive ja kuormitus API-palvelimella jäivät hyvin pieniksi. Toivon, että voimme jatkossakin rakentaa arkkitehtuurin tähän tapaan aina, kun se on mahdollista.
