Reaktiivisuuden parantaminen Redis-asiakaspuolen välimuistin avulla
Mikä on Redis?
Tuskin on olemassa monia, jotka eivät tunne Redisiä. Kuitenkin, jos haluaisimme lyhyesti mainita muutaman sen ominaisuuden, voisimme tiivistää ne seuraavasti:
- Toiminnot suoritetaan yhdessä säikeessä, joten kaikki toiminnot ovat atomisia.
- Tiedot tallennetaan ja käsitellään In-Memory-muistiin, mikä tekee kaikista toiminnoista nopeita.
- Redis voi tallentaa WAL-tiedostoja (Write-Ahead Log) asetuksista riippuen, mikä mahdollistaa nopean viimeisimmän tilan varmuuskopioinnin ja palauttamisen.
- Se tukee monia eri tyyppejä, kuten Set, Hash, Bit ja List, mikä tekee siitä erittäin tuottavan.
- Sillä on suuri yhteisö, jonka kautta voi jakaa erilaisia kokemuksia, ongelmia ja ratkaisuja.
- Sitä on kehitetty ja ylläpidetty pitkään, mikä takaa luotettavan vakauden.
Joten asiaan
Kuvittelepa?
Mitä jos palvelusi välimuisti täyttäisi seuraavat kaksi ehtoa?
- Usein haettavat tiedot on tarjottava käyttäjälle ajantasaisena, mutta päivitys on epäsäännöllistä, mikä vaatii välimuistin tiheää päivitystä.
- Päivitystä ei tapahdu, mutta samaa välimuistitietoa on haettava ja tarkistettava hyvin usein.
Ensimmäisessä tapauksessa voidaan harkita verkkokaupan reaaliaikaisia suosituimpia tuotteita. Kun verkkokaupan reaaliaikaiset suosituimmat tuotteet tallennetaan sorted set -muotoon, olisi tehotonta, jos Redis lukisi ne joka kerta, kun käyttäjä saapuu pääsivulle. Toisessa tapauksessa, vaikka valuuttakurssitiedot julkaistaan noin 10 minuutin välein, todelliset haut tapahtuvat hyvin usein. Erityisesti Won-Dollari-, Won-Jeni- ja Won-Yuan-kursseja haetaan välimuistista hyvin tiheästi. Näissä tapauksissa olisi tehokkaampaa, että API-palvelimella olisi erillinen paikallinen välimuisti ja se päivittäisi sen hakemalla tiedot uudelleen Redisistä, kun tiedot muuttuvat.
Miten tällainen toiminta voidaan toteuttaa tietokanta – Redis – API-palvelin -rakenteessa?
Eikö Redis PubSubilla onnistu?
Kun käytät välimuistia, tilaa kanava, josta voit vastaanottaa päivitysilmoituksia!
- Silloin on luotava logiikka, joka lähettää viestin päivityksen yhteydessä.
- PubSubin aiheuttama lisätoiminta vaikuttaa suorituskykyyn.


Entä jos Redis havaitsisi muutokset?
Käyttämällä Keyspace Notificationia, jos vastaanotettaisiin komentohälytyksiä tietystä avaimesta?
- On olemassa hankaluus, että päivityksessä käytetyt avaimet ja komennot on tallennettava ja jaettava etukäteen.
- Esimerkiksi tietyille avaimille yksinkertainen Set on päivityskomento, kun taas toisille LPush, RPush, SAdd tai SRem voivat olla päivityskomentoja, mikä tekee siitä monimutkaista.
- Tämä lisää merkittävästi mahdollisuutta kommunikaatiovirheisiin ja inhimillisiin virheisiin koodauksessa kehitysprosessin aikana.
Käyttämällä Keyevent Notificationia, jos vastaanotettaisiin ilmoituksia komennon perusteella?
- Kaikkien päivityksessä käytettävien komentojen tilaaminen on välttämätöntä. Sieltä tulevien avainten osalta tarvitaan asianmukaista suodatusta.
- Esimerkiksi on todennäköistä, että tietyillä Del-komennolla tulevilla avaimilla ei ole paikallista välimuistia kyseisessä asiakasohjelmassa.
- Tämä voi johtaa tarpeettomaan resurssien tuhlaukseen.
Siksi Invalidation Message on välttämätön!
Mikä on Invalidation Message?
Invalidation Messages on käsite, joka otettiin käyttöön Redis 6.0:ssa osana Server Assisted Client-Side Cache -toimintoa. Invalidation Message toimitetaan seuraavan kulun mukaisesti:
- Oletetaan, että ClientB on jo lukenut avaimen kerran.
- ClientA asettaa kyseisen avaimen uudelleen.
- Redis havaitsee muutoksen ja julkaisee Invalidation Messagen ClientB:lle ilmoittaen sille, että välimuisti on tyhjennettävä.
- ClientB vastaanottaa kyseisen viestin ja ryhtyy asianmukaisiin toimenpiteisiin.

Miten sitä käytetään?
Perusrakenne
Redisiin yhdistetty asiakasohjelma saa invalidation message -viestejä suorittamalla CLIENT TRACKING ON REDIRECT <client-id>. Viestejä vastaanottavan asiakasohjelman tulee tilata invalidation message -viestit komennolla SUBSCRIBE __redis__:invalidate.
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"
Toteutus! Toteutus! Toteutus!
Redigo + Ristretto
Jos asia selitetään vain näin, on epäselvää, miten sitä tulisi käyttää todellisessa koodissa. Joten yritämme aluksi rakentaa sen yksinkertaisesti redigo:n ja ristretto:n avulla.
Asenna ensin kaksi riippuvuutta.
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, // seurattavien avainten määrä (10M).
22 MaxCost: 1 << 30, // välimuistin enimmäiskustannus (1GB).
23 BufferItems: 64, // avainten määrä Get-puskuria kohti.
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 yksinkertainen RedisClient, joka sisältää ristretto- ja redigo-kirjastot.
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.
- Se luo toisen yhteyden seurantaa varten. Tämä on varotoimenpide, jotta PubSub ei häiritse muita toimintoja.
- Se hakee lisätyn yhteyden tunnuksen ja ohjaa seurantatoiminnon kyseiseen yhteyteen tietojen hakemista varten.
- Ja se tilaa invalidation message -viestit.
- Tilausten käsittelykoodi on hieman monimutkainen. Koska redigo ei jäsenä invalidointiviestejä, on käsiteltävä jäsentämätön vastaus.
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-viesti ensin hakee arvon Ristrettosta, ja jos sitä ei löydy, se haetaan Redisistä.
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ä oleva koodi on tarkoitettu testaamiseen. Voit havaita, että arvo päivittyy uudelleen aina, kun tietoja päivitetään Redisissä.
Tämä on kuitenkin liian monimutkaista. Ennen kaikkea klusterin skaalautuvuuden vuoksi on välttämätöntä ottaa Tracking käyttöön kaikissa mastereissa tai replikoissa.
Rueidis
Go-kieltä käytettäessä meillä on käytettävissämme nykyaikaisin ja kehittynein rueidis. Kirjoitan koodin, joka käyttää Redis-klusteriympäristössä palvelinavusteista asiakaspuolen välimuistia rueidisin avulla.
Asenna ensin riippuvuus.
github.com/redis/rueidis
Kirjoita sitten koodi, joka hakee tietoja 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}
Rueidisissa client side cache -toiminnon käyttämiseen riittää pelkkä DoCache. Se lisää paikalliseen välimuistiin tiedon, kuinka kauan sitä säilytetään, ja kutsumalla DoCache-funktiota uudelleen, se hakee tiedon paikallisesta välimuistista. Luonnollisesti se käsittelee myös invalidointiviestit asianmukaisesti.
Miksi ei redis-go?
redis-go ei valitettavasti tue virallisesti server assisted client side cache -toimintoa. Lisäksi PubSubia luotaessa uusi yhteys luodaan, eikä ole olemassa API:a, joka mahdollistaisi suoran pääsyn tähän yhteyteen, joten client id:tä ei voida selvittää. Siksi redis-go:n todettiin olevan mahdoton konfiguroida, ja se ohitettiin.
Seksikästä
client side cache -arkkitehtuurin kautta
- Jos tiedot voidaan valmistella etukäteen, tämä rakenne voi minimoida Redisiin kohdistuvat kyselyt ja liikenteen ja tarjota aina ajantasaiset tiedot.
- Tämän avulla voidaan luoda eräänlainen CQRS-rakenne, joka parantaa lukusuorituskykyä eksponentiaalisesti.

Kuinka paljon seksikkäämmäksi se muuttui?
Itse asiassa, koska tällaista rakennetta käytetään parhaillaan kentällä, tutkin lyhyesti kahden API:n viivettä. Pyydän ymmärrystä siitä, että voin kuvata sitä vain hyvin abstraktisti.
- Ensimmäinen API
- Ensimmäinen haku: keskimäärin 14.63ms
- Seuraavat haut: keskimäärin 2.82ms
- Keskimääräinen ero: 10.98ms
- Toinen API
- Ensimmäinen haku: keskimäärin 14.05ms
- Seuraavat haut: keskimäärin 1.60ms
- Keskimääräinen ero: 11.57ms
Viiveen paraneminen oli jopa 82%!
Odotettavissa on seuraavia parannuksia:
- Asiakkaan ja Redisin välisen verkkoliikenteen poistuminen ja liikenteen säästö
- Redisin suorittamien lukukomentojen määrän väheneminen
- Tämä parantaa myös kirjoitussuorituskykyä.
- Redisin protokollan jäsennyksen minimointi
- Redisin protokollan jäsentäminen ei ole ilmaista. Sen vähentäminen on suuri mahdollisuus.
Kaikessa on kuitenkin kompromisseja. Tämän saavuttamiseksi olemme uhranneet vähintään kaksi asiaa:
- Asiakaspuolen välimuistin hallintaelementtien toteutus, käyttö ja ylläpito ovat välttämättömiä.
- Tämä lisää asiakkaan suorittimen ja muistin käyttöä.
Johtopäätös
Henkilökohtaisesti tämä arkkitehtuurin komponentti oli mielestäni tyydyttävä, ja viive sekä API-palvelimen kuormitus olivat hyvin vähäisiä. Aion jatkossakin mahdollisuuksien mukaan rakentaa arkkitehtuurin tällä tavoin.