Go и Redis: go-redis, cache-aside и защита от stampede

В Go Redis чаще всего используют через клиент github.com/redis/go-redis/v9. Он даёт connection pool, context-aware API, pipeline, transactions, cluster/sentinel clients и нормальную интеграцию с типами Go.

Redis-код в backend должен быть таким же аккуратным, как код для PostgreSQL: timeout'ы, обработка ошибок, метрики, graceful shutdown и понятные границы ответственности. Кеш - это не место для хаотичных GET и SET из любого слоя приложения.


Подключение и graceful shutdown

Минимальный пример подключения:

go
package main import ( "context" "errors" "log" "os" "os/signal" "syscall" "time" "github.com/redis/go-redis/v9" ) func main() { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() rdb := redis.NewClient(&redis.Options{ Addr: "localhost:6379", Username: "", Password: "", DB: 0, PoolSize: 20, MinIdleConns: 5, DialTimeout: 2 * time.Second, ReadTimeout: 500 * time.Millisecond, WriteTimeout: 500 * time.Millisecond, }) defer func() { if err := rdb.Close(); err != nil { log.Printf("close redis: %v", err) } }() pingCtx, cancel := context.WithTimeout(ctx, time.Second) defer cancel() if err := rdb.Ping(pingCtx).Err(); err != nil { log.Fatalf("ping redis: %v", err) } log.Println("redis connected") <-ctx.Done() if !errors.Is(ctx.Err(), context.Canceled) { log.Printf("shutdown: %v", ctx.Err()) } _ = os.Stdout.Sync() }

Что важно:

  • context.Context должен приходить из request/job lifecycle;
  • у операций должны быть deadline или timeout;
  • Close() нужен, чтобы корректно закрыть соединения pool;
  • PoolSize не надо ставить "чем больше, тем лучше".

Connection pool

go-redis держит pool TCP-соединений. Одна Redis-команда занимает соединение на время round-trip'а. Если pool слишком маленький, goroutine ждут свободное соединение. Если слишком большой, Redis и сеть получают лишнюю нагрузку.

text
goroutines ─► go-redis pool ─► TCP connections ─► Redis │ ├─ idle conn ├─ busy conn └─ wait queue

Практический подход:

  • начинайте с умеренного PoolSize, например 10 * CPU или меньше для небольшого сервиса;
  • измеряйте pool stats: hits, misses, timeouts;
  • избегайте длинных блокирующих команд на общем клиенте;
  • для Pub/Sub и blocking operations часто нужен отдельный client.

Cache-aside repository

Cache-aside означает: приложение само решает, когда читать кеш, когда идти в БД и когда сохранять результат в кеш.

text
GetCourse(id) ├─ Redis GET course:{id} │ └─ hit -> return ├─ PostgreSQL SELECT ├─ Redis SET course:{id} EX 10m └─ return

Пример почти реального repository:

go
package cache import ( "context" "encoding/json" "errors" "fmt" "time" "github.com/redis/go-redis/v9" ) type Course struct { ID int64 `json:"id"` Slug string `json:"slug"` Title string `json:"title"` Description string `json:"description"` } type CourseStore interface { GetCourse(ctx context.Context, id int64) (Course, error) } type CachedCourseStore struct { redis *redis.Client next CourseStore ttl time.Duration } func NewCachedCourseStore(rdb *redis.Client, next CourseStore, ttl time.Duration) *CachedCourseStore { return &CachedCourseStore{redis: rdb, next: next, ttl: ttl} } func (s *CachedCourseStore) GetCourse(ctx context.Context, id int64) (Course, error) { key := fmt.Sprintf("course:%d", id) raw, err := s.redis.Get(ctx, key).Bytes() if err == nil { var course Course if err := json.Unmarshal(raw, &course); err == nil { return course, nil } // Битый кеш не должен ломать основной сценарий. delCtx, cancel := context.WithTimeout(ctx, 200*time.Millisecond) defer cancel() _ = s.redis.Del(delCtx, key).Err() } else if !errors.Is(err, redis.Nil) { // Redis недоступен: логируем в реальном коде и идём в основное хранилище. } course, err := s.next.GetCourse(ctx, id) if err != nil { return Course{}, err } payload, err := json.Marshal(course) if err != nil { return course, nil } if err := s.redis.Set(ctx, key, payload, s.ttl).Err(); err != nil { // Ошибка записи в кеш обычно не должна ломать read path. return course, nil } return course, nil }

Ключевая мысль: если Redis используется как кеш, ошибка Redis обычно не должна превращаться в 500, если основная БД доступна.

Invalidation и stale policy

Cache-aside ломается не на GET, а на обновлениях. Если source of truth изменился, старый ключ в Redis может жить до TTL и отдавать устаревший ответ.

Типичные варианты:

ПодходКогда подходитРиск
TTL onlyданные редко меняются, stale допустимпользователь видит старое до конца TTL
delete-on-writeпосле commit удалить cache keyrace: параллельный reader может снова записать старое значение
write-throughзапись сразу обновляет БД и кешсложнее error handling и rollback story
versioned keysновый формат или версия данных получает новый prefixстарые ключи нужно чистить по TTL/retention
stale-while-revalidateотдаём старое и обновляем в фоненужно явно показывать/ограничивать максимальную старость

Для RateDesk это должно быть частью cache contract: например, кеш конверсии инвалидируется при обновлении курса валют, а если курс старше freshness threshold, use case возвращает доменную ошибку вместо молчаливого stale результата.


TTL и jitter

TTL задаёт срок жизни ключа:

go
err := rdb.Set(ctx, "course:42", payload, 10*time.Minute).Err()

Но если много ключей создано одновременно с одинаковым TTL, они могут истечь одновременно. Это создаёт всплеск запросов в БД. Добавляют jitter:

go
func ttlWithJitter(base time.Duration) time.Duration { if base <= 0 { return base } maxJitter := int64(base / 10) if maxJitter <= 0 { return base } jitter := time.Duration(rand.Int63n(maxJitter)) return base + jitter }

Идея простая: TTL должен быть немного размазан. В production random source лучше инжектировать или централизовать, чтобы код было удобно тестировать и чтобы все ключи не получили одинаковый jitter после рестарта. Для jitter достаточно обычного pseudo-random source; для lock token ниже нужен криптографически стойкий random.


Negative caching

Если сущность не найдена в БД, можно ненадолго кешировать сам факт отсутствия.

text
GET course:404 -> miss SELECT ... WHERE id=404 -> not found SET course:404:missing 1 EX 30

Это защищает БД от повторяющихся запросов к несуществующим объектам. TTL должен быть коротким, иначе можно получить неприятный эффект: объект создали, а кеш всё ещё говорит "not found".

Пример:

go
var ErrNotFound = errors.New("not found") func (s *CachedCourseStore) GetCourseWithNegativeCache(ctx context.Context, id int64) (Course, error) { key := fmt.Sprintf("course:%d", id) missingKey := key + ":missing" exists, err := s.redis.Exists(ctx, missingKey).Result() if err == nil && exists == 1 { return Course{}, ErrNotFound } course, err := s.GetCourse(ctx, id) if errors.Is(err, ErrNotFound) { _ = s.redis.Set(ctx, missingKey, "1", 30*time.Second).Err() } return course, err }

Serialization

Для кеша часто используют JSON: он читаемый, простой и удобный. Но у него есть цена по CPU и размеру payload. Для горячих путей можно рассмотреть msgpack, protobuf или хранение отдельных полей в Hash.

ФорматПлюсыМинусы
JSONпросто читать, легко отлаживатьбольше размер, медленнее
Protobufкомпактно, строгий контрактнужен schema/codegen
Msgpackкомпактнее JSONхуже читается руками
Redis Hashчастичные обновлениясложнее версионировать объект

Версионирование payload тоже важно:

text
course:v1:42 course:v2:42

Если формат изменился, новый prefix позволяет не пытаться читать старые данные новым кодом.


Cache stampede

Cache stampede происходит, когда популярный ключ истёк, и много запросов одновременно пошли в БД.

text
100 requests -> Redis miss -> 100 SELECT в PostgreSQL -> 100 SET в Redis

Решения:

  • TTL jitter;
  • singleflight внутри одного процесса;
  • distributed mutex через Redis;
  • stale-while-revalidate;
  • background refresh для самых горячих ключей.

singleflight в Go

singleflight.Group объединяет одинаковые конкурентные вызовы внутри одного процесса.

go
package cache import ( "context" "fmt" "time" "golang.org/x/sync/singleflight" ) type StampedeProtectedStore struct { cache *CachedCourseStore group singleflight.Group } func (s *StampedeProtectedStore) GetCourse(ctx context.Context, id int64) (Course, error) { key := fmt.Sprintf("course:%d", id) value, err, _ := s.group.Do(key, func() (any, error) { loadCtx, cancel := context.WithTimeout(ctx, 2*time.Second) defer cancel() return s.cache.GetCourse(loadCtx, id) }) if err != nil { return Course{}, err } course, ok := value.(Course) if !ok { return Course{}, fmt.Errorf("unexpected cache value type %T", value) } return course, nil }

Ограничение: это работает только внутри одного процесса. Если у вас 20 replicas backend'а, каждая replica может сделать свой запрос в БД. Для глобальной защиты используют Redis lock или другой координатор, но с caveats.


Простая mutex-защита через Redis

Для cache rebuild можно использовать SET key token NX PX ttl. Важно хранить случайный token и удалять lock только если token совпал.

В примере crand - это alias для crypto/rand, а hex - encoding/hex.

go
func newLockToken() (string, error) { var b [16]byte if _, err := crand.Read(b[:]); err != nil { return "", err } return hex.EncodeToString(b[:]), nil } func acquireLock(ctx context.Context, rdb *redis.Client, key string, ttl time.Duration) (string, bool, error) { token, err := newLockToken() if err != nil { return "", false, err } ok, err := rdb.SetNX(ctx, key, token, ttl).Result() if err != nil || !ok { return "", ok, err } return token, true, nil } func releaseLock(ctx context.Context, rdb *redis.Client, key, token string) error { const script = ` if redis.call("GET", KEYS[1]) == ARGV[1] then return redis.call("DEL", KEYS[1]) end return 0` return rdb.Eval(ctx, script, []string{key}, token).Err() }

Для критичных distributed locks этого мало: нужны clock assumptions, fencing tokens, продуманная обработка пауз, failover и повторов. Но для защиты кеша от stampede такой lock часто достаточно хорош, потому что ошибка не нарушает деньги или инварианты домена.


Go client pitfalls на ревью

Что часто стоит проверять в MR с go-redis:

  • reusable adapter не создаёт context.Background() внутри операций вместо caller ctx;
  • Redis timeout короче пользовательского request timeout и не зависает до отмены HTTP-клиента;
  • redis.Nil обрабатывается как cache miss, а не как infrastructure outage;
  • метрики не используют полный Redis key как label;
  • Pub/Sub, blocking pop и Streams worker не делят один маленький pool с request path;
  • retries включены только там, где операция идемпотентна или повтор безопасен;
  • cache payload имеет версию, ограничение размера и понятный fallback при ошибке decode.

Вопросы на собеседовании

  • Что делает redis.Nil в go-redis и почему это не обычная ошибка сервера?
  • Почему операции с Redis должны принимать context.Context?
  • Что такое cache-aside?
  • Когда ошибка Redis должна приводить к ошибке HTTP-запроса, а когда нет?
  • Зачем TTL jitter?
  • Что такое negative caching и какой у него риск?
  • Чем singleflight отличается от distributed lock?
  • Почему lock нужно удалять через Lua-скрипт с проверкой token?

Практика

  1. Напишите CachedLessonRepository, который кеширует урок по slug на 5 минут и использует negative caching для отсутствующих slug на 30 секунд.
  2. Добавьте TTL jitter к записи кеша и объясните, как это влияет на нагрузку на PostgreSQL.
  3. Оберните загрузку урока через singleflight.Group, чтобы 50 одновременных miss'ов внутри одного процесса дали один запрос в БД.
  4. Добавьте метрики cache_hit, cache_miss, cache_error на уровне repository.

Интерактивная практика

Quiz+10 XP

Как обычно нужно трактовать redis.Nil при GET в go-redis?

  • Как падение Redis-сервера
  • Как cache miss или отсутствие ключа
  • Как ошибку JSON-декодирования
  • Как сигнал немедленно остановить процесс
Predict+15 XP

Что выведет этот код?

go
package main import "fmt" func cachePath(found bool, redisDown bool) string { if redisDown { return "load-db" } if found { return "hit" } return "load-db" } func main() { fmt.Println(cachePath(true, false)) fmt.Println(cachePath(false, false)) fmt.Println(cachePath(false, true)) }
Задача+20 XP

Реализуй CacheDecision: валидный cached payload возвращаем, битый payload удаляем и грузим заново, отсутствие payload грузим из БД.