Перейти к содержанию

Как сервис должен переживать падение Redis

Redis — не primary store, а ускоритель: кэш, rate-limit счётчики, дедуп- ключи, idempotency. Падение Redis не должно валить сервис — но и «просто игнорировать» нельзя: часть операций становится небезопасной. Эта страница — как заранее спроектировать поведение сервиса при недоступном Redis, по операциям.

Reference по Redis-клиенту, TTL, naming — в ../conventions/caching.md. Про дедупликацию consumer'ов — ../patterns/idempotent-consumer.md. Если Redis уже лёг и нужно разобрать — см. ../troubleshooting/redis-unavailable.md.

Содержание

Главное правило

Каждая Redis-операция в коде сервиса должна иметь явное решение: fail-open или fail-closed. Молчаливый default — баг: когда Redis ляжет, поведение непредсказуемо.

  • Fail-open — при ошибке Redis пропусти операцию дальше. Потенциально небезопасно (например, rate-limit пропустит лишние запросы), но сохраняет работоспособность сервиса.
  • Fail-closed — при ошибке Redis верни ошибку клиенту. Сохраняет гарантии (дедуп, idempotency), но превращает Redis в SPOF.

Выбор — per-операция, не per-сервис.

Классификация операций

Операция Решение Причина
Cache-aside (GET для чтения) fail-open Upgrade на БД — медленнее, не опаснее
Rate-limit (INCR/token bucket) fail-open (default) / fail-closed (критичные endpoint'ы) Abuse ≠ SPOF
Consumer dedup (SETNX на Message.UUID) fail-open (default) / fail-closed (для finance-like событий) Дубли ≠ simplicity
Idempotency-key на POST fail-closed Без проверки idem-key двойной платёж / двойная команда
Session store (если JWT в Redis, а не в токене) fail-closed Иначе auth обходится
Distributed lock fail-closed «Lost lock» = потенциальная corruption
Feature flag cache fail-open Возвращаем default
Дедуп по бизнес-ID (в Postgres) N/A — Redis не участвует Первичная проверка — unique constraint

Дефолт = fail-open. Fail-closed — осознанное исключение.

Cache (cache-aside)

Всегда fail-open. При ошибке Redis — идём в БД напрямую:

func (s *Service) GetPlace(ctx context.Context, id int64) (*domain.Place, error) {
    cached, err := s.cache.GetPlace(ctx, id)
    if err != nil && !errors.Is(err, redis.Nil) {
        metrics.CacheErrors.WithLabelValues("get", classify(err)).Inc()
        log.FromCtx(ctx).Warn("cache get failed", "err", err, "id", id)
        // продолжаем в БД — это и есть fail-open
    }
    if cached != nil {
        return cached, nil
    }

    place, err := s.repo.GetPlace(ctx, id)
    if err != nil { return nil, err }

    if err := s.cache.SetPlace(ctx, id, place, withJitter(5*time.Minute)); err != nil {
        metrics.CacheErrors.WithLabelValues("set", classify(err)).Inc()
        // молча, это best-effort
    }
    return place, nil
}

Риски:

  • БД перегрузится, если все запросы пойдут мимо кэша. Решение — singleflight/early-refresh (см. ../conventions/caching.md), плюс увеличение pool size per сервис на время outage.
  • Тяжёлые ORM/агрегирующие запросы становятся узким местом. Если кэш защищал такой запрос, снижай нагрузку через rate-limit на endpoint или fallback на stale-значение (см. §Stale fallback).

Rate limiting

Дефолт — fail-open, с логом WARN. Обоснование: rate-limit — защита от abuse, не от downtime. Лучше пропустить spike, чем уронить сервис.

pipe := rdb.TxPipeline()
incr := pipe.Incr(ctx, key)
pipe.Expire(ctx, key, window)
if _, err := pipe.Exec(ctx); err != nil {
    metrics.RateLimitRedisErrors.WithLabelValues(endpoint).Inc()
    log.FromCtx(ctx).Warn("ratelimit redis unavailable",
        "endpoint", endpoint, "err", err)
    next.ServeHTTP(w, r)  // пропускаем
    return
}
if incr.Val() > int64(limit) {
    // ... 429 ...
}

Исключения (fail-closed):

  • POST /v1/auth/reset-password — без rate-limit это DoS на email/SMS канал.
  • POST /v1/payments/* — не пропускаем без ограничения.
  • Критичные внешние-провайдер вызовы (SMS-gateway, push-провайдер) — rate-limit защищает бюджет, не сервис.

На таких endpoint'ах middleware возвращает 503 при недоступном Redis:

if err := rdb.Ping(ctx).Err(); err != nil {
    writeError(w, http.StatusServiceUnavailable,
        "temporarily_unavailable", "try again later")
    return
}

Подробно про настройку rate-limit — ../conventions/security.md.

Consumer dedup

Redis хранит Message.UUID уже обработанных сообщений. При недоступности:

  • Fail-open (default). Handler вызывается заново для сообщения, которое уже обрабатывалось. Гарантия at-least-once — дубли возможны. Работает при условии, что handler идемпотентен по БД-уровню (unique constraint, upsert).
  • Fail-closed. Dedup-middleware возвращает error → Retry → PoisonQueue → DLQ. Все сообщения пойдут в DLQ, пока Redis не вернётся. Применимо для критичных событий, где бизнес-уровневая идемпотентность не реализуема (рассылки, внешние платежи).

Настройка — ../patterns/idempotent-consumer.md.

Правило: fail-open + Postgres unique constraint покрывает 95% кейсов. Fail-closed — когда бизнес явно требует «не больше одного».

Idempotency-key для POST

Idempotency-Key header, Redis хранит (key → cached response) с TTL 24 часа. При недоступности Redis:

  • Fail-closed. 503 с Retry-After: 5. Клиент повторит — либо попадёт на Redis уже поднятый, либо продолжит получать 503 ровно до восстановления.
func (h *Handler) CreatePayment(w http.ResponseWriter, r *http.Request) {
    key := r.Header.Get("Idempotency-Key")
    if key == "" {
        writeError(w, http.StatusBadRequest, "missing_idempotency_key", "")
        return
    }
    cached, err := h.cache.Get(r.Context(), "idem:"+key)
    if err != nil {
        writeError(w, http.StatusServiceUnavailable,
            "temporarily_unavailable", "idempotency check unavailable")
        return
    }
    if cached != nil {
        // ... replay закешированный response ...
        return
    }
    // ... нормальный flow ...
}

Почему fail-closed: без Redis невозможно отличить retry от первого запроса. Одобрение платежа «ещё раз» — хуже, чем временный 503.

Альтернатива — хранить idem-key в Postgres (не в Redis). Тогда одно звено меньше, но цена — каждый POST лезет в БД вместо быстрого Redis- hit. Использовать, только если Redis-реpeti outage'и регулярные.

Session / short-lived state

Если сессия хранится полностью в JWT (stateless) — Redis не нужен, outage на auth не влияет. Это наш дефолт.

Если Redis хранит, например, revocation list или last-activity:

  • Read revocation list при верификации токена — fail-closed, иначе отозванные JWT работают.
  • Last-activity / «online now» — fail-open, это информационное.

Distributed lock

Redlock или SET NX EXfail-closed. Без lock'а операции, которые полагаются на взаимное исключение, нарушат инварианты.

Но правило важнее: избегай distributed lock на Redis. Это антипаттерн:

  • Lost-lock при network partition → две реплики считают, что у них lock → corruption.
  • TTL lock'а дожит → работа не сделана, но lock «освободился».

Правильные альтернативы:

  • Advisory lock в Postgres (pg_advisory_lock / pg_try_advisory_lock) — транзакционные гарантии, автоснятие при обрыве соединения.
  • Уникальный constraint в БД — если «только один обработчик» можно выразить как «только одна строка с таким ключом».
  • Outbox + kafka-partition key — если «в порядке» = «в одной партиции» с single consumer-group'ой.

См. ../conventions/db-pgx.md для advisory lock в миграциях, ../patterns/outbox.md для упорядочивания через partition key.

Health endpoints: /readyz

/readyz должен включать Redis-ping только для endpoint'ов, которые реально от него зависят. Тупая «если Redis лежит → сервис не ready» приводит к тому, что pods перезагружаются, хотя сервис мог бы работать в degraded mode.

Две стратегии:

  1. Liveness /healthz — 200, пока процесс жив. Никогда не проверяет Redis.
  2. Readiness /readyz — 200, пока БД ping'уется. Redis — опционально: если хоть один endpoint fail-closed по Redis, /readyz тоже включает redis-ping; иначе — не включает.
func (h *HealthHandler) Ready(w http.ResponseWriter, r *http.Request) {
    ctx, cancel := context.WithTimeout(r.Context(), 2*time.Second)
    defer cancel()

    if err := h.pool.Ping(ctx); err != nil {
        writeError(w, http.StatusServiceUnavailable, "db_unavailable", "")
        return
    }
    if h.redisRequired {
        if err := h.redis.Ping(ctx).Err(); err != nil {
            writeError(w, http.StatusServiceUnavailable, "redis_unavailable", "")
            return
        }
    }
    writeJSON(w, http.StatusOK, map[string]string{"status": "ready"})
}

redisRequired пробрасывается из конфига: REDIS_REQUIRED=true для payment-service, false — для review-service.

См. ../conventions/http-api.md.

Redis-client timeouts

Жёсткие timeouts — обязательны. Без них медленный (но не упавший) Redis съест таймауты клиентов:

client := redis.NewUniversalClient(&redis.UniversalOptions{
    Addrs:        cfg.Redis.Addrs,
    DialTimeout:  500 * time.Millisecond,
    ReadTimeout:  100 * time.Millisecond,
    WriteTimeout: 100 * time.Millisecond,
    PoolSize:     20,
    PoolTimeout:  500 * time.Millisecond,  // ожидание free connection
})

Правило: ReadTimeout должен быть меньше остаточного request- budget'а handler'а. Если у handler'а chi-timeout 30 секунд, а он последовательно делает 3 Redis-вызова по 100ms + 1 pgx-вызов — всего 400ms потратится на Redis даже при slow-случае. Это не критично, но ReadTimeout: 5s — уже катастрофа.

Retry внутри Redis-клиента:

MaxRetries: 1,            // одна попытка после первой — больше бессмысленно
MinRetryBackoff: 10 * time.Millisecond,
MaxRetryBackoff: 100 * time.Millisecond,

Stale fallback

Advanced-паттерн: при ошибке Redis отдать стайл-значение из локального in-process LRU. Это буфер на время короткого outage.

func (s *Service) GetPlace(ctx context.Context, id int64) (*domain.Place, error) {
    cached, err := s.cache.GetPlace(ctx, id)
    if err == nil && cached != nil {
        s.staleLRU.Set(id, cached)  // запоминаем на случай
        return cached, nil
    }
    if err != nil && !errors.Is(err, redis.Nil) {
        if stale, ok := s.staleLRU.Get(id); ok {
            metrics.StaleHits.Inc()
            return stale, nil
        }
    }
    return s.repo.GetPlace(ctx, id)
}

Trade-off:

  • Плюс — во время outage часть трафика обслуживается из памяти pod'а, БД не проседает.
  • Минус — stale-данные могут быть 10-15 минут назад. Применимо к read-heavy, low-update кэшам.

LRU — с bounded размером, иначе утечка памяти. См. ../troubleshooting/memory-leak.md.

Чего не делать

  • Не оборачивать Redis в circuit breaker. go-redis сам обрабатывает connection refused / timeout быстро; CB над ним — лишний слой без выигрыша. См. ../patterns/retry-and-circuit-breaker.md.
  • Не retry'ить Redis-команды снаружи. Клиент уже делает 1 retry. Больше не поможет, но увеличит latency.
  • Не использовать Redis pub/sub для критичных сигналов. При outage сообщения теряются молча. Для cross-service событий — Kafka + outbox.
  • Не логировать Redis-error как ERROR. Это WARN, если fail-open. ERROR зашумит алерты.
  • Не показывать stale-данные без отметки. Пустой ответ или «старые» данные без флага — пользователь не знает, что что-то деградирует. Заголовок X-Data-Stale: true или поле в body.
  • Не проверять Redis синхронно в /healthz. /healthz = процесс жив. Redis — в /readyz и только если нужен.
  • Не хранить write-primary-state в Redis. Счётчики, которые нельзя восстановить из БД → в Postgres, не в Redis.

Чеклист

  • Каждая Redis-операция в коде имеет комментарий или тип, явно указывающий fail-open / fail-closed.
  • Все Redis-вызовы обёрнуты в context.WithTimeout или используют client-level timeout.
  • DialTimeout, ReadTimeout, WriteTimeout настроены в redis.UniversalOptions.
  • Метрика cache_errors_total{op, reason} или аналог есть.
  • Fail-open cache-read не роняет БД: есть singleflight или допустимый overcommit pool.
  • Fail-closed операции возвращают 503 с Retry-After, не 500.
  • /readyz проверяет Redis только, если от него зависит хотя бы один fail-closed endpoint.
  • Distributed lock'ов на Redis нет; если есть — заменены на advisory lock / unique constraint.
  • Нагрузочное тестирование (k6) включает сценарий «Redis недоступен» через toxiproxy. См. load-test.md.
  • Runbook знает, куда смотреть при Redis outage — ../troubleshooting/redis-unavailable.md.

Связанные разделы