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
Минимальный пример подключения:
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 и сеть получают лишнюю нагрузку.
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 означает: приложение само решает, когда читать кеш, когда идти в БД и когда сохранять результат в кеш.
GetCourse(id)
├─ Redis GET course:{id}
│ └─ hit -> return
├─ PostgreSQL SELECT
├─ Redis SET course:{id} EX 10m
└─ return
Пример почти реального repository:
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 key | race: параллельный 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 задаёт срок жизни ключа:
err := rdb.Set(ctx, "course:42", payload, 10*time.Minute).Err()
Но если много ключей создано одновременно с одинаковым TTL, они могут истечь одновременно. Это создаёт всплеск запросов в БД. Добавляют jitter:
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
Если сущность не найдена в БД, можно ненадолго кешировать сам факт отсутствия.
GET course:404 -> miss
SELECT ... WHERE id=404 -> not found
SET course:404:missing 1 EX 30
Это защищает БД от повторяющихся запросов к несуществующим объектам. TTL должен быть коротким, иначе можно получить неприятный эффект: объект создали, а кеш всё ещё говорит "not found".
Пример:
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 тоже важно:
course:v1:42
course:v2:42
Если формат изменился, новый prefix позволяет не пытаться читать старые данные новым кодом.
Cache stampede
Cache stampede происходит, когда популярный ключ истёк, и много запросов одновременно пошли в БД.
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 объединяет одинаковые конкурентные вызовы внутри одного процесса.
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.
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()внутри операций вместо callerctx; - 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?
Практика
- Напишите
CachedLessonRepository, который кеширует урок по slug на 5 минут и использует negative caching для отсутствующих slug на 30 секунд. - Добавьте TTL jitter к записи кеша и объясните, как это влияет на нагрузку на PostgreSQL.
- Оберните загрузку урока через
singleflight.Group, чтобы 50 одновременных miss'ов внутри одного процесса дали один запрос в БД. - Добавьте метрики
cache_hit,cache_miss,cache_errorна уровне repository.
Интерактивная практика
Как обычно нужно трактовать redis.Nil при GET в go-redis?
Что выведет этот код?
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))
}
Реализуй CacheDecision: валидный cached payload возвращаем, битый payload удаляем и грузим заново, отсутствие payload грузим из БД.