Модуль 1. Redis в Go-проекте: клиент как системный компонент

Цель модуля: решить главный вопрос — зачем вашему сервису Redis и что вы готовы платить, когда он недоступен. Собрать клиент go-redis как системный компонент: с осознанными таймаутами, пулом, health-check и политикой деградации — а не как «ещё одну глобальную переменную».

Перед началом: базовый Go (интерфейсы, контексты, goroutine), Docker. Инструменты: github.com/redis/go-redis/v9, miniredis (тесты), Docker. Трудоёмкость: ~6–8 ч (теория 3 ч · практика 4 ч).


🔥 Инцидент: «Redis мигнул на 400 мс — а API отходил полчаса»

Ночная перекачка соседнего стенда: Redis перезапустился и был недоступен меньше секунды. Мониторинг Redis показал 400 мс даунтайма. А API-сервис в это время «встал»: p99 вырос с 80 мс до десятков секунд, горутины копились, часть запросов отвалилась по клиентским таймаутам. Отходили полчаса — столько занимало расхлёбывание очереди ожидающих.

Разбор показал: клиент go-redis жили на дефолтах — таймауты в секунды, несколько ретраев поверх, размер пула «как повезло». Каждый запрос к недоступному Redis висел долго, занимал горутину и слот пула, а их на каждый запрос приходилось по два-три. Redis не был причиной деградации — причиной была конфигурация клиента, о которой никто не думал, «ведь это просто кеш». В этом модуле собираем клиент как системный компонент: таймауты, пул, health-check и явную политику — что делать, когда Redis недоступен.

📖 1.1. Карта применений: где Redis оправдан

Redis в Go-сервисе — не «ещё одна база», а набор конкретных механизмов, каждый со своей ценой:

ПрименениеЧто даётЦена ошибки
Кэш (cache-aside)Разгрузка БД, мкс-ответыПропавшее = перечитаем из БД (дёшево)
Распределённые локиКоординация реплик (cron, дедуп)Зависшая работа или двойная — зависит от контракта
Rate limitingЗащита, тарифы, лимиты внешних APIПропуск лимита при отказе — осознанная цена
Идемпотентность (ключи дедупа)Защита от повторовДубль при потере метки (TTL ≥ retention!)
Очереди (Streams/Pub-Sub)Лог, fan-outОтдельная дисциплина — курс «Redis как брокер»

Общий принцип цен: чем важнее функция, тем жёстче требования к доступности Redis — и тем осмысленнее политика отказа (§1.3).

Когда Redis не нужен: данные должны переживать отказ (это БД), нужны транзакции между сущностями (это БД), нужна очередь с retention «дни» и миллионами сообщений (это Kafka — курс брокеров).


📖 1.2. Клиент go-redis: конфигурация, которая важнее «hello world»

Дефолты клиента рассчитаны на localhost. Продакшен-конфигурация — это явные ответы на четыре вопроса:

go
func NewRedis(cfg RedisConfig) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:         cfg.Addr,
        Password:     cfg.Password,
        DB:           cfg.DB,

        // 1. Подключение: сколько ждать установки соединения
        DialTimeout:  2 * time.Second,

        // 2. Операции: обычные команды должны падать быстро
        //    (кэш-промах дешевле висящего запроса)
        ReadTimeout:  500 * time.Millisecond,
        WriteTimeout: 500 * time.Millisecond,

        // 3. Пул: сколько соединений держать даже в простое
        //    (холодный пул = всплеск латентности при первом трафике)
        PoolSize:     cfg.PoolSize,
        MinIdleConns: 5,

        // 4. Очередь команд: клиент копит запросы, когда Redis молчит;
        //    без лимита — рост горутин и памяти при инциденте
        PoolSize + MaxRetries: cfg.MaxRetries, // 2, с бэкоффом
    })
}

Ключевые грабли:

  • Таймаут по умолчанию — 3 секунды. 3 секунды на кэш-чтение в HTTP хендлере — это бюджет всего запроса; ужимайте до сотен миллисекунд;
  • Блокирующие команды требуют отдельного пула. XREAD BLOCK 0 из общего пула «съедает» соединения и душит кэш (детали — курс «Redis как брокер», М1). Правило: blocking-потребители — отдельный клиент;
  • Клиент создаётся один раз (в composition root приложения), живёт со сервисом и закрывается в shutdown. Не создавайте клиент на запрос.

📖 1.3. Политики отказа: fail-open против fail-close

Это главное архитектурное решение всего курса. Когда Redis недоступен, у каждой функции два честных варианта:

ФункцияПолитикаПочему
Кэшfail-open: читать из БД без кэшаСервис продолжает работать, медленнее
Rate limiting (входящий)обычно fail-open: пропускать запросыДоступность важнее лимита (но решайте по угрозе!)
Rate limiting (тарифы/биллинг)возможно fail-close: отклонятьИначе раздаёте платное бесплатно
Распределённые локиfail-close (не брать работу)Двойное выполнение хуже простоя
Идемпотентностьfail-close для записиДубль списания дороже отказа

Плохо не ни один из вариантов, а стихийный: когда каждый вызов redis.Get в глубине кода сам решает, что делать при ошибке. Правильная форма — политика выражена один раз, в обёртке:

go
// CacheGet: fail-open — ошибка Redis неотличима от промаха.
func CacheGet(ctx context.Context, rdb *redis.Client, key string) ([]byte, bool) {
    val, err := rdb.Get(ctx, key).Bytes()
    if err != nil {
        if !errors.Is(err, redis.Nil) {
            metrics.CacheErrors.Inc() // ошибка Redis — считаем отдельно от промаха
        }
        return nil, false // для вызывающего: промах
    }
    return val, true
}

Заметьте разделение redis.Nil (нормальный промах) от ошибки связи — путаница этих двух случаев выдаёт себя метрикой «кэш всегда промахивается».


📖 1.4. Health-check и наблюдаемость

Redis-зависимости попадают в readiness, но с нюансом: если кэш fail-open, то падение Redis — не повод выводить pod из балансировки (сервис работает!). Разделяйте:

go
// Readiness: Redis нужен для fail-close функций (локи, лимиты)
func ready(ctx context.Context, rdb *redis.Client) error {
    ctx, cancel := context.WithTimeout(ctx, 300*time.Millisecond)
    defer cancel()
    return rdb.Ping(ctx).Err()
}

Минимальный набор метрик клиента: операции по типам (get/set/hit/miss), ошибки (связь vs redis.Nil), латентность (p99 по командам), пул (redis_client_pool_hits/misses/timeouts от самого клиента). Алерты — в Модуле 6.


📖 1.5. Тестирование: miniredis против testcontainers

Два уровня, оба нужны:

  • github.com/alicebob/miniredis/v2 — in-memory Redis на Go: мгновенный, без Docker, поддерживает основные команды (включая Lua-скрипты и TTL). Идеален для unit-тестов бизнес-логики поверх Redis;
  • testcontainers-go — настоящий Redis в Docker: для интеграционных тестов, где важны поведение сети, репликация, eviction.
go
// unit: быстро и детерминированно
mr := miniredis.RunT(tb)
rdb := redis.NewClient(&redis.Options{Addr: mr.Addr()})

Правило: бизнес-код тестируется против интерфейса (см. §1.3 — обёртка CacheGet), а miniredis/testcontainers — для самих обёрток.


💻 Практика

  1. Каркас сервиса: конфиг → клиент (таймауты из §1.2) → readiness /health/ready с Ping → graceful shutdown (rdb.Close()).
  2. Обёртка кэша: CacheGet/CacheSet с разделением redis.Nil и ошибок связи + две метрики.
  3. Демонстрация деградации: остановите Redis — сервис жив, /health/ready красный, кэш-запросы промахиваются (fail-open), метрика cache_errors растёт.
  4. Unit-тест обёртки на miniredis: hit / miss / «redis лежит» (mr.Close()) — три кейса.

Артефакт: каркас сервиса + обёртка + три теста.


🧩 Quiz "Проверь себя"

1. Сервис использует Redis для кэша (fail-open) и распределённых локов (fail-close). Redis упал. Что правильно?
2. Чем отличается redis.Nil от ошибки связи в Get?
3. Почему блокирующие команды (XREAD BLOCK) требуют отдельного клиента?
4. Клиент go-redis создаётся…

✅ Итоги модуля

ТемаГлавное
Карта примененийКэш/локи/лимиты/идемпотентность/очереди; цена ошибки определяет политику
КлиентОдин на приложение; таймауты в сотни мс; MinIdleConns; отдельный пул для blocking
Политики отказаfail-open / fail-close — решаются один раз в обёртке, не в глубине кода
НаблюдаемостьПромах ≠ ошибка связи; readiness по fail-close функциям
Тестыminiredis для unit, testcontainers для интеграции

🔗 Мостик к Модулю 2

Клиент поставлен на рельсы. Дальше — первые два больших применения: кэш (cache-aside без stampede, singleflight, инвалидация) и распределённые локи (SET NX PX + токен, честные границы Redlock) — с ответом, где лок в Redis честен, а где нужен контракт БД.