Повышение отзывчивости с помощью клиентского кэша Redis

Что такое Redis?
Полагаю, что мало кто не знаком с Redis. Тем не менее, кратко напомню его особенности, которые можно обобщить следующим образом.
- Операции выполняются в однопоточном режиме, поэтому все операции обладают атомарностью.
- Данные хранятся и обрабатываются In-Memory, поэтому все операции быстрые.
- Redis может сохранять WAL в зависимости от настроек, что позволяет быстро создавать резервные копии актуального состояния и восстанавливать его.
- Поддерживаются различные типы данных, такие как Set, Hash, Bit, List, что обеспечивает высокую производительность разработки.
- Имеется большое сообщество, в котором можно получить опыт, информацию о проблемах и способы их решения, которыми можно делиться.
- Redis долгое время разрабатывается и эксплуатируется, что обеспечивает надежную стабильность.
Теперь к основной теме
Представьте себе?
Что, если кэш вашего сервиса соответствует следующим двум условиям?
- Часто запрашиваемые данные необходимо предоставлять пользователю в актуальном состоянии, но их обновления происходят нерегулярно, поэтому кэш приходится часто обновлять.
- Данные не обновляются, но к одним и тем же данным кэша требуется часто обращаться.
Первый случай может быть примером рейтинга популярных товаров интернет-магазина в реальном времени. Если хранить рейтинг популярных товаров интернет-магазина в виде sorted set, то чтение из Redis при каждом обращении пользователя к главной странице будет неэффективным. Второй случай касается данных о курсах валют: даже если курсы официально публикуются примерно каждые 10 минут, фактические запросы к ним возникают очень часто. Особенно часто кэш запрашивается для пар корейская вона–доллар, вона–иена и вона–юань. В таких случаях более эффективно, чтобы API-сервер хранил отдельный кэш локально и при изменении данных заново запрашивал и обновлял их из Redis.
Тогда как же реализовать такое поведение в архитектуре «база данных – Redis – API-сервер»??
Не подойдет ли Redis PubSub?
При использовании кэша подпишемся на канал, по которому можно получать информацию об обновлении!
- В таком случае необходимо создать логику, которая отправляет сообщение при обновлении.
- Дополнительные операции PubSub влияют на производительность.


А если Redis сам будет отслеживать изменения?
Что, если использовать Keyspace Notification и получать уведомления о командах для нужного ключа?
- Необходимо заранее сохранять и согласовывать ключи и команды, используемые для обновления, что создает определенные неудобства.
- Например, для одних ключей командой обновления является простой Set, для других — LPush, а также RPush, SAdd и SRem, и всё усложняется.
- Это значительно повышает вероятность ошибок коммуникации в процессе разработки и человеческих ошибок при написании кода.
Что, если использовать Keyevent Notification и получать уведомления по каждой команде?
- Требуется подписка на все команды, которые используются для обновления. Для поступающих ключей необходима соответствующая фильтрация.
- Например, среди всех ключей, поступающих через Del, для некоторых у данного клиента, вероятно, нет локального кэша.
- Это может привести к бесполезной трате ресурсов.
Поэтому нужны Invalidation Message!
Что такое Invalidation Message?
Invalidation Messages — это концепция, предоставляемая в рамках Server Assisted Client-Side Cache, добавленного в Redis 6.0. Invalidation Message передается по следующему процессу.
- Предположим, что ClientB уже однажды прочитал key.
- ClientA устанавливает новое значение для этого key.
- Redis обнаруживает изменение и отправляет ClientB Invalidation Message, сообщая ему, что кэш нужно очистить.
- ClientB получает это сообщение и предпринимает соответствующие действия.

Как это использовать
Базовая структура работы
Клиент, подключенный к Redis, выполняет CLIENT TRACKING ON REDIRECT <client-id>, чтобы получать invalidation message. Клиент, которому необходимо получать сообщения, подписывается на SUBSCRIBE __redis__:invalidate для получения invalidation message.
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"
Реализация! Реализация! Реализация!
Redigo + Ristretto
Если объяснять только так, то непонятно, как использовать это в реальном коде. Поэтому для начала попробуем собрать простую конструкцию с redigo и ristretto.
Сначала установим две зависимости.
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, // количество ключей для отслеживания частоты (10M).
22 MaxCost: 1 << 30, // максимальная стоимость кэша (1GB).
23 BufferItems: 64, // количество ключей на один буфер Get.
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}
Сначала просто создаем RedisClient, который содержит ristretto и 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("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}
Код получился довольно сложным.
- Для Tracking открывается еще одно соединение. Это сделано с учетом того, что PubSub может помешать другим операциям.
- Определяется ID добавленного соединения, и для соединения, которое будет запрашивать данные, Tracking перенаправляется на это соединение.
- Затем выполняется подписка на invalidation message.
- Обработка подписки получилась несколько сложной. Поскольку redigo не умеет парсить invalidation message, ответ необходимо получить и обработать до парсинга.
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 сначала проверяет ristretto, а если данных там нет, получает их из 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("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}
Код для тестирования приведен выше. Если его запустить, можно увидеть, что значение обновляется каждый раз, когда данные изменяются в Redis.
Однако этот подход слишком сложен. Кроме того, для масштабирования на кластер необходимо активировать Tracking на всех мастерах и репликах.
Rueidis
Раз уж мы пишем на Go, у нас есть самый современный и развитый клиент rueidis. Напишем код, использующий server assisted client side cache в кластерной среде Redis с помощью rueidis.
Сначала установим зависимость.
github.com/redis/rueidis
Затем напишем код для получения данных из Redis.
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}
Для использования client side cache в rueidis достаточно просто вызвать DoCache. Затем, например, задав время хранения в локальном кэше, при повторном вызове DoCache данные будут получены из локального кэша. Разумеется, сообщения об инвалидации также обрабатываются корректно.
Почему не redis-go?
К сожалению, redis-go не поддерживает server assisted client side cache через официальный API. Более того, при создании PubSub он открывает новое соединение, но не предоставляет API для прямого доступа к этому соединению, поэтому client id узнать невозможно. Поэтому мы решили, что построить такую конфигурацию с redis-go невозможно, и пропустили его.
Выглядит круто
Через структуру client side cache
- Если данные можно подготовить заранее, с помощью этой структуры можно минимизировать запросы и трафик к Redis, одновременно предоставляя всегда актуальные данные.
- Таким образом можно построить своего рода структуру CQRS и значительно повысить производительность чтения.

Насколько стало круче?
Поскольку эта структура уже используется на практике, мы измерили примерную задержку для двух API. Прошу прощения, что могу привести лишь очень абстрактные данные.
- Первый API
- При первом запросе: в среднем 14.63 мс
- При последующих запросах: в среднем 2.82 мс
- Средняя разница: 10.98 мс
- Второй API
- При первом запросе: в среднем 14.05 мс
- При последующих запросах: в среднем 1.60 мс
- Средняя разница: 11.57 мс
Максимальное дополнительное улучшение задержки составило около 82%!
Предполагаю, что были достигнуты следующие улучшения.
- Исключение сетевого взаимодействия между клиентом и Redis и экономия трафика
- Уменьшение количества команд чтения, которые должен выполнять сам Redis
- Это также повышает производительность записи.
- Минимизация парсинга протокола Redis
- Парсинг протокола Redis тоже имеет ненулевую стоимость, поэтому его сокращение — большая возможность.
Однако все является компромиссом. Для этого мы пожертвовали как минимум двумя вещами.
- Необходимость реализации, эксплуатации и сопровождения компонентов управления client side cache
- Связанный с этим рост использования CPU и памяти на стороне клиента
Заключение
Лично я считаю, что это был удовлетворительный архитектурный компонент: задержка и нагрузка на API-сервер были очень низкими. Я надеюсь и дальше, по возможности, строить архитектуру с использованием такой структуры.