Forbedring av responsivitet med Redis Client-Side Caching
Hva er Redis?
Jeg tror det er få som ikke kjenner til Redis. Men hvis vi likevel skal oppsummere noen av dens egenskaper kort, kan det gjøres slik:
- Operasjoner utføres i en enkelt tråd, noe som gir alle operasjoner atomisitet.
- Data lagres og behandles In-Memory, noe som gjør alle operasjoner raske.
- Redis kan lagre WAL (Write-Ahead Log) avhengig av opsjoner, slik at den raskt kan sikkerhetskopiere og gjenopprette den nyeste tilstanden.
- Den støtter flere typer som Set, Hash, Bit, List, noe som gir høy produktivitet.
- Den har et stort fellesskap, slik at man kan dele ulike erfaringer, problemer og løsninger.
- Den har blitt utviklet og drevet over lang tid, noe som gir pålitelig stabilitet.
Så til poenget
Tenk deg?
Hva om cache-minnet i tjenesten deres oppfyller følgende to betingelser?
- Du må gi brukeren hyppig etterspurte data i sanntid, men oppdateringen er uregelmessig, noe som krever hyppige cache-oppdateringer.
- Oppdatering er ikke nødvendig, men du må ofte aksessére og hente data fra det samme cache-minnet.
Det første tilfellet kan være en nettbutikks sanntids popularitetsrangering. Hvis nettbutikkens sanntids popularitetsrangering lagres som et sorted set, vil det være ineffektivt å lese fra Redis hver gang en bruker besøker hovedsiden. I det andre tilfellet, selv om valutakursdata oppdateres omtrent hvert tiende minutt, skjer faktiske forespørsler svært hyppig. Spesielt for valutakursene mellom won-dollar, won-yen og won-yuan, vil cache-minnet bli konsultert svært ofte. I slike tilfeller vil det være en mer effektiv operasjon hvis API-serveren har en separat lokal cache, og oppdaterer den ved å spørre Redis på nytt når dataene endres.
Så hvordan kan en slik operasjon implementeres i en database – Redis – API-server-struktur?
Kan det ikke gjøres med Redis PubSub?
Når du bruker cache, abonner på en kanal som kan motta varsler om oppdateringer!
- Da må man lage logikk for å sende meldinger ved oppdatering.
- Ytelsen påvirkes av ekstra operasjoner forårsaket av PubSub.


Hva om Redis registrerer endringene?
Hva om man bruker Keyspace Notification for å motta kommando-varsler for en bestemt nøkkel?
- Det er en ulempe med å måtte lagre og dele nøklene og kommandoene som brukes for oppdatering på forhånd.
- For eksempel blir det komplisert, da en enkel
Setkan være oppdateringskommandoen for noen nøkler, mensLPush,RPush,SAddellerSRemkan være det for andre. - Dette øker i stor grad sannsynligheten for kommunikasjonsfeil og menneskelige feil under utviklingsprosessen.
Hva om man bruker Keyevent Notification for å motta varsler per kommando?
- Det kreves abonnement på alle kommandoer som brukes for oppdatering. Det er også nødvendig med passende filtrering for nøklene som kommer inn derfra.
- For eksempel er det sannsynlig at klienten ikke har en lokal cache for noen av nøklene som kommer inn via
Del. - Dette kan føre til unødvendig ressursforbruk.
Derfor er Invalidation Message nødvendig!
Hva er Invalidation Message?
Invalidation Messages er et konsept som tilbys som en del av Server Assisted Client-Side Cache, lagt til fra Redis 6.0. Invalidation Message overføres i følgende flyt:
- Anta at ClientB allerede har lest en nøkkel én gang.
- ClientA setter den samme nøkkelen på nytt.
- Redis registrerer endringen og publiserer en Invalidation Message til ClientB for å informere ClientB om å slette cachen.
- ClientB mottar meldingen og utfører passende handlinger.

Hvordan brukes det?
Grunnleggende operasjonsstruktur
En klient koblet til Redis utfører CLIENT TRACKING ON REDIRECT <client-id> for å motta invalidation messages. Klienten som skal motta meldinger, abonnerer deretter på SUBSCRIBE __redis__:invalidate for å motta invalidation messages.
default tracking
1# client 1
2> SET a 100
1# client 3
2> CLIENT ID
312
4> SUBSCRIBE __redis__:invalidate
51) "subscribe"
62) "__redis__:invalidate"
73) (integer) 1
1# client 2
2> CLIENT TRACKING ON REDIRECT 12
3> GET a # tracking
1# client 1
2> SET a 200
1# client 3
21) "message"
32) "__redis__:invalidate"
43) 1) "a"
broadcasting tracking
1# client 3
2> CLIENT ID
312
4> SUBSCRIBE __redis__:invalidate
51) "subscribe"
62) "__redis__:invalidate"
73) (integer) 1
1# client 2
2CLIENT TRACKING ON BCAST PREFIX cache: REDIRECT 12
1# client 1
2> SET cache:name "Alice"
3> SET cache:age 26
1# client 3
21) "message"
32) "__redis__:invalidate"
43) 1) "cache:name"
51) "message"
62) "__redis__:invalidate"
73) 1) "cache:age"
Implementering! Implementering! Implementering!
Redigo + Ristretto
Med kun en slik forklaring er det uklart hvordan det faktisk skal brukes i kode. La oss derfor raskt konfigurere det med redigo og ristretto først.
Installer først de to avhengighetene.
github.com/gomodule/redigogithub.com/dgraph-io/ristretto
1package main
2
3import (
4 "context"
5 "fmt"
6 "log/slog"
7 "time"
8
9 "github.com/dgraph-io/ristretto"
10 "github.com/gomodule/redigo/redis"
11)
12
13type RedisClient struct {
14 conn redis.Conn
15 cache *ristretto.Cache[string, any]
16 addr string
17}
18
19func NewRedisClient(addr string) (*RedisClient, error) {
20 cache, err := ristretto.NewCache(&ristretto.Config[string, any]{
21 NumCounters: 1e7, // antall nøkler å spore frekvensen av (10M).
22 MaxCost: 1 << 30, // maksimal kostnad for cache (1GB).
23 BufferItems: 64, // antall nøkler per Get-buffer.
24 })
25 if err != nil {
26 return nil, fmt.Errorf("failed to generate cache: %w", err)
27 }
28
29 conn, err := redis.Dial("tcp", addr)
30 if err != nil {
31 return nil, fmt.Errorf("failed to connect to redis: %w", err)
32 }
33
34 return &RedisClient{
35 conn: conn,
36 cache: cache,
37 addr: addr,
38 }, nil
39}
40
41func (r *RedisClient) Close() error {
42 err := r.conn.Close()
43 if err != nil {
44 return fmt.Errorf("failed to close redis connection: %w", err)
45 }
46
47 return nil
48}
Først oppretter vi en RedisClient som enkelt inkluderer ristretto og redigo.
1func (r *RedisClient) Tracking(ctx context.Context) error {
2 psc, err := redis.Dial("tcp", r.addr)
3 if err != nil {
4 return fmt.Errorf("failed to connect to redis: %w", err)
5 }
6
7 clientId, err := redis.Int64(psc.Do("CLIENT", "ID"))
8 if err != nil {
9 return fmt.Errorf("failed to get client id: %w", err)
10 }
11 slog.Info("client id", "id", clientId)
12
13 subscriptionResult, err := redis.String(r.conn.Do("CLIENT", "TRACKING", "ON", "REDIRECT", clientId))
14 if err != nil {
15 return fmt.Errorf("failed to enable tracking: %w", err)
16 }
17 slog.Info("subscription result", "result", subscriptionResult)
18
19 if err := psc.Send("SUBSCRIBE", "__redis__:invalidate"); err != nil {
20 return fmt.Errorf("failed to subscribe: %w", err)
21 }
22 psc.Flush()
23
24 for {
25 msg, err := psc.Receive()
26 if err != nil {
27 return fmt.Errorf("failed to receive message: %w", err)
28 }
29
30 switch msg := msg.(type) {
31 case redis.Message:
32 slog.Info("mottatt melding", "kanal", msg.Channel, "data", msg.Data)
33 key := string(msg.Data)
34 r.cache.Del(key)
35 case redis.Subscription:
36 slog.Info("abonnement", "type", msg.Kind, "kanal", msg.Channel, "antall", msg.Count)
37 case error:
38 return fmt.Errorf("error: %w", msg)
39 case []interface{}:
40 if len(msg) != 3 || string(msg[0].([]byte)) != "message" || string(msg[1].([]byte)) != "__redis__:invalidate" {
41 slog.Warn("uventet melding", "melding", msg)
42 continue
43 }
44
45 contents := msg[2].([]interface{})
46 keys := make([]string, len(contents))
47 for i, key := range contents {
48 keys[i] = string(key.([]byte))
49 r.cache.Del(keys[i])
50 }
51 slog.Info("mottatt invalidation message", "nøkler", keys)
52 default:
53 slog.Warn("uventet melding", "type", fmt.Sprintf("%T", msg))
54 }
55 }
56}
Koden er litt kompleks.
- For å utføre Tracking etableres en ekstra forbindelse. Dette er et tiltak tatt i betraktning at PubSub kan forstyrre andre operasjoner.
- Klient-ID-en til den tilleggsforbindelsen hentes, og Tracking for forbindelsen som skal hente data, omdirigeres til denne forbindelsen.
- Deretter abonneres det på invalidation messages.
- Koden for å behandle abonnementet er litt kompleks. Siden redigo ikke kan parse ugyldiggjøringsmeldinger, må responsen behandles før parsing.
1func (r *RedisClient) Get(key string) (any, error) {
2 val, found := r.cache.Get(key)
3 if found {
4 switch v := val.(type) {
5 case int64:
6 slog.Info("cache hit", "nøkkel", key)
7 return v, nil
8 default:
9 slog.Warn("uventet type", "type", fmt.Sprintf("%T", v))
10 }
11 }
12 slog.Info("cache miss", "nøkkel", key)
13
14 val, err := redis.Int64(r.conn.Do("GET", key))
15 if err != nil {
16 return nil, fmt.Errorf("failed to get key: %w", err)
17 }
18
19 r.cache.SetWithTTL(key, val, 1, 10*time.Second)
20 return val, nil
21}
Get-meldingen undersøker først Ristretto, og hvis den ikke finnes, henter den fra Redis.
1package main
2
3import (
4 "context"
5 "log/slog"
6 "os"
7 "os/signal"
8 "time"
9)
10
11func main() {
12 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
13 defer cancel()
14
15 client, err := NewRedisClient("localhost:6379")
16 if err != nil {
17 panic(err)
18 }
19 defer client.Close()
20
21 go func() {
22 if err := client.Tracking(ctx); err != nil {
23 slog.Error("failed to track invalidation message", "error", err)
24 }
25 }()
26
27 ticker := time.NewTicker(1 * time.Second)
28 defer ticker.Stop()
29 done := ctx.Done()
30
31 for {
32 select {
33 case <-done:
34 slog.Info("avslutter")
35 return
36 case <-ticker.C:
37 v, err := client.Get("key")
38 if err != nil {
39 slog.Error("failed to get key", "error", err)
40 return
41 }
42 slog.Info("fikk nøkkel", "verdi", v)
43 }
44 }
45}
Koden for testing er som ovenfor. Hvis du tester den, vil du kunne se at Redis oppdaterer verdien hver gang dataene oppdateres.
Men dette er for komplisert. Fremfor alt, for å skalere opp til et cluster, er det uunngåelig å måtte aktivere Tracking for alle mastere, eller replikaer.
Rueidis
For de som bruker Go, har vi den mest moderne og avanserte rueidis. Vi skal skrive kode som bruker server assisted client side cache i et Redis cluster-miljø med rueidis.
Først installeres avhengigheten.
github.com/redis/rueidis
Deretter skrives koden for å spørre Redis om data.
1package main
2
3import (
4 "context"
5 "log/slog"
6 "os"
7 "os/signal"
8 "time"
9
10 "github.com/redis/rueidis"
11)
12
13func main() {
14 ctx, cancel := signal.NotifyContext(context.Background(), os.Interrupt)
15 defer cancel()
16
17 client, err := rueidis.NewClient(rueidis.ClientOption{
18 InitAddress: []string{"localhost:6379"},
19 })
20 if err != nil {
21 panic(err)
22 }
23
24 ticker := time.NewTicker(1 * time.Second)
25 defer ticker.Stop()
26 done := ctx.Done()
27
28 for {
29 select {
30 case <-done:
31 slog.Info("avslutter")
32 return
33 case <-ticker.C:
34 const key = "key"
35 resp := client.DoCache(ctx, client.B().Get().Key(key).Cache(), 10*time.Second)
36 if resp.Error() != nil {
37 slog.Error("failed to get key", "error", resp.Error())
38 continue
39 }
40 i, err := resp.AsInt64()
41 if err != nil {
42 slog.Error("failed to convert response to int64", "error", err)
43 continue
44 }
45 switch resp.IsCacheHit() {
46 case true:
47 slog.Info("cache hit", "nøkkel", key)
48 case false:
49 slog.Info("missed key", "nøkkel", key)
50 }
51 slog.Info("fikk nøkkel", "verdi", i)
52 }
53 }
54}
For å bruke client side cache i rueidis, er det bare å kalle DoCache. Da legges dataene til den lokale cachen, inkludert hvor lenge de skal beholdes, og ved å kalle DoCache igjen hentes dataene fra den lokale cachen. Naturligvis behandles invalidation messages også korrekt.
Hvorfor ikke redis-go?
redis-go støtter dessverre ikke server assisted client side cache som et offisielt API. I tillegg, når en PubSub opprettes, lages en ny forbindelse, og det er ingen API for direkte tilgang til denne forbindelsen, slik at klient-ID-en ikke kan gjenkjennes. Derfor ble redis-go utelatt, da det ble vurdert at konfigurasjonen i seg selv ikke var mulig.
Sexy
Gjennom client side cache-strukturen
- Hvis data kan forberedes på forhånd, vil denne strukturen muliggjøre levering av de nyeste dataene til enhver tid, samtidig som Redis-spørringer og trafikk minimeres.
- Dette kan skape en slags CQRS-struktur, som dramatisk forbedrer leseytelsen.

Hvor mye mer sexy ble det?
Faktisk, siden en slik struktur allerede er i bruk, har jeg undersøkt enkle latenser for de to API-ene. Jeg ber om forståelse for at jeg bare kan skrive det veldig abstrakt.
- Første API
- Første forespørsel: Gjennomsnittlig 14.63ms
- Senere forespørsler: Gjennomsnittlig 2.82ms
- Gjennomsnittlig forskjell: 10.98ms
- Andre API
- Første forespørsel: Gjennomsnittlig 14.05ms
- Senere forespørsler: Gjennomsnittlig 1.60ms
- Gjennomsnittlig forskjell: 11.57ms
Det var en ytterligere forbedring av latensen på opptil omtrent 82%!
Jeg forventer at følgende forbedringer har funnet sted:
- Eliminering av nettverkskommunikasjon mellom klienten og Redis og trafikkbesparelse.
- Reduksjon i antall lesekommandoer som Redis selv må utføre.
- Dette har også effekten av å forbedre skriveytelsen.
- Minimering av parsing av Redis-protokollen.
- Parsing av Redis-protokollen er ikke uten kostnad. Å redusere dette er en stor mulighet.
Men alt er en avveining. For dette har vi ofret minst to ting:
- Behovet for å implementere, drifte og vedlikeholde elementer for klient-side cache-administrasjon.
- Økt CPU- og minnebruk på klienten som følge av dette.
Konklusjon
Personlig var dette en tilfredsstillende arkitekturkomponent, og latensen og belastningen på API-serveren var svært lav. Jeg tror det ville være fordelaktig å fortsette å strukturere arkitekturen på denne måten hvis mulig.