---
id: 1c72ae6efee059c2c727ea53b56bf6c5
author: snowmerak
title: Verbeteren van de responsiviteit met een client-side cache in Redis
description: Verbetering van de responsiviteit van API's met behulp van Redis Server Assisted Client Side Cache
language: nl
date: 2024-11-29T00:51:27.525566863Z
path: /blog/posts/improving-responsiveness-with-redis-client-side-caching-zb711e502
category: database
---

## Wat is Redis?

Ik denk dat er weinig mensen zijn die Redis niet kennen. Toch wil ik er kort enkele kenmerken van noemen, die als volgt samengevat kunnen worden.

- Bewerkingen worden uitgevoerd in een **enkele thread**, waardoor alle bewerkingen **atomair** zijn.
- Gegevens worden **In-Memory** opgeslagen en verwerkt, waardoor alle bewerkingen **snel** zijn.
- Redis kan, afhankelijk van de instellingen, een **WAL** opslaan, waardoor de meest recente status snel kan worden **gebackupt** en **hersteld**.
- Redis ondersteunt meerdere **datatypen**, zoals Set, Hash, Bit en List, waardoor het een hoge **productiviteit** biedt.
- Redis heeft een **grote community**, waardoor men ervaringen, problemen en oplossingen kan **delen** en ontvangen.
- Redis wordt **al lange tijd ontwikkeld en onderhouden**, waardoor het een betrouwbare **stabiliteit** heeft.

## Nu ter zake

### Stel je voor

Hoe zou het zijn als de cache van uw service aan de volgende twee voorwaarden voldoet?

1. Vaak opgevraagde gegevens moeten actueel aan gebruikers worden aangeboden, maar omdat de updates onregelmatig plaatsvinden, moet de cache vaak worden bijgewerkt.
2. De gegevens worden niet bijgewerkt, maar dezelfde cachegegevens worden vaak opgevraagd.

Het eerste geval kan bijvoorbeeld de real-time populariteitsranglijst van een webwinkel zijn. Wanneer de real-time populariteitsranglijst van een webwinkel is opgeslagen als sorted set, is het inefficiënt om deze bij elke toegang van een gebruiker tot de hoofdpagina vanuit Redis uit te lezen.  
Het tweede geval betreft wisselkoersgegevens. Hoewel wisselkoersen ongeveer elke 10 minuten worden gepubliceerd, vinden de daadwerkelijke opvragingen zeer frequent plaats. Met name voor won-dollar, won-yen en won-yuan wordt de cache zeer frequent opgevraagd.  
In dergelijke gevallen is het efficiënter dat de API-server een eigen lokale cache bijhoudt en, wanneer de gegevens veranderen, Redis opnieuw raadpleegt om de cache bij te werken.

Hoe kan men dit gedrag realiseren in een structuur van database, Redis en API-server?

### Lukt dit niet met Redis PubSub?

> Abonneer je op een kanaal waarmee je kunt ontvangen of er een update is wanneer je de cache gebruikt!

- Er moet dan logica worden gebouwd die bij een update een bericht verstuurt.
- Doordat er extra bewerkingen door PubSub worden toegevoegd, heeft dit invloed op de prestaties.

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

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

### Wat als Redis de wijzigingen zelf detecteert?

> Wat als je via Keyspace Notification meldingen ontvangt van de commando's voor specifieke sleutels?

- Er bestaat de last dat de sleutels en commando's die voor updates worden gebruikt van tevoren moeten worden opgeslagen en gedeeld.
- Zo kan voor sommige sleutels een eenvoudige Set het updatecommando zijn, terwijl voor andere sleutels LPush, RPush, SAdd of SRem het updatecommando is, wat het complex maakt.
- Hierdoor neemt de kans op communicatiefouten tijdens de ontwikkeling en menselijke fouten in de code sterk toe.

> Wat als je via Keyevent Notification meldingen per commando ontvangt?

- Er is een abonnement nodig op alle commando's die voor updates worden gebruikt. Voor de binnenkomende sleutels is een passende filtering nodig.
- Zo is het bij Del waarschijnlijk dat de client voor sommige binnenkomende sleutels geen lokale cache heeft.
- Dit kan leiden tot onnodige verspilling van resources.

### Daarom is Invalidation Message nodig!

#### Wat is een Invalidation Message?

Invalidation Messages is een concept dat wordt aangeboden als onderdeel van de Server Assisted Client-Side Cache die sinds Redis 6.0 beschikbaar is. Een Invalidation Message wordt via de volgende stroom verzonden.

1. Veronderstel dat ClientB een key al eens heeft gelezen.
2. ClientA stelt die key opnieuw in.
3. Redis detecteert de wijziging en stuurt een Invalidation Message naar ClientB om hem te laten weten dat de cache moet worden gewist.
4. ClientB ontvangt het bericht en neemt de passende maatregelen.

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

### Hoe gebruik je het?

#### Basisstructuur

Een client die verbonden is met Redis voert `CLIENT TRACKING ON REDIRECT <client-id>` uit, zodat deze invalidation messages ontvangt. De client die de berichten moet ontvangen, abonneert zich vervolgens met `SUBSCRIBE __redis__:invalidate` op de invalidation messages.

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

## Implementatie! Implementatie! Implementatie!

### Redigo + Ristretto

Als alleen zo wordt uitgelegd, is het onduidelijk hoe je dit in de praktijk in code moet gebruiken. Daarom ga ik eerst met `redigo` en `ristretto` een eenvoudige opzet maken.

Installeer eerst de twee dependencies.

- `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). // het aantal sleutels waarvan de frequentie wordt bijgehouden (10M).
		MaxCost:     1 << 30, // maximum cost of cache (1GB). // maximale kosten van de cache (1GB).
		BufferItems: 64,      // number of keys per Get buffer. // aantal sleutels per Get-buffer.
	})
	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
}
```

Eerst maak ik een `RedisClient` aan die ristretto en redigo bevat.

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

De code is wat ingewikkeld.

- Voor Tracking wordt nog een verbinding gemaakt. Dit is een maatregel omdat PubSub de overige bewerkingen zou kunnen verstoren.
- Het ID van de toegevoegde verbinding wordt opgevraagd, waarna de Tracking van de verbinding waarmee de gegevens worden opgevraagd naar die verbinding wordt omgeleid.
- Vervolgens abonneert men zich op de invalidation messages.
- De verwerking van de abonnementen is wat ingewikkeld. Omdat redigo de invalidation messages niet kan parsen, moet de respons vóór het parsen worden ontvangen en verwerkt.

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

De `Get`-methode zoekt eerst in ristretto, en haalt de waarde alleen uit Redis wanneer deze daar niet staat, zoals hieronder wordt weergegeven.

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

De code voor de test is hierboven weergegeven. Als u deze test uitvoert, zult u zien dat de waarde telkens opnieuw wordt bijgewerkt wanneer de gegevens in Redis veranderen.

Dit is echter te ingewikkeld. Bovendien is het voor uitbreiding naar een cluster noodzakelijk om Tracking op alle masters en replica's in te schakelen.

### Rueidis

Als je Go gebruikt, hebben we de meest moderne en geavanceerde optie: `rueidis`. Laten we code schrijven die server assisted client side cache gebruikt in een Redis-clusteromgeving met rueidis.

Installeer eerst de dependency.

- `github.com/redis/rueidis`

Schrijf vervolgens code die gegevens uit Redis opvraagt.

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

Om de client side cache in rueidis te gebruiken, hoeft men alleen `DoCache` aan te roepen. Daarbij geeft men op hoe lang de gegevens in de lokale cache moeten blijven. Wanneer `DoCache` daarna opnieuw wordt aangeroepen, worden de gegevens uit de lokale cache opgehaald. Uiteraard worden invalidation messages ook correct verwerkt.

### Waarom niet redis-go?

Helaas ondersteunt `redis-go` de server assisted client side cache niet via de officiële API. Bovendien is er bij het aanmaken van een PubSub een nieuwe verbinding, en is er geen API om rechtstreeks toegang tot die verbinding te krijgen, waardoor ook het client-ID niet kan worden opgevraagd. Daarom heb ik geoordeeld dat de opzet met `redis-go` niet mogelijk is, en heb ik deze optie laten vallen.

## Wat ziet dat er goed uit

### Via de client side cache-structuur

- Voor gegevens die vooraf kunnen worden voorbereid, kunnen de queries en het verkeer naar Redis via deze structuur tot een minimum worden beperkt, terwijl altijd actuele gegevens worden aangeboden.
- Hiermee kan een soort CQRS-structuur worden gecreëerd, waardoor de leesprestaties sterk kunnen worden verbeterd.

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

### Hoeveel beter is het geworden?

Deze structuur wordt momenteel daadwerkelijk in productie gebruikt, dus heb ik de latentie van twee API's kort onderzocht. Ik vraag uw begrip dat ik dit slechts zeer abstract kan weergeven.

1. Eerste API
   1. Bij de eerste opvraging: gemiddeld 14,63 ms
   2. Bij latere opvragingen: gemiddeld 2,82 ms
   3. Gemiddeld verschil: 10,98 ms
2. Tweede API
   1. Bij de eerste opvraging: gemiddeld 14,05 ms
   2. Bij latere opvragingen: gemiddeld 1,60 ms
   3. Gemiddeld verschil: 11,57 ms

Er was een extra latentieverbetering van maximaal ongeveer 82%!

Ik verwacht dat de volgende verbeteringen zijn opgetreden.

- Overslaan van de netwerkcommunicatie tussen client en Redis en besparing op verkeer
- Vermindering van het aantal leescommando's dat Redis zelf moet uitvoeren
  - Dit heeft ook het effect dat de schrijfprestaties verbeteren.
- Minimalisatie van het parsen van het Redis-protocol
  - Het parsen van het Redis-protocol kost ook niet niets. Het verminderen daarvan is een grote kans.

Echter, alles is een afweging. Hiervoor hebben we minimaal de volgende twee zaken opgeofferd.

- Implementatie, beheer en onderhoud van de client side cache-componenten zijn nodig
- Daardoor neemt het gebruik van CPU en geheugen van de client toe

## Conclusie

Persoonlijk was dit een bevredigend architectuurcomponent, en de latentie en de belasting van de API-server waren ook zeer laag. Ik denk dat ik in de toekomst, waar mogelijk, weer zo'n structuur voor de architectuur zal kiezen.
