← назад к разделу

Redis в Go-сервисе появляется по трём поводам: кэш перед PostgreSQL, короткоживущее состояние (сессии, лимиты, блокировки) и очереди или потоки между процессами. Клиент для всех трёх один, github.com/redis/go-redis/v9, и в отличие от фреймворков с аннотациями он не прячет ни одной команды: кэш, инвалидация, таймауты и поведение при падении Redis это код, который пишете вы. Это и есть предмет статьи: не команды Redis (они разобраны в соседних статьях раздела), а то, как их правильно обернуть в сервисе.

Обязательно

Клиент и таймауты

import "github.com/redis/go-redis/v9"

func newClient() *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:                  "redis:6379",
        Password:              "",
        DB:                    0,
        DialTimeout:           2 * time.Second,
        ReadTimeout:           300 * time.Millisecond,
        WriteTimeout:          300 * time.Millisecond,
        PoolSize:              20,
        MinIdleConns:          2,
        ConnMaxIdleTime:       5 * time.Minute,
        MaxRetries:            1,
        ContextTimeoutEnabled: true,
    })
}

Один *redis.Client на процесс: внутри пул соединений, он безопасен для горутин. Значения выше отличаются от умолчаний не случайно.

  • Таймауты чтения и записи по умолчанию 5 секунд. Для кэша это приговор: Redis завис, и каждый запрос к сервису ждёт пять секунд, прежде чем пойти в базу. Сотни миллисекунд это уже много для хранилища, которое отвечает за доли миллисекунды; таймаут кэша должен быть меньше, чем ожидание запроса в базу.
  • ContextTimeoutEnabled по умолчанию выключен. Без него дедлайн из ctx не ограничивает чтение из сокета, только таймауты клиента. С ним дедлайн обработчика становится и дедлайном команды, как у любого другого вызова в Go.
  • Повторы по умолчанию три с паузой до секунды. На пути запроса пользователя повтор это удвоенная задержка при проблеме, поэтому один или ноль; три оставляют фоновым задачам.
  • Размер пула по умолчанию 10 × GOMAXPROCS, на машине с 16 ядрами это 160 соединений от каждого экземпляра сервиса. Redis однопоточный, ему от этого не легче; 10-20 соединений хватает почти всем. PoolTimeout (ожидание свободного соединения, по умолчанию таймаут чтения плюс секунда) ограничивает, сколько запрос простоит в очереди за соединением.

Клиент с версии 9 говорит по RESP3, а redis.ParseURL("redis://:secret@redis:6379/0?read_timeout=300ms") собирает те же Options из строки подключения, что удобно для конфигурации через окружение. Для Sentinel и Cluster есть redis.NewUniversalClient: один конструктор, который по форме UniversalOptions (есть MasterName, несколько адресов или один) выбирает нужный клиент, а код сервиса зависит только от интерфейса redis.UniversalClient.

Что хранить и как сериализовать

Redis хранит байты. Структуру сервиса превращают в них явно: encoding/json для читаемости и совместимости, msgpack или protobuf, когда счёт идёт на мегабайты в секунду. Одно значение целиком удобнее хеша, пока его читают и пишут целиком; хеш (HSET/HGETALL) выигрывает, когда нужно обновлять одно поле или читать часть. Ключ строят по схеме сущность:идентификатор, и схему держат в одном месте кода, а не размазывают по вызовам.

type Product struct {
    ID    int64  `json:"id"`
    Name  string `json:"name"`
    Price int64  `json:"price"`
}

Деньги в копейках целым числом, время в Unix-секундах или RFC 3339: JSON от Go и JSON от сервиса на другом языке должны читать друг друга, если кэш общий.

Cache-aside: чтение сквозь кэш

Самый частый узор: посмотреть в Redis, при промахе сходить в базу и положить результат с TTL.

import "golang.org/x/sync/singleflight"

type ProductCache struct {
    rdb  *redis.Client
    db   func(ctx context.Context, id int64) (Product, error)
    ttl  time.Duration
    once singleflight.Group
}

func (c *ProductCache) Get(ctx context.Context, id int64) (Product, error) {
    key := fmt.Sprintf("product:%d", id)
    raw, err := c.rdb.Get(ctx, key).Bytes()
    switch {
    case err == nil:
        var p Product
        if err := json.Unmarshal(raw, &p); err == nil {
            return p, nil
        }
    case !errors.Is(err, redis.Nil):
        slog.Warn("redis get", "key", key, "err", err)
    }
    v, err, _ := c.once.Do(key, func() (any, error) {
        p, err := c.db(ctx, id)
        if err != nil {
            return Product{}, err
        }
        if body, err := json.Marshal(p); err == nil {
            jitter := time.Duration(id%7) * time.Second
            if err := c.rdb.Set(ctx, key, body, c.ttl+jitter).Err(); err != nil {
                slog.Warn("redis set", "key", key, "err", err)
            }
        }
        return p, nil
    })
    if err != nil {
        return Product{}, err
    }
    return v.(Product), nil
}

Здесь четыре решения, и каждое закрывает отдельную аварию.

  • redis.Nil это промах, а не ошибка. Get отсутствующего ключа возвращает именно его; любая другая ошибка (таймаут, обрыв) логируется, и запрос идёт в базу так же, как при промахе. Кэш, который при собственной ошибке роняет запрос, хуже отсутствия кэша.
  • singleflight схлопывает одновременные промахи. Когда ключ истёк, а товар популярный, сто горутин одновременно идут в базу за одним и тем же. С singleflight.Group в базу идёт одна, остальные получают её результат. Это защита от лавины на уровне экземпляра; между экземплярами её дополняет короткий TTL и база, которая выдерживает пару десятков одинаковых запросов.
  • TTL с разбросом. Если тысяча ключей положена в одну секунду с одинаковым TTL, через час они истекут в одну секунду. Небольшой разброс размазывает этот всплеск.
  • Испорченное значение равно промаху. Не разобрался JSON (сменилась структура после выката) значит перечитать из базы и перезаписать, а не вернуть 500.

Отсутствие товара тоже стоит кэшировать: короткий ключ-метка на 30-60 секунд, иначе запросы по несуществующим идентификаторам (боты, старые ссылки) всегда долетают до базы.

Инвалидация: удалять, а не обновлять

При изменении товара кэш не обновляют новым значением, а удаляют ключ: следующий читатель положит свежее. Обновление на запись проигрывает в гонке «два обработчика изменили товар, в кэш последним записал тот, кто читал базу раньше». Удаление делают после коммита транзакции в базе, а не до: иначе параллельный читатель успеет положить в кэш старое значение между удалением и коммитом.

func (c *ProductCache) Invalidate(ctx context.Context, id int64) error {
    return c.rdb.Del(ctx, fmt.Sprintf("product:%d", id)).Err()
}

Гонка остаётся и здесь (читатель прочитал из базы старое, в это время прошли коммит и удаление, читатель записал старое в кэш), её закрывает только короткий TTL: на пять минут старая цена допустима, на сутки нет. Для кэша, где это недопустимо, выбирают write-through с версией значения или не кэшируют вовсе. Если ключей, связанных с сущностью, несколько (товар, товар в списке категории, товар в поиске), удаляют их все одним Del с несколькими аргументами или ведут набор ключей на сущность.

Удалять по маске KEYS product:* в проде нельзя: команда блокирует Redis на время обхода всех ключей. Для редких массовых чисток есть SCAN постранично и UNLINK, который освобождает память в фоне.

Конвейеры и транзакции

Каждая команда это сетевой круг. Десять GET подряд это десять кругов, MGET или конвейер это один:

func mgetProducts(ctx context.Context, rdb *redis.Client, ids []int64) (map[int64]Product, error) {
    keys := make([]string, len(ids))
    for i, id := range ids {
        keys[i] = fmt.Sprintf("product:%d", id)
    }
    vals, err := rdb.MGet(ctx, keys...).Result()
    if err != nil {
        return nil, err
    }
    out := make(map[int64]Product, len(ids))
    for i, v := range vals {
        s, ok := v.(string)
        if !ok {
            continue
        }
        var p Product
        if json.Unmarshal([]byte(s), &p) == nil {
            out[ids[i]] = p
        }
    }
    return out, nil
}

rdb.Pipeline() отправляет разные команды одной пачкой и возвращает результаты по отдельности; rdb.TxPipeline() оборачивает их в MULTI/EXEC, и они выполняются без вклинивания чужих команд. Это не транзакция в смысле отката: если третья команда упала, первые две уже применились. Так считают лимиты:

func rateLimit(ctx context.Context, rdb *redis.Client, user string, limit int64) (bool, error) {
    key := "rl:" + user + ":" + time.Now().Format("200601021504")
    pipe := rdb.TxPipeline()
    incr := pipe.Incr(ctx, key)
    pipe.Expire(ctx, key, time.Minute)
    if _, err := pipe.Exec(ctx); err != nil {
        return false, err
    }
    return incr.Val() <= limit, nil
}

Результат команды в конвейере доступен только после Exec: incr.Val() до него вернёт ноль. Окно здесь фиксированное по минутам; скользящее окно делают сортированным множеством с отметками времени или готовой библиотекой (go-redis/redis_rate).

Блокировки: владелец и Lua

Распределённая блокировка на одном Redis это SET key owner NX PX ttl, а освобождение обязано проверять владельца, иначе процесс, который провисел дольше TTL, снимет чужую блокировку. Проверка и удаление должны быть одной атомарной операцией, то есть скриптом:

var release = redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
  return redis.call("DEL", KEYS[1])
end
return 0`)

func withLock(ctx context.Context, rdb *redis.Client, name, owner string,
    ttl time.Duration, fn func() error) error {
    ok, err := rdb.SetNX(ctx, "lock:"+name, owner, ttl).Result()
    if err != nil {
        return err
    }
    if !ok {
        return errors.New("busy")
    }
    defer release.Run(ctx, rdb, []string{"lock:" + name}, owner)
    return fn()
}

owner это случайная строка на захват (UUID), ttl больше ожидаемого времени работы с запасом, а fn обязана уметь завершиться раньше TTL или продлевать его. Такая блокировка защищает от двойного запуска задачи по расписанию на нескольких экземплярах; для денег она не годится: при переключении мастера Redis блокировка может пропасть, а значит, идемпотентность операции обеспечивают в базе, а не в Redis. Алгоритм Redlock на нескольких независимых узлах реализует go-redsync/redsync, и спор о его гарантиях стоит прочитать до того, как на него опираться.

Сессии

Сессия в Redis это значение по ключу sess:<id> с TTL, а идентификатор живёт в cookie. Скользящее продление делает одна команда:

type Session struct {
    UserID string
    Roles  []string
}

func saveSession(ctx context.Context, rdb *redis.Client, sid string, s Session) error {
    body, err := json.Marshal(s)
    if err != nil {
        return err
    }
    return rdb.Set(ctx, "sess:"+sid, body, 30*time.Minute).Err()
}

func loadSession(ctx context.Context, rdb *redis.Client, sid string) (Session, bool, error) {
    raw, err := rdb.GetEx(ctx, "sess:"+sid, 30*time.Minute).Bytes()
    if errors.Is(err, redis.Nil) {
        return Session{}, false, nil
    }
    if err != nil {
        return Session{}, false, err
    }
    var s Session
    return s, true, json.Unmarshal(raw, &s)
}

GETEX читает и продлевает TTL за один круг. Идентификатор сессии генерируют криптографически (crypto/rand, 32 байта в base64), в cookie ставят HttpOnly, Secure и SameSite. Отзыв сессии это DEL, и в этом главное преимущество перед JWT без состояния: выход и блокировка пользователя действуют мгновенно. Для сервисов с JWT Redis остаётся местом для чёрного списка отозванных токенов с TTL до их истечения.

Когда Redis лежит

Поведение при недоступности выбирают для каждого применения отдельно, и это решение записывают в код явно.

  • Кэш отказывает открыто. Ошибка Redis это промах: лог, метрика, запрос в базу. Чтобы при зависшем Redis не платить таймаут на каждый запрос, перед кэшем ставят размыкатель (sony/gobreaker или свой счётчик ошибок): после серии ошибок кэш отключается на 10-30 секунд, и запросы идут в базу сразу. База в этот момент получает весь трафик, поэтому её запас прочности на холодный кэш проверяют заранее.
  • Лимиты и блокировки отказывают по решению продукта. Пропустить запрос без проверки лимита или отказать всем? Для публичного API обычно пропускают и пишут метрику, для дорогих операций отказывают.
  • Сессии отказывают закрыто. Без Redis пользователь не аутентифицирован, и это 503 с коротким дедлайном, а не бесконечное ожидание. Поэтому Redis под сессии держат с репликой и Sentinel или Cluster.

Проверка здоровья отражает это: кэш это «деградация», сессии это «не готов». Короткие таймауты и MaxRetries: 1 из первого раздела здесь и работают: сервис замечает проблему за сотни миллисекунд, а не за секунды.

Sentinel и Cluster

UniversalClient с MasterName и адресами Sentinel сам находит мастера и переподключается после переключения; с несколькими адресами без MasterName работает с Cluster и маршрутизирует команды по слотам. Два следствия для кода. Во-первых, команды над несколькими ключами (MGET, MULTI, Lua с двумя KEYS) в кластере работают только внутри одного слота, поэтому связанные ключи получают общий хеш-тег: cart:{42}:items и cart:{42}:total лягут вместе. Во-вторых, чтение с реплик (ReadOnly, RouteByLatency) ускоряет кэш, но может отдать значение, которое мастер уже удалил, и для сессий и блокировок его не включают.

Наблюдаемость

redisotel.InstrumentTracing(rdb) и redisotel.InstrumentMetrics(rdb) из github.com/redis/go-redis/extra/redisotel/v9 добавляют каждой команде отрезок трассировки и метрики через OpenTelemetry. rdb.PoolStats() показывает состояние пула: Hits и Misses (взяли соединение из пула или открывали новое), Timeouts (не дождались соединения, пул мал или Redis медленный), TotalConns и IdleConns. Для самого кэша считают свои метрики: попадания и промахи по имени кэша и время ответа; без доли попаданий нельзя сказать, нужен ли кэш вообще.

Тесты

Логику кэша тестируют на miniredis (github.com/alicebob/miniredis/v2): это Redis на Go внутри теста, стартует за миллисекунды, умеет FastForward для проверки TTL. Реализацию против настоящего сервера проверяет контейнер:

import tcredis "github.com/testcontainers/testcontainers-go/modules/redis"

c, err := tcredis.Run(ctx, "redis:7")
if err != nil {
    t.Fatal(err)
}
t.Cleanup(func() { c.Terminate(ctx) })
uri, _ := c.ConnectionString(ctx)
opt, _ := redis.ParseURL(uri)
rdb := redis.NewClient(opt)

Что проверяют: промах идёт в базу один раз при ста одновременных вызовах (singleflight), ошибка Redis не ломает чтение (закрыть контейнер посреди теста или подменить адрес), инвалидация после обновления, лимит отсекает ровно на limit + 1, чужой владелец не снимает блокировку.

Дополнительно: при первом чтении можно пропустить

Глубже: горячие ключи и большие значениярасширенное

Один ключ, который читают десятки тысяч раз в секунду (главная страница, конфигурация), упирается в одно ядро Redis и в одну сетевую карту узла кластера. Лечат его локальным кэшем в памяти процесса на секунды перед Redis: singleflight плюс sync.Map с временем жизни или библиотека вроде ristretto. Большое значение (мегабайты JSON) тормозит всех: Redis однопоточный, и пока он отдаёт мегабайт одному клиенту, остальные ждут. Правило: значения в килобайтах, большее режут на части, сжимают или не кладут в кэш вообще. Удаление большого ключа тоже блокирует, поэтому UNLINK вместо DEL.

Коротко

  • Один клиент на процесс; таймауты в сотни миллисекунд вместо пяти секунд, ContextTimeoutEnabled: true, MaxRetries не больше одного на пути запроса, пул 10-20.
  • redis.Nil это промах; любая другая ошибка кэша тоже промах с логом, а не 500.
  • Cache-aside с singleflight, TTL с разбросом и кэшем отсутствия закрывает лавину промахов.
  • Инвалидация удалением после коммита; гонку чтения закрывает короткий TTL; KEYS в проде запрещён, вместо него SCAN и UNLINK.
  • MGET и конвейеры вместо циклов команд; TxPipeline это атомарная пачка, а не транзакция с откатом.
  • Блокировка: SET NX PX с владельцем и освобождение Lua-скриптом; деньги защищает идемпотентность в базе, а не Redis.
  • Сессии по ключу с TTL и GETEX, идентификатор из crypto/rand, отзыв через DEL.
  • При падении Redis кэш отказывает открыто и за размыкателем, сессии закрыто с коротким дедлайном.
  • В кластере связанные ключи с общим хеш-тегом {id}, чтение с реплик только для кэша.
  • redisotel и PoolStats для наблюдения, miniredis для юнит-тестов, контейнер для реализации.

Что почитать дальше