Перейти к содержимому

Redis в Go: кеши, локи, очереди

Зачем знать: Redis — швейцарский нож backend-инфраструктуры: кеш, session store, rate limiter, distributed lock, pub/sub, lightweight queue (Streams). Middle 1 Go-разработчик в 2026 должен уверенно пользоваться redis/go-redis/v9, понимать pipelining, transactions, cache stampede и почему Redlock не безопасен в общем случае.

  1. Базовая концепция
  2. Как в Go (с примерами)
  3. Gotchas
  4. Best practices в production
  5. Вопросы на собесе
  6. Practice
  7. Источники

Redis (REmote DIctionary Server) — in-memory key-value store с persistence. Однопоточный (для commands), но event loop позволяет выдерживать 100K+ ops/sec на одной машине. В 2026 актуальная версия — Redis 7.4+ (LTS 7.2).

  • Cache — самое частое, ускорение БД-запросов.
  • Session store — JWT не идеален для logout, Redis session с TTL.
  • Rate limiter — atomic INCR + EXPIRE.
  • Distributed lock — SET NX EX.
  • Counter / leaderboard — Sorted sets (ZADD, ZRANGE).
  • Pub/Sub — fire-and-forget сообщения.
  • Streams — persistent журнал событий (5.0+), альтернатива Kafka для среднего масштаба.
  • Geo-search — GEOADD, GEOSEARCH.
  • Bitmap, HyperLogLog — для analytics.
ТипКомандыUse case
StringGET, SET, INCR, APPENDсчётчики, кеш
HashHSET, HGET, HGETALLобъекты (user:123)
ListLPUSH, RPUSH, LPOP, BLPOPочереди, истории
SetSADD, SISMEMBER, SUNIONуникальные, теги
Sorted SetZADD, ZRANGE, ZRANGEBYSCOREleaderboards, time-series
StreamXADD, XREAD, XREADGROUPappend-only лог
BitmapSETBIT, GETBIT, BITCOUNTfeature flags по user_id
HyperLogLogPFADD, PFCOUNTunique counts, ~1% error
GeoGEOADD, GEOSEARCHгео-поиск
JSON (модуль)JSON.SET, JSON.GETstructured docs
  • RDB — snapshot (fork + dump). Дефолт.
  • AOF — append-only file (каждая команда). Безопаснее, но больше IO.
  • Hybrid — RDB snapshot + AOF с last snapshot.

В большинстве cache-сценариев persistence отключают (кеш можно перепрогреть).

  • Single instance — для dev, small prod.
  • Replication (master + N replicas) — масштабирование чтений, HA через Redis Sentinel.
  • Cluster — sharding (16384 slots), data partitioning, HA встроенно.
  • Redis Enterprise / KeyDB / Dragonfly — коммерческие/альтернативные.

Когда maxmemory достигнут, Redis удаляет согласно maxmemory-policy:

  • noeviction — ошибка на write.
  • allkeys-lru — выкидывает least recently used.
  • allkeys-lfu — least frequently used.
  • volatile-lru/lfu — только ключи с TTL.
  • volatile-ttl — ключи с близким expire.
  • volatile-random / allkeys-random.

Для кеша обычно allkeys-lfu (better hit rate).


Окно терминала
go get github.com/redis/go-redis/v9

В 2026 актуальна v9. Старый go-redis/redis/v8 deprecated (RESP2 only).

package main
import (
"context"
"github.com/redis/go-redis/v9"
)
func main() {
rdb := redis.NewClient(&redis.Options{
Addr: "redis:6379",
Password: "",
DB: 0,
PoolSize: 50, // default = 10 * GOMAXPROCS
MinIdleConns: 10,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
DialTimeout: 2 * time.Second,
})
ctx := context.Background()
if err := rdb.Ping(ctx).Err(); err != nil {
panic(err)
}
}
// SET / GET
err := rdb.Set(ctx, "user:1", "alice", 10*time.Minute).Err()
val, err := rdb.Get(ctx, "user:1").Result() // "alice"
if err == redis.Nil {
// ключа нет
}
// INCR (atomic)
n, _ := rdb.Incr(ctx, "counter").Result()
// TTL
ttl, _ := rdb.TTL(ctx, "user:1").Result() // 9m59s
// DEL
deleted, _ := rdb.Del(ctx, "user:1").Result() // 1
// EXPIRE
rdb.Expire(ctx, "user:1", 5*time.Minute)
rdb.HSet(ctx, "user:1", map[string]any{
"name": "alice",
"age": 30,
})
age, _ := rdb.HGet(ctx, "user:1", "age").Int()
all, _ := rdb.HGetAll(ctx, "user:1").Result() // map[string]string
rdb.HIncrBy(ctx, "user:1", "balance", 100)
// Producer
rdb.RPush(ctx, "queue", "task1", "task2")
// Consumer (блокирующий)
res, err := rdb.BLPop(ctx, 5*time.Second, "queue").Result()
// res[0] = "queue", res[1] = "task1"
rdb.SAdd(ctx, "tags:post:1", "go", "redis", "cache")
isMember, _ := rdb.SIsMember(ctx, "tags:post:1", "go").Result()
all, _ := rdb.SMembers(ctx, "tags:post:1").Result()
rdb.ZAdd(ctx, "leaderboard",
redis.Z{Score: 100, Member: "alice"},
redis.Z{Score: 95, Member: "bob"},
)
// Top 10
top, _ := rdb.ZRevRangeWithScores(ctx, "leaderboard", 0, 9).Result()
pipe := rdb.Pipeline()
incr := pipe.Incr(ctx, "counter")
expire := pipe.Expire(ctx, "counter", time.Hour)
_, err := pipe.Exec(ctx)
if err == nil {
val, _ := incr.Result()
_ = val
_ = expire.Val()
}

Pipelining = одна сетевая поездка для N команд. Не атомарность — другие клиенты могут вмешаться между командами.

err := rdb.Watch(ctx, func(tx *redis.Tx) error {
n, err := tx.Get(ctx, "counter").Int()
if err != nil && err != redis.Nil {
return err
}
n++
_, err = tx.TxPipelined(ctx, func(pipe redis.Pipeliner) error {
pipe.Set(ctx, "counter", n, 0)
return nil
})
return err
}, "counter") // watched keys

WATCH + MULTI + EXEC = optimistic locking. Если другой клиент изменил counter между WATCH и EXEC — txf возвращает redis.TxFailedErr (retry).

// Subscriber
sub := rdb.Subscribe(ctx, "events")
defer sub.Close()
ch := sub.Channel()
for msg := range ch {
fmt.Println(msg.Channel, msg.Payload)
}
// Publisher
rdb.Publish(ctx, "events", "hello")

Pub/Sub fire-and-forget — если подписчика нет, сообщение теряется. Для надёжной доставки — Streams.

// Producer
id, _ := rdb.XAdd(ctx, &redis.XAddArgs{
Stream: "events",
MaxLen: 10000, // обрезка
Values: map[string]any{"user_id": "u-123", "action": "buy"},
}).Result()
// Consumer Group
rdb.XGroupCreateMkStream(ctx, "events", "workers", "$")
// Consumer
for {
res, err := rdb.XReadGroup(ctx, &redis.XReadGroupArgs{
Group: "workers",
Consumer: "worker-1",
Streams: []string{"events", ">"}, // ">" = только новые
Count: 10,
Block: 5 * time.Second,
}).Result()
if err != nil {
continue
}
for _, stream := range res {
for _, msg := range stream.Messages {
process(msg.Values)
rdb.XAck(ctx, "events", "workers", msg.ID)
}
}
}

Если consumer крашится не делая XACK, при XPENDING/XCLAIM сообщение можно забрать другим consumer.

type UserCache struct {
rdb *redis.Client
db *Repository
ttl time.Duration
}
func (c *UserCache) Get(ctx context.Context, id string) (*User, error) {
key := "user:" + id
// Try cache
data, err := c.rdb.Get(ctx, key).Bytes()
if err == nil {
var u User
if err := json.Unmarshal(data, &u); err == nil {
return &u, nil
}
}
// Cache miss → DB
u, err := c.db.GetUser(ctx, id)
if err != nil {
return nil, err
}
// Write back to cache (best-effort)
if b, err := json.Marshal(u); err == nil {
c.rdb.Set(ctx, key, b, c.ttl)
}
return u, nil
}
func (c *UserCache) Invalidate(ctx context.Context, id string) error {
return c.rdb.Del(ctx, "user:"+id).Err()
}

Если популярный ключ истёк, все запросы одновременно ходят в БД. Решение — singleflight:

import "golang.org/x/sync/singleflight"
var sfg singleflight.Group
func (c *UserCache) Get(ctx context.Context, id string) (*User, error) {
if u, err := c.getFromCache(ctx, id); err == nil {
return u, nil
}
res, err, _ := sfg.Do(id, func() (interface{}, error) {
u, err := c.db.GetUser(ctx, id)
if err != nil {
return nil, err
}
c.setCache(ctx, id, u)
return u, nil
})
if err != nil {
return nil, err
}
return res.(*User), nil
}

Все одновременные запросы за одним ID сольются в один поход в БД.

func acquireLock(ctx context.Context, rdb *redis.Client, key, val string, ttl time.Duration) (bool, error) {
return rdb.SetNX(ctx, "lock:"+key, val, ttl).Result()
}
func releaseLock(ctx context.Context, rdb *redis.Client, key, val string) error {
// Lua-script для атомарной проверки + удаления
script := redis.NewScript(`
if redis.call("GET", KEYS[1]) == ARGV[1] then
return redis.call("DEL", KEYS[1])
else
return 0
end
`)
_, err := script.Run(ctx, rdb, []string{"lock:" + key}, val).Result()
return err
}

val обычно UUID — чтобы не удалить чужой lock после своего timeout.

import "github.com/go-redsync/redsync/v4"
pool := goredis.NewPool(rdb)
rs := redsync.New(pool)
mutex := rs.NewMutex("my-resource", redsync.WithExpiry(10*time.Second))
if err := mutex.LockContext(ctx); err != nil {
return err
}
defer mutex.UnlockContext(ctx)

Внимание: Redlock алгоритм (Antirez) критикован Martin Kleppmann — в edge cases (clock skew, network partitions) даёт два владельца одновременно. Не использовать для критических transactions (деньги, инвентарь).

-- rate_limit.lua
local key = KEYS[1]
local limit = tonumber(ARGV[1])
local window = tonumber(ARGV[2])
local current = redis.call("INCR", key)
if current == 1 then
redis.call("EXPIRE", key, window)
end
if current > limit then
return 0
end
return 1
script := redis.NewScript(rateLimitLua)
func allow(ctx context.Context, rdb *redis.Client, userID string) bool {
key := "rate:" + userID
res, err := script.Run(ctx, rdb, []string{key}, 100, 60).Int()
return err == nil && res == 1
}

100 запросов за 60 секунд. Lua атомарен (Redis однопоточный для commands).

rdb := redis.NewFailoverClient(&redis.FailoverOptions{
MasterName: "mymaster",
SentinelAddrs: []string{"sentinel-1:26379", "sentinel-2:26379"},
})

Sentinel мониторит master, при отказе делает failover.

rdb := redis.NewClusterClient(&redis.ClusterOptions{
Addrs: []string{"node-1:6379", "node-2:6379", "node-3:6379"},
})

Cluster: 16384 slots распределены между nodes. Ключи {tag}suffix попадают в один slot (для multi-key операций).

import "github.com/redis/go-redis/extra/redisotel/v9"
import "github.com/redis/go-redis/extra/redisprometheus/v9"
redisotel.InstrumentTracing(rdb)
redisotel.InstrumentMetrics(rdb)
prometheus.MustRegister(redisprometheus.NewCollector("myapp", "redis", rdb))
if err := rdb.Ping(ctx).Err(); err != nil {
// readiness fail
}

val, err := rdb.Get(ctx, "k").Result()
if err == redis.Nil {
// нет ключа — это нормально
} else if err != nil {
// настоящая ошибка
}

Многие используют errors.Is(err, redis.Nil) — корректно.

keys, _ := rdb.Keys(ctx, "user:*").Result() // BAD

KEYS блокирует Redis на время сканирования (O(N) от размера базы). На 10М ключей = 5 секунд freeze. Используйте SCAN:

iter := rdb.Scan(ctx, 0, "user:*", 100).Iterator()
for iter.Next(ctx) {
key := iter.Val()
// ...
}

Не запускать на prod. Если очень нужно — --lazy (async).

Если подписчик offline → сообщение потеряно. Pub/Sub не для критичных событий. Используйте Streams или Kafka.

Значение > 100KB замедляет всё (сетевая bandwidth, парсинг). Не храните blob в Redis — S3.

HGETALL на hash с 100K полей = 100K строк в одном reply = задержка всем клиентам. Используйте HSCAN.

rdb.Set(ctx, "k", v, 0) // без TTL
rdb.Expire(ctx, "k", time.Minute) // отдельной командой

Между этими командами кто-то может SET без TTL — потеряете expire. Лучше Set с TTL атомарно.

Между командами в pipeline другие клиенты могут вмешаться. Для атомарности — TxPipelined (MULTI/EXEC) или Lua-скрипт.

rdb.MGet(ctx, "user:1", "user:2") // в Cluster может упасть CROSSSLOT

В Cluster MGet/MSET работают только если все ключи в одном слоте. Используйте {tag} для группировки: {user}1, {user}2.

Default 10 × NumCPU. На 32-core — 320 соединений (Redis по умолчанию maxclients=10000). Под нагрузкой maxclients исчерпывается → ошибки.

Настройте PoolSize явно. Метрика redis_pool_wait_count через redisprometheus.

Без TTL ключ остаётся вечно → memory растёт. Каждый кеш-ключ должен иметь TTL. MAXMEMORY + allkeys-lfu спасают, но лучше эксплицитно.

«Two hard problems in computer science: cache invalidation, naming things, and off-by-one errors». Стратегии:

  • TTL (просто, eventually consistent).
  • Delete on write.
  • Write-through (записываем в кеш одновременно с БД).
  • Event-driven (CDC/Outbox → invalidate).
val, _ := rdb.Get(ctx, "balance").Int()
val += 100
rdb.Set(ctx, "balance", val, 0) // BAD: race

Используйте INCRBY или WATCH/MULTI.

Один популярный ключ (featured_product) — все запросы туда → CPU bottleneck. Решения: реплики (для чтения), local cache над Redis, шардирование по бакетам.

Долгий Lua-скрипт блокирует Redis для других клиентов (одного потока).

rdb.Get(ctx, "k").Int() парсит string из Redis в int. Если значение содержит non-numeric — ошибка.

В 2026 для cache часто отключают AOF и даже RDB. Снижает IO. Restart = прогрев заново.

mem_fragmentation_ratio > 1.5 — память фрагментирована. Команда MEMORY PURGE помогает; в новых версиях есть activedefrag.


rdb := redis.NewClient(&redis.Options{
Addr: "...",
PoolSize: 50,
MinIdleConns: 10,
PoolTimeout: 2 * time.Second,
ConnMaxIdleTime: 5 * time.Minute,
ConnMaxLifetime: time.Hour,
DialTimeout: 2 * time.Second,
ReadTimeout: 500 * time.Millisecond,
WriteTimeout: 500 * time.Millisecond,
})
ctx, cancel := context.WithTimeout(parent, 200*time.Millisecond)
defer cancel()
rdb.Get(ctx, "k")

Без deadline — поход в Redis может зависнуть, request у пользователя — тоже.

service:entity:id:field
payments:user:123:balance
sessions:active:s-abc
cache:product:42
rate:user:123
lock:order:42

Convention: : как разделитель, нижний регистр.

rdb.Set(ctx, "cache:...", v, 10*time.Minute)

Никаких persistent ключей в Redis-кеше.

maxmemory 4gb
maxmemory-policy allkeys-lfu

В Redis Cluster — на каждой ноде.

JSON удобен, но дорог. Для hot path — Protobuf, MessagePack, или Hash (без сериализации, native структура).

См. 2.13. Защита от thundering herd.

Local in-memory (ristretto, freecache) + Redis. Local read = nanoseconds, Redis = ~1ms, БД = ~10ms.

v, found := localCache.Get(k)
if !found {
v, _ = rdb.Get(ctx, k).Bytes()
localCache.SetWithTTL(k, v, 1, 30*time.Second)
}

Single instance — только staging/dev. Prod — Sentinel (replicas) или Cluster (sharding).

RDB snapshot периодически выгружать в S3. Без этого при катастрофе данные пропадут.

Метрики Redis (через exporter): hit rate, memory, evictions, connected_clients, blocked_clients.

Метрики клиента: pool_hits, pool_misses, pool_timeouts.

CONFIG SET slowlog-log-slower-than 10000 (10ms). SLOWLOG GET 10 показывает медленные команды.

См. 3.2. SCAN или явные индексы (Set).

Redis 6+ поддерживает TLS. В public cloud — обязательно.

Redis 6+: ACL SETUSER. Один пароль для всех — антипаттерн. Отдельный user на сервис с минимальными command permissions.

Redis даёт atomicity в рамках команды/Lua/MULTI, но не durability (persistence по умолчанию async). Для денежных транзакций — БД с ACID.

Если потеря сообщения недопустима — Streams + Consumer Groups + ACK + XPENDING для retry.

См. 2.19. В trace каждая Redis-команда — отдельный span.

/ready не должен делать тяжёлый Redis-call. Простой PING с timeout.

Для retry-safe операций: храните idempotency_key → response с TTL. При повторе клиент получает тот же результат.


  1. Чем Redis отличается от memcached? Redis: больше типов данных, persistence, replication, transactions, scripts, streams. Memcached проще, чисто LRU cache.

  2. Какие use cases у Redis? Cache, session, rate limit, lock, leaderboard, queue (List/Stream), pub/sub, counter.

  3. Почему Redis быстрый? In-memory, однопоточная обработка commands (нет lock contention), эффективный event loop, оптимизированные структуры данных.

  4. Что такое pipelining? Отправка нескольких команд одним сетевым раундтрипом. Снижает latency. Не атомарно.

  5. Чем MULTI/EXEC отличается от Pipeline? MULTI/EXEC — атомарность (все команды или ни одной). Pipeline — просто batching, между командами могут вклиниться другие.

  6. Зачем WATCH? Optimistic locking. WATCH ключ → MULTI/EXEC выполнится только если ключ не изменился.

  7. Что такое Pub/Sub и его ограничения? Fire-and-forget публикация. Подписчик offline → сообщение потеряно. Для надёжности — Streams.

  8. Чем Streams лучше Pub/Sub? Persistent (хранит сообщения), Consumer Groups (как Kafka), ACK, replay, XCLAIM для retry.

  9. Что такое cache-aside pattern? Приложение: cache miss → fetch from DB → populate cache. Дефолт.

  10. Что такое cache stampede и как защититься? Многие запросы одновременно идут в БД при истечении популярного ключа. Решение — singleflight.

  11. Чем INCR отличается от GET + SET + 1? INCR атомарен на стороне Redis. GET+SET — race (два клиента одновременно прочитали и записали).

  12. Что такое распределённый lock? Эксклюзивный доступ к ресурсу из разных процессов. В Redis — SET NX EX.

  13. Безопасен ли Redlock? В edge cases (clock skew, GC pause, network partition) — нет (Martin Kleppmann). Для критичных систем — Zookeeper/etcd или DB row lock.

  14. Какие eviction policies? noeviction, allkeys-lru, allkeys-lfu, volatile-lru, volatile-lfu, volatile-ttl, allkeys-random, volatile-random.

  15. Чем Sentinel отличается от Cluster? Sentinel — HA (master + replicas + failover), не sharding. Cluster — sharding (16384 slots) + HA.

  16. Что такое hot key и как решить? Один ключ получает 90% запросов → CPU bottleneck. Решения: реплики чтения, local cache, шардирование по бакетам.

  17. Почему нельзя KEYS в prod? O(N) от всего keyspace, блокирует Redis на длительное время. SCAN — incremental, non-blocking.

  18. Что такое connection pool? Set переиспользуемых TCP-соединений к Redis. PoolSize ограничивает concurrent.

  19. Persistence: RDB vs AOF? RDB — snapshot (компактнее, быстрее восстановление). AOF — append-only log (надёжнее, fsync per second/always).

  20. Когда не использовать Redis? Когда нужна сильная durability (деньги), сложные запросы (joins), большие blob (>100KB).

  21. Что такое Lua-скрипт в Redis? Скрипт, выполняемый атомарно (Redis однопоточный). Полезно для rate limit, atomic check-and-set.

  22. Чем HSET отличается от SET? SET — string-значение. HSET — hash (объект с полями). HSET не имеет TTL на поле, только на весь hash.

  23. Что такое CROSSSLOT в Cluster? Ошибка при multi-key операции на ключах из разных slots. Решение: {tag} для группировки.

  24. Как сделать idempotency через Redis? Хранить idempotency_key → response с TTL. При повторе — вернуть кешированный ответ.

  25. Что такое HyperLogLog? Approximate cardinality (count unique). Использует ~12KB на бесконечное множество с ~1% ошибкой. PFADD, PFCOUNT.


Реализуйте UserCache с методами Get(id), Set(user), Invalidate(id). TTL 5 минут.

Добавьте singleflight в Get, чтобы при одновременных miss на один и тот же ID был один поход в БД.

Реализуйте Lock(ctx, resource, ttl) через SET NX EX + UUID + Lua release. Напишите тест с двумя горутинами.

Lua-скрипт: 100 запросов на user_id за 60 секунд. Используйте INCR + EXPIRE атомарно.

Топ-10 пользователей по очкам. ZADD на каждом действии, ZREVRANGEWITHSCORES для топа.

Producer добавляет события в stream orders. Consumer Group workers обрабатывает с XACK. При перезапуске consumer получает unacked сообщения через XCLAIM.

Сравните latency 100 INCR через for-loop и через Pipeline. Замерьте через time.Since.

Subscriber на канал notifications, publisher шлёт каждые 5 секунд. Покажите, что при отключении subscriber сообщения теряются.

В Redis Cluster попробуйте MGET двух ключей без {tag} — увидите ошибку. Добавьте {user}1, {user}2 — заработает.

Подключите redisotel и redisprometheus. Откройте Grafana — увидьте метрики пула и spans в трассах.


  1. Официальная документация go-redis/v9https://redis.uptrace.dev/.
  2. Redis docshttps://redis.io/docs/.
  3. Redis Best Practices (AWS)https://docs.aws.amazon.com/AmazonElastiCache/latest/red-ug/BestPractices.html.
  4. Martin Kleppmann: “How to do distributed locking”https://martin.kleppmann.com/2016/02/08/how-to-do-distributed-locking.html.
  5. Antirez (Salvatore Sanfilippo) bloghttp://antirez.com/ (создатель Redis).
  6. Redis Streams introductionhttps://redis.io/docs/data-types/streams/.
  7. redsynchttps://github.com/go-redsync/redsync.
  8. “Redis in Action” by Josiah Carlson (Manning, 2013) — устарела, но фундаменты живы.
  9. “Database Internals” by Alex Petrov (O’Reilly, 2019) — для понимания TS DB.
  10. Redis Cluster spechttps://redis.io/docs/reference/cluster-spec/.