Отказоустойчивость и нагрузка: 10k соединений без сюрпризов

Перед началом: модуль 6 (notification-worker на шине); из курса 1 — Модуль 6 (sync-протокол и догон по seq), Модуль 11 (метрики, лимиты, шторм-тест), Модуль 12 (релизный гейт и откат). Инструменты: docker compose, redis-cli, curl, Prometheus/Grafana, k6 (или синтетический генератор на Go). Трудоёмкость: ~6 ч.

🔥 Инцидент: «ночной апгрейд Redis и пропавшие сообщения»

Ночь, плановый апгрейд Redis. Шина — ещё в pub/sub-режиме переходного периода. Redis лежал десять секунд, реплики event-service переподключились сами, все панели позеленели. Утром — тикеты: «отправил сообщение, собеседник его не видел, пока не открыл диалог». Дежурный ищет потерянные данные, готовит объявление об инциденте с потерей.

А терять нечего. Пока Redis был недоступен, pub/sub работал как at-most-once: события канала ушли в пустоту, и транспорт о них не помнит. Спасла не шина, а клиент: следующий seq не пришёл — при переподключении кадр sync дочитал дыру по REST (курс 1, Модуль 6). Инцидент оказался не «потерей данных», а секундной задержкой доставки, которую никто не измерял: на лаги не было ни SLO, ни алерта. В этом модуле — расклад всех отказов по цене и нагрузочный прогон, после которого 10k соединений на реплику перестают быть сюрпризом.

📖 Теория

7.1 Матрица отказов: радиус поражения и цена

В микросервисной топологии падать можно по очереди — и заранее знать, что увидит пользователь. Полный список «что если»:

Что падаетРадиус пораженияЧто видит клиентВосстановление
event-репликасоединения этой репликиразрыв WS, авто-реконнектпереподключение с jitter + догон по seq (курс 1, Модуль 6); клиенты других реплик ничего не замечают
Redis, pub/sub-режим шинывсе реплики, окно недоступностизадержка доставкиat-most-once: потерянные события не вернутся — клиенты дочитывают по seq; это норма транспорта, а не потеря данных
Redis, штатный Streamsзадержка, без потерьпауза доставкикаждая реплика — своя группа gateway-<replica-id>; pending дочитывается после возвращения Redis
user-serviceлогин, профиль, контактыпереписка работаетJWT валидируются офлайн (RS256 + кэш JWKS); синхронных вызовов на пути кадра нет
push-провайдертолько пушиWS-доставка в нормеnotification-worker изолирован (модуль 6): ретраи и чистка подписок не задевают шину и соединения

Читать таблицу нужно как бюджет риска: у каждого отказа известен радиус, известен механизм восстановления и известно, что данные при этом не теряются — потому что хранение (seq, сообщения, outbox) живёт не там, куда пришёл отказ.

7.2 Деградация Redis: busy-poll fallback

Штатно реплика живёт от шины: XREADGROUP блокируется до событий. Когда шина недоступна, реплика не умирает, а деградирует: уходит в busy-poll fallback — периодически пробует вернуться на шину и на время деградации опирается на прямые проверки состояния (presence TTL, локальные очереди, health шины) с редким интервалом опроса.

go
// цикл потребителя: шина -> пауза -> редкий опрос, пока Redis не вернётся
for {
    msgs, err := bus.Read(ctx, group, block)
    if err != nil {
        log.Warn("bus unavailable, degraded", "err", err)
        time.Sleep(pollInterval) // секунды, не миллисекунды
        continue
    }
    deliver(msgs)
}

Компромисс называем честно: страдает latency — кадры приходят с задержкой до интервала опроса, — но не корректность: клиентская seq-механика делает потерю транспорта неважной, дыра закроется догоном. Поэтому fallback обязан быть «медленным и честным», а не быстрым и придумывающим состояние. После восстановления лаг группы покажет окно деградации и вернётся к нулю — по этой кривой инцидент и читается.

7.3 SLO на лаги: чем измерять «доставка жива»

Три числа, по которым видно здоровье конвейера:

  • outbox_lag — возраст старейшего pending в outbox: от транзакции messenger до публикации в шину (метрика курса 1, Модуль 11);
  • лаг consumer group реплики — это и есть задержка доставки: событие считается доставленным, когда группа реплики дочитала его и разослала своим соединениям; для notification-worker та же метрика — очередь пуше;
  • p99 WS-кадра — от публикации в шину до записи в сокет получателя (ориентир курса 1: p99 доставки < 300 мс).

На эти числа вешаются алерты: «лаг группы растёт при стабильном трафике» — реплика не успевает; «лаг дёрнулся и вернулся» — был отказ шины, pending дочитан. Подход к SLO и алертам — тот же, что в практикуме «Архитектурный полигон» (модуль про наблюдаемость и SLO): цель и burn-rate сначала, алерт — когда бюджет ошибки горит.

bash
redis-cli XINFO GROUPS messenger.events            # lag/pending по группам
redis-cli XPENDING messenger.events gateway-replica-2

7.4 Нагрузочный тест: 10k соединений на реплику

Цель — ~10k коннектов на реплику event-service. База уже есть из курса 1, Модуль 11: 20–40 KB памяти на соединение, ulimit -n 65535, лимит соединений на пользователя, sync-budget и jitter при рестарте — всё это переезжает в event-service без изменений.

Чем мерить:

  • k6 или vegeta в WS-режиме — если нужен быстрый профиль;
  • синтетический генератор на Go (в стиле loadgen полигона): N виртуальных клиентов с профилем «подключиться → sync → тишина → получать», часть клиентов сидит в группах по 30 участников и пишет — это fan-out 30.
bash
ulimit -n 65535
./loadgen -url wss://host/api/v1/ws \
  -conns 10000 -groups 300 -fanout 30 -dur 10m

Что смотреть:

  1. Память на соединение — (RSS_после − RSS_до) / коннекты; сравнить с ориентиром 20–40 KB. Рост в разы — ищем «горутину на событие» и неограниченные буферы (инцидент курса 1, Модуль 11).
  2. Файловые дескрипторы — коннект это сокет; упёрлись в ulimit — потолок реплики найден раньше, чем в память.
  3. p99 рассылки при fan-out 30 — сколько занимает разослать кадр всем получателям обычной группы.
  4. Дрейф лага группы — растёт при стабильной нагрузке: реплика не масштабируется, делите соединения на больше реплик; дёргается и возвращается — норма (ресинхронизация, GC).

💻 Практика

Хаос-меню: пять управляемых отказов. Для каждого — зафиксируйте в отчёте, что увидели клиенты (переподключение, задержка, догон) и что показали метрики (ws_connections_active, outbox_lag, лаг группы). Стенд — из предыдущих модулей; база — два устройства и шторм-скрипт из курса 1.

  1. Уронить реплику event-service (15 мин). При 100+ соединениях: docker compose kill event-2. Ожидаемо: соединения упали ровно на одну реплику, клиенты переподключились с jitter к живым репликам, догон по seq закрыл окно; клиенты других реплик не заметили.
  2. Уронить Redis на 10 секунд (20 мин). docker compose stop redis, таймер, start. Прогнать оба режима шины: pub/sub — записать окно потерянных событий и убедиться, что клиенты дочитали по seq; Streams — убедиться, что после возвращения pending дочитан, лаг группы вернулся к нулю. Это и есть цена деградации из §7.2.
  3. Уронить user-service (10 мин). docker compose stop user. Проверить: логин и профиль недоступны, при этом отправка, получение и история на живых токенах работают — валидация офлайн по кэшу JWKS.
  4. Отключить push-провайдер (10 мин). Заблокировать endpoint пушей (фейк 410/503). Ожидаемо: WS-доставка не задета, у notification-worker растут ошибки отправки и чистятся подписки, соединения и лаг реплик — в норме.
  5. Рестарт монолита (10 мин). docker compose restart messenger при живых event-репликах: соединения не рвутся, но на окно рестарта новые сообщения приходят с задержкой — outbox-воркер стоял; клиенты дочитали по seq.
  6. Нагрузочный прогон (45 мин). Сгенератором из §7.4 доведите реплику до 10k коннектов и снимите профиль: память на соединение, fd, p99 рассылки при fan-out 30, кривая лага группы. В отчёт — число «KB на соединение» и вывод: сколько коннектов держит реплика до потолка.

🧩 Quiz

1. Redis в pub/sub-режиме был недоступен 10 секунд. Что стало с событиями этого окна?
2. Перезапустили одну из трёх реплик event-service. Кто заметил?
3. Упал user-service. Что с перепиской?
4. Что обязательно смотрят при прогоне 10k коннектов на реплику?
5. Почему лаг consumer group реплики — это SLO доставки?

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

  • Назвать для каждого отказа (реплика, Redis в обоих режимах, user-service, push-провайдер) радиус поражения и механизм восстановления.
  • Объяснить busy-poll fallback и его компромисс: latency страдает, корректность держит seq-механика клиента.
  • Сформулировать SLO на outbox_lag, лаг consumer group и p99 WS-кадра — и повесить на них алерты.
  • Прогнать нагрузочный сценарий до 10k коннектов и снять профиль памяти на соединение.
  • Провести хаос-меню из пяти отказов с записью наблюдений по клиентам и метрикам.

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

Хаос-меню и SLO — это не только эксплуатация: те же проверки («рестарт без потерь», «лаг группы вернулся к нулю») — критерии приёма каждого этапа переезда. Осталось провести сам переезд: четыре сервиса, пять этапов, strangler с откатом на каждом шаге — и финальная защита с ADR.