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для юнит-тестов, контейнер для реализации.
Что почитать дальше
- Что такое Redis — однопоточная модель, память, персистентность.
- Структуры данных Redis — строки, хеши, множества и когда какая нужна.
- Паттерны кэширования — cache-aside, write-through, инвалидация и лавины на уровне архитектуры.
- Redis не только кэш — очереди, потоки, блокировки и счётчики.
- Redis в проде — политика вытеснения, Sentinel и Cluster, мониторинг.
- Профилирование и утечки в Go — как найти горутины, зависшие на вызове без таймаута.