25. Работа с Redis

В 2009 году Сальваторе Санфилиппо (antirez) создал Redis, руководствуясь принципом предельного минимализма: вместо того чтобы усложнять базу данных тяжелыми транзакционными блокировками и распределенными потоками, он построил однопоточный сервер структур данных в оперативной памяти вокруг неблокирующего цикла событий (event loop на базе системного селектора epoll).

Результат оказался ошеломляющим: операции над хеш-таблицами, списками и множествами стали выполняться за доли миллисекунды. Но в этом же кроется и фундаментальная опасность. Если в кодовой базе Go разработчик запускает тяжелую O(N) команду, однопоточный цикл событий Redis замирает, и тысячи легких горутин вашего бэкенда мгновенно выстраиваются в мертвую очередь к перегруженному сокету.

Философия работы с Redis в Go

Redis — это не просто in-memory key-value хранилище. Это однопоточный сервер событий (event loop), управляющий сложными структурами данных (strings, hashes, lists, sets, streams, geospatial) с детерминированной латентностью. В экосистеме Go работа с Redis отличается от PHP или Java: здесь нет блокирующих тредов ОС на каждый запрос, а вся конкурентность управляется планировщиком Go и сетевым поллером netpoll. Правильная интеграция с Redis требует понимания пулов соединений, протокола сериализации, атомарности операций и влияния парсинга ответов на сборщик мусора.

1. Архитектура сервера и протокол RESP

Redis использует текстовый бинарный протокол RESP (Redis Serialization Protocol). В версии 3 (RESP3) добавлены типизированные ответы, пуш-уведомления и нулевые указатели, что упрощает парсинг на стороне клиента.

Каждая команда отправляется как массив строк. Например, GET user:1 превращается в:

TEXT
*2\r\n$3\r\nGET\r\n$6\r\nuser:1\r\n

Ответ парсится посимвольно. В Go клиент github.com/redis/go-redis/v9 оптимизировал этот процесс, избегая промежуточных аллокаций через буферы []byte и прямой парсинг в структуры.

flowchart LR
    A["Горутина вызывает<br>команду Redis"] --> B["Запрос попадает<br>в пул соединений"]
    B --> C["Сериализация в RESP<br>буферизация в conn"]
    C --> D["syscall write<br>отправка в сокет"]
    D --> E["Ожидание через<br>epoll netpoll"]
    E --> F["syscall read<br>чтение ответа"]
    F --> G["Парсинг RESP3<br>десериализация в Go-типы"]
    G --> H["Возврат результата<br>горутине"]

    classDef normal fill:#1e293b,stroke:#38bdf8,color:#f8fafc;
    classDef syscall fill:#0f172a,stroke:#10b981,color:#f8fafc;
    class A,B,C,G,H normal;
    class D,E,F syscall;
  

2. Пул соединений и механика netpoll

go-redis использует собственный пул соединений, оптимизированный под высокую конкуренцию. В отличие от database/sql, пул Redis работает через каналы и атомарные счетчики, минимизируя блокировки.

Go
package cache

import (
    "context"
    "fmt"
    "time"

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

func NewRedisClient(addr string) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:            addr,
        PoolSize:        50,               // Максимум одновременных соединений
        MinIdleConns:    10,               // Предварительно открытые соединения
        ConnMaxIdleTime: 10 * time.Minute,
        ConnMaxLifetime: 30 * time.Minute,
        ReadTimeout:     200 * time.Millisecond,
        WriteTimeout:    200 * time.Millisecond,
    })
}

func GetUser(ctx context.Context, rdb *redis.Client, userID int64) (string, error) {
    key := fmt.Sprintf("user:%d:profile", userID)
    val, err := rdb.Get(ctx, key).Result()
    if err != nil {
        if err == redis.Nil { // Ключ не найден в кеше
            return "", nil
        }
        return "", fmt.Errorf("redis get: %w", err)
    }
    return val, nil
}

3. Пайплайнинг и мультиплексирование

Главный инструмент оптимизации Redis в Go — Pipeline. Он группирует несколько команд в один системный вызов write, отправляет их пакетом, а затем читает все ответы через один read. Это сокращает RTT и количество syscall с N до 2.

Go
func BatchUpdateUsers(ctx context.Context, rdb *redis.Client, users []*User) error {
    pipe := rdb.Pipeline()

    for _, u := range users {
        key := fmt.Sprintf("user:%d:profile", u.ID)
        data, err := json.Marshal(u)
        if err != nil {
            return fmt.Errorf("marshal user %d: %w", u.ID, err)
        }
        pipe.Set(ctx, key, data, 10*time.Minute)
    }

    // Выполнение всех команд одним пакетом
    _, err := pipe.Exec(ctx)
    if err != nil {
        return fmt.Errorf("pipeline exec: %w", err)
    }
    return nil
}

4. Сериализация, аллокации и GC-давление

encoding/json активно использует рефлексию. Каждый json.Marshal/json.Unmarshal создает временные структуры в куче. При тысячах RPS это ускоряет достижение триггера GOGC, заставляя рантайм чаще запускать фазы Concurrent Mark и отвлекать процессорные ядра на помощь сборщику мусора (GC Mark Assist), что приводит к росту задержек p99.

Оптимизация:

  1. Используйте go-redis методы .Scan() для прямых преобразований типов.
  2. Для hot-path применяйте msgpack или protobuf, они быстрее JSON на 30-50% и требуют меньше аллокаций.
  3. Предварительно выделяйте буферы через sync.Pool.
Go
func GetUserTyped(ctx context.Context, rdb *redis.Client, userID int64) (*User, error) {
    key := fmt.Sprintf("user:%d:profile", userID)

    // Прямое чтение в []byte, минуя строковые аллокации
    val, err := rdb.Get(ctx, key).Bytes()
    if err != nil {
        if err == redis.Nil {
            return nil, nil
        }
        return nil, err
    }

    var u User
    if err := json.Unmarshal(val, &u); err != nil {
        return nil, fmt.Errorf("unmarshal: %w", err)
    }
    return &u, nil
}

5. Ловушки production и антипаттерны

  • Блокирующие команды и пул: BLPOP, BRPOP, XREAD BLOCK ждут данные. Они блокируют соединение на всё время ожидания. Если PoolSize=50 и 50 горутин вызовут BLPOP, пул исчерпается. Решение: выделяйте отдельный клиент с большим PoolSize для pub/sub/streams или используйте асинхронные воркеры.
  • Контекст и отмена: go-redis/v9 полностью поддерживает context.Context. При отмене контекста клиент помечает соединение как bad и закрывает его. Команда на стороне Redis не отменяется (Redis однопоточный, отмена невозможна без CLIENT KILL). Это приводит к выполнению команды "в холостую".
  • KEYS и SCAN: KEYS pattern блокирует Redis O(N). В production запрещен. Используйте SCAN с курсором и COUNT, но помните, что SCAN тоже может нагружать сервер при высокой дельте изменений.
  • Memory Fragmentation: Redis использует jemalloc или системный malloc. При активном удалении ключей возникает фрагментация памяти (mem_fragmentation_ratio > 1.5). Это не лечится на стороне Go, но мониторинг метрик Redis обязателен.

6. Сравнение с другими экосистемами

Аспект PHP (phpredis) Java (Jedis / Lettuce) Python (redis-py) Go (go-redis/v9)
Конкурентность Синхронный, блокирует тред PHP-FPM Jedis (sync, блокирует), Lettuce (async, Netty) GIL блокирует, асинхронность через asyncio Горутины + netpoll, неблокирующая асинхронность
Пул соединений Отсутствует (одно соединение на процесс) Встроенный (Apache Commons Pool) ConnectionPool с thread-safety Высокооптимизированный, lock-free частично
Пайплайнинг multi() или pipeline() (sync) pipeline() (async batch) pipeline() (sync buffer) Pipeline() (async, batch write/read)
Overhead Минимальный (C-расширение), но блокирующий JVM GC pressure, Netty overhead GIL contention, object creation Низкий, аллокации при парсинге, оптимизируемы

7. Итог

  1. Redis однопоточен: избегайте тяжелых команд, используйте SCAN вместо KEYS, мониторьте латентность.
  2. go-redis/v9 + context + netpoll обеспечивают максимальную пропускную способность без блокировок тредов ОС.
  3. Пул соединений настраивайте под пиковую нагрузку: PoolSize, MinIdleConns, таймауты обязательны.
  4. Пайплайнинг сокращает RTT и syscall, но не дает атомарности. Для атомарности используйте Lua-скрипты.
  5. Сериализация через json создает GC-давление. Для highload применяйте бинарные форматы или прямые .Bytes()/.Scan().
  6. Контекст отменяет ожидание на клиенте, но не прерывает выполнение на стороне Redis. Учитывайте это при таймаутах.
  7. Distributed locks требуют атомарного SET NX EX и строгой проверки владельца. Никогда не реализуйте кастомные замки без understanding edge cases.

Следующая статья: 26. Background jobs