Как сервис должен переживать падение 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.
Содержание¶
- Главное правило
- Классификация операций
- Cache (cache-aside)
- Rate limiting
- Consumer dedup
- Idempotency-key для POST
- Session / short-lived state
- Distributed lock
- Health endpoints:
/readyz - Redis-client timeouts
- Stale fallback
- Чего не делать
- Чеклист
- Связанные разделы
Главное правило¶
Каждая 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 EX — fail-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.
Две стратегии:
- Liveness
/healthz— 200, пока процесс жив. Никогда не проверяет Redis. - 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.
Связанные разделы¶
../conventions/caching.md— cache-aside, TTL, Redis failure modes.../conventions/security.md— rate-limit, fail-open/closed.../patterns/idempotent-consumer.md— dedup-middleware, fail-open/closed.../patterns/retry-and-circuit-breaker.md— fallback и graceful degradation.../troubleshooting/redis-unavailable.md— диагностика, когда Redis уже упал.../troubleshooting/memory-leak.md— stale-LRU как bounded cache.load-test.md— как воспроизвести outage в тестовом окружении.