Skip to Content
How-toПережить падение Redis

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

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

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

Содержание

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

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

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

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

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

ОперацияРешениеПричина
Cache-aside (GET для чтения)fail-openUpgrade на БД — медленнее, не опаснее
Rate-limit (INCR/token bucket)fail-open (default) / fail-closed (критичные endpoint’ы)Abuse ≠ SPOF
Consumer dedup (SETNX на Message.UUID)fail-closed (default); fail-open только при DB-level идемпотентности handler’аБез DB-level защиты fail-open = дубли в БД и внешних системах
Idempotency-key на POSTfail-closedБез проверки idem-key двойной платёж / двойная команда
Session store (если JWT в Redis, а не в токене)fail-closedИначе auth обходится
Distributed lockfail-closed«Lost lock» = потенциальная corruption
Feature flag cachefail-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), плюс увеличение 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.

Consumer dedup

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

  • Fail-closed (default). Dedup-middleware возвращает error → Retry → PoisonQueue → DLQ. Короткий Redis-outage — сообщения задержатся на retry-окно и пройдут; длинный — часть уйдёт в DLQ и будет разобрана вручную. Безопасно по умолчанию, потому что не ведёт к дубликатам в БД / во внешних системах.
  • Fail-open. Dedup-middleware возвращает «не дубль» и пропускает сообщение в handler без проверки. Допустим только при DB-level идемпотентности handler’а (INSERT ... ON CONFLICT DO NOTHING по natural key + гейтинг side effects по RowsAffected). Без этого fail-open = прямой дубликат данных и дубль внешних вызовов (FCM, SMS, платежи) на каждой Redis-паузе.

Автоматика long outage — circuit breaker поверх Redis SETNX: после серии timeout’ов CB переходит в open, Deduplicator bypass’ится, счётчик consumer_dedup_bypassed_total инкрементится, alert page’ит on-call. Это работает только на handler’ах с DB-level защитой.

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

Правило: fail-closed — дефолт для любого нового consumer’а. Fail-open включается явно, на ревью, с подтверждённой DB-level идемпотентностью; иначе PR не мержится.

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 для advisory lock в миграциях, ../patterns/outbox для упорядочивания через 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.

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.

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

  • Не оборачивать Redis в circuit breaker. go-redis сам обрабатывает connection refused / timeout быстро; CB над ним — лишний слой без выигрыша. См. ../patterns/retry-and-circuit-breaker.
  • Не 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.
  • Runbook знает, куда смотреть при Redis outage — ../troubleshooting/redis-unavailable.

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

Last updated on