GoSuda

Reagoivuuden parantaminen Redis client-side cache -välimuistin avulla

By snowmerak views ...

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

pubsub-read

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

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)

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"

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

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

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

 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("received message", "channel", msg.Channel, "data", msg.Data)
33			key := string(msg.Data)
34			r.cache.Del(key)
35		case redis.Subscription:
36			slog.Info("subscription", "kind", msg.Kind, "channel", msg.Channel, "count", 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("unexpected message", "message", 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("received invalidation message", "keys", keys)
52		default:
53			slog.Warn("unexpected message", "type", fmt.Sprintf("%T", msg))
54		}
55	}
56}

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.
 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", "key", key)
 7			return v, nil
 8		default:
 9			slog.Warn("unexpected type", "type", fmt.Sprintf("%T", v))
10		}
11	}
12	slog.Info("cache miss", "key", 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-metodi hakee ensin arvon ristrettosta ja hakee sen Redisistä, jos sitä ei ole välimuistissa.

 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("shutting down")
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("got key", "value", v)
43		}
44	}
45}

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ä.

 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("shutting down")
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", "key", key)
48			case false:
49				slog.Info("missed key", "key", key)
50			}
51			slog.Info("got key", "value", i)
52		}
53	}
54}

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

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.