Demetra logo/demetra
На главнуюВойти
Блог
АрхитектураПаттерны бэкенда1/21 августа 2026 г.

Паттерны бэкенда, часть 1: отказоустойчивость и консистентность

Circuit breaker, деградация, повторы, rate limiting, события, outbox, inbox, идемпотентность, DLQ и саги — на одном сервисе уведомлений, с анимациями и продакшн-кейсами

Эти паттерны спрашивают на собеседованиях на middle и senior, и отвечать определением из документации — худшее, что можно сделать. Спрашивают не «что такое circuit breaker», а «что у вас происходило, когда падал Redis»

Поэтому всё разобрано на одной системе — сервисе уведомлений. Он маленький, но в нём есть всё: входящие события, чужой API, кеш, база, очередь на отправку и воркеры по каналам. Каждая стрелка на схеме — место, где что-то отваливалось в проде

событие → профиль → история → отправка

Notification Service — на нём и разбираем

user.registeredgetListX-Api-Keyкешисториякомандаconsume
FrontendReact
User Serviceпрофили
Kafkaвходящие
Notification Serviceядро
Kafkaна отправку
Redisкеш
PostgreSQLистория
Push · SMS · Emailворкеры
1/6

Всё начинается с чужого события: какой-то сервис сообщил, что пользователь зарегистрировался. Notification Service читает его из Kafka и решает, что с этим делать

Совет

Как читать схемы

Каждая анимация проходит сценарий по шагам: видно запрос, видно отказ и видно, что именно сделал паттерн. Шаги переключаются кнопками внизу, пауза — кнопкой в шапке. У каждого паттерна сверху есть строка «проблема — решение»: если суть ясна, дальше можно листать

12 паттернов

Первая часть — про отказы и данные: как пережить падение чужого сервиса и не потерять сообщение по дороге. Порядок такой же, как раскручивается разговор на собеседовании

  • 01Circuit Breaker
  • 02Graceful degradation
  • 03Retry с экспоненциальной паузой
  • 04Rate limiting
  • 05Работа с внешним API
  • 06Event-Driven Messaging
  • 07Transactional Outbox
  • 08Transactional Inbox
  • 09Идемпотентность и Idempotency-Key
  • 10Dead Letter Queue
  • 11Pub/Sub в Redis
  • 12Saga и виды саг

1. Circuit Breaker

Проблема

Зависимость не отвечает, а сервис продолжает в неё ходить. Каждый вызов занимает соединение до таймаута — через минуту пул кончился, и падает уже всё, а не только та ручка

Решение

Считаем ошибки в окне. Больше порога — размыкаем цепь: вызовы отклоняются мгновенно, а ответ собирается из запасного источника

Самое дорогое при отказе зависимости — не сам отказ, а ваше упорство. Сто запросов в секунду по 200 мс таймаута — это двадцать занятых соединений в каждый момент. Ещё немного, и сервис отвечает пятисотками на всё подряд, включая ручки, которым Redis вообще не нужен

Брейкер ставит между вами и зависимостью счётчик. Пока ошибок мало, он прозрачен. Как только их доля переваливает порог, он перестаёт пропускать вызовы вообще — и тем самым даёт упавшему сервису подняться

closed → open → half-open

Redis отвалился, лента уведомлений живёт

GET /feedGET keyfallback
Клиентweb
Notification APINestJS
Rediscache
In-MemoryLRU 5k
1/5

Брейкер закрыт. Каждый запрос ленты идёт в Redis и возвращается за 4 мс — это обычный горячий путь

  • Closed — трафик идёт как обычно, брейкер только считает ошибки в скользящем окне
  • Open — порог пройден, вызовы отклоняются сразу, без похода в сеть
  • Half-open — по таймеру пропускается один пробный вызов: ответ есть — закрываемся, нет — окно начинается заново

Памятка

Продакшн-кейс

Лента уведомлений читается из Redis. Когда кеш недоступен, брейкер открывается и лента собирается из in-memory LRU на 5 тысяч записей. Данные отстают на минуту, зато экран открывается — вместо пустоты со спиннером

Не делай так

Две ошибки, которые убивают пользу

Брейкер без запасного пути просто меняет медленный отказ на быстрый — польза появляется только там, где за ним стоит fallback. И порог считают в долях, а не в штуках: 20 ошибок срабатывает и на тысяче запросов в секунду, и на десяти, хотя это совершенно разные ситуации

2. Graceful degradation

Проблема

Экран собран из нескольких источников, и отказ любого роняет весь ответ. Аватарки не пришли — пользователь не видит и списка уведомлений

Решение

Заранее решаем, что ядро, а что украшение. Украшение при отказе подменяется заглушкой или устаревшим значением, ядро — единственное, ради чего отдаём 503

Источники у экрана разные по важности, но в коде это обычно ничем не выражено: всё лежит в одном Promise.all, и любой reject роняет всё. История уведомлений — смысл экрана. Аватарки рядом с ними — украшение. Для Promise.all это одно и то же

Деградация — это не «постараемся, чтобы не упало». Это заранее составленный список: вот ядро, вот это можно отдать заглушкой, вот это — старым значением с честной пометкой

полный ответ → усечённый ответ

Половина зависимостей лежит, экран всё ещё работает

GET /feed
Клиентweb
Notification APIBFF
Profile svcаватарки
Countersнепрочит.
PostgreSQLистория
1/5

Экран уведомлений собирается из трёх источников: история из PostgreSQL, аватарки из профилей, счётчик непрочитанного

  • Разметьте зависимости на критичные и необязательные — до инцидента, а не во время
  • У каждого необязательного вызова свой таймаут и своё «что вернуть, если не вышло»
  • Старые данные помечаются старыми — задержку простят, молчаливое враньё нет
  • Отказ ядра — это честный 503 с понятным текстом, а не белый экран

Памятка

Продакшн-кейс

Сервис профилей отдаёт 503 — вместо фотографий рисуются инициалы, список приходит целиком. Отвалился счётчик непрочитанного — показываем последнее известное значение с пометкой, что оно двухминутной давности

Совет

Проверить разметку можно одним вопросом: «если этот сервис ляжет на час, что увидит пользователь». Если ответ «белый экран» — зависимость критичная, как бы её ни называли в документации

3. Retry с экспоненциальной паузой

Проблема

Провайдер прилёг на десять секунд — задача потеряна. А если повторять сразу и из всех подов, вы добьёте его синхронным залпом ровно в момент подъёма

Решение

Повторяем только временные ошибки, с растущей паузой и случайным разбросом. Пять попыток — и в DLQ, а не бесконечный круг

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

Опаснее другое. Внешний сервис прилёг — и повторы летят в него от всех ваших подов одновременно. Он поднимается, получает залп и падает снова. Так вы своими руками устраиваете ему DDoS в момент, когда он слабее всего

retry + exponential backoff + jitter

Три воркера повторяют отправку, не добивая провайдера

send
Email Workerpod-1
Email Workerpod-2
Email Workerpod-3
Почтовый провайдервнешний503
DLQна разбор
1/6

Провайдер прилёг и отвечает 503 всем трём воркерам. Письма не потеряны — они просто не доставлены прямо сейчас

Пауза перед попыткой n
const BASE_MS = 1_000;
const CAP_MS = 30_000;
const JITTER = 0.2;

export function backoffDelay(attempt: number): number {
  const exponential = Math.min(BASE_MS * 2 ** attempt, CAP_MS);
  const spread = exponential * JITTER;
  return Math.round(exponential - spread + Math.random() * spread * 2);
}
// 1 → ~2.0 c, 2 → ~4.0 c, 3 → ~8.0 c, 6 → 30 c (потолок)
  • Повторяем идемпотентное и только на 5xx, таймаут и обрыв соединения
  • 400, 401, 422 не повторяем — тело запроса не изменится, а нагрузку вы создадите
  • Джиттер обязателен — без него весь кластер просыпается в одну миллисекунду
  • У повторов есть потолок паузы и предел попыток, дальше — DLQ

Памятка

Продакшн-кейс

Воркер писем повторяет отправку пять раз с паузами от секунды до тридцати. Что не ушло за пять попыток, уезжает в отдельный топик с причиной — там уже видно, это сломанный адрес или провайдер лежит второй час

Важно

Считайте общее время, а не число попыток

Три попытки по три секунды — это девять секунд ожидания для человека, который смотрит на спиннер. У запроса должен быть общий бюджет времени: кончился бюджет — прекращаем повторы, даже если попытки ещё остались

4. Rate limiting

Проблема

Все предыдущие паттерны — про то, как пережить чужой отказ. Этот про обратное: как не дать положить себя. Один скрипт в цикле выбирает всю ёмкость, и сервис ложится для всех остальных

Решение

Счётчик на входе: сколько запросов в единицу времени можно этому клиенту. Лишнее отбивается кодом 429, не доходя ни до базы, ни до внешних провайдеров

Лимит — это не защита от злоумышленников, а защита от любой неравномерности. Забытый setInterval в чужом коде, ретрай без бэкоффа, выгрузка отчёта в цикле — все они выглядят как атака и лечатся одинаково

Классический алгоритм — token bucket: ведро с токенами, которое наполняется с постоянной скоростью. Короткий всплеск проходит за счёт накопленного, длинный поток упирается в скорость наполнения. Это честнее фиксированного окна, где на границе двух окон можно пропихнуть двойную норму

токены → 429 → ключ вместо IP

Всплеск упирается в ведро, а не в базу

пропущено
Пользовательобычный
Скриптвсплеск
Лимитерtoken bucket20 / 20 токенов
Notification APIзащищён
1/6

В ведре 20 токенов, оно наполняется со скоростью 10 в секунду. Каждый запрос забирает один токен — так короткий всплеск проходит, а долгий поток упирается в скорость наполнения

  • Считаем по владельцу — userId или ключ API, а не по IP
  • Отвечаем 429 и Retry-After — клиент должен понимать, когда возвращаться
  • Лимит хранится в Redis, иначе у каждой реплики будет свой, и общий окажется втрое больше
  • Отказ должен быть дешёвым — проверка идёт до похода в базу, иначе лимит сам станет нагрузкой
  • Разные ручки — разные лимиты: выгрузка отчёта и чтение ленты не могут стоить одинаково

Не делай так

Продакшн-кейс: лимит, который считал не то

Прокси-слой между браузером и бэкендом не пробрасывал адрес клиента, и все запросы приходили с одного адреса — его собственного. Лимит «по IP» работал идеально, просто ведро было общим на всю платформу: 46 живых пользователей делили квоту одного. Симптом выглядел как «сервис тормозит у части людей», а причина была в одной недостающей строке проброса заголовка

Важно

IP — плохой идентификатор

За одним адресом сидит офис, мобильный оператор или ваш собственный прокси. За одним пользователем — десяток адресов, если он в метро. Лимит по IP годится только там, где владельца ещё нет: логин, регистрация, восстановление пароля

5. Работа с внешним API

Проблема

У чужого сервиса нет ни ваших логов, ни ваших метрик, ни кнопки «перезапустить». А зависший вызов держит ваше соединение столько, сколько захочет он

Решение

Четыре слоя вокруг каждого вызова: таймаут, повтор, брейкер и отдельный пул. Каждый закрывает то, что не закрывает предыдущий

Единственное, чем вы управляете в интеграции, — как именно вы туда ходите. Три предыдущих паттерна работают здесь вместе, и порядок имеет значение: таймаут ограничивает одну попытку, повтор даёт второй шанс, брейкер прекращает попытки целиком, когда стало ясно, что шанса нет

таймаут → ретрай → брейкер → изоляция

Чужой API, который может зависнуть навсегда

POST /notifyHTTPHTTP
Пользовательждёт
Notification APIпул 32пул 32 / 32
Push-провайдерFCMвисит
SMS-провайдервнешний
1/5

HTTP-клиент по умолчанию ждёт ответа бесконечно. Провайдер не отвечает — и через минуту весь пул из 32 соединений занят висящими запросами, а сервис не принимает ничего

  1. Таймаут на соединение и отдельно на чтение — по умолчанию HTTP-клиенты ждут вечно
  2. Повтор с паузой и джиттером, только для безопасных операций
  3. Брейкер по каждому провайдеру отдельно, а не один на все интеграции
  4. Свой пул соединений на провайдера, чтобы медленный не съел воркеры остальных
  5. Метрики в разрезе провайдера: p99, доля ошибок, состояние брейкера

Памятка

Продакшн-кейс

У пуш-провайдера и SMS-шлюза разные пулы и разные брейкеры. Когда пуши начинают отваливаться, сообщение уходит по SMS — а не встаёт в общую очередь и не блокирует отправку тем, у кого канал живой

Не делай так

Грабли

Один общий HTTP-клиент на все интеграции — это одна общая точка отказа. Зависший платёжный шлюз в такой схеме останавливает и отправку уведомлений, хотя между ними нет ничего общего, кроме библиотеки

6. Event-Driven Messaging

Проблема

Регистрация вызывает нотификации напрямую: знает про них, ждёт их ответа и падает вместе с ними. Добавили аналитику — правим регистрацию. Добавили антифрод — правим снова

Решение

Продюсер публикует факт и забывает. Кто это читает, сколько их и живы ли они — не его дело и не его релиз

Синхронный вызов связывает два сервиса жёстче, чем кажется: он тащит за собой и знание об адресате, и его аптайм, и его скорость. Событие переворачивает зависимость — теперь потребитель знает про продюсера, а не наоборот

Событие описывает то, что уже случилось, в прошедшем времени: user.registered, а не send_welcome_email. Разница не косметическая — команда подразумевает конкретного исполнителя, факт не подразумевает никого

publish → fan-out → независимые потребители

X Service не знает, кто читает его события

publishoffset 41 902offset 41 902
X Serviceproducer
Kafkauser.registered
Notificationgroup: notify
Analyticsgroup: dwh
1/6

X Service публикует факт: «пользователь зарегистрировался». Он не вызывает нотификации, не знает про них и не падает, если их вообще нет в кластере

  • У каждого потребителя своя consumer-группа, свой оффсет и своя скорость
  • Потребитель лёг — события копятся в топике, а не теряются в воздухе
  • Новый потребитель подключается без единой правки у продюсера
  • Порядок гарантирован только внутри партиции — ключ партиционирования выбирается осознанно

Памятка

Продакшн-кейс

Notification Service слушает user.registered и шлёт приветственное письмо. Аналитика слушает то же событие и строит воронку. Когда нотификации лежали 5 минут после релиза, письма ушли с задержкой — но ушли все, потому что события ждали в топике

Важно

Где события не подходят

Всё, где пользователь ждёт ответа прямо сейчас, остаётся синхронным. «Хватит ли денег на счёте» — вызов с ответом. «Отправить письмо о списании» — событие. Асинхронность берут за деньги: вы платите отладкой, трассировкой и невозможностью просто прочитать стектрейс

7. Transactional Outbox

Проблема

Запись в базу и отправка события в брокер — две разные транзакции. Упали между ними: либо данные без события, либо событие без данных

Решение

Событие пишется строкой в ту же транзакцию, что и данные. Отдельный процесс потом вычитывает эти строки и публикует их

Код выглядит невинно: сохранили уведомление, отправили событие. Но между COMMIT и publish нет ничего, что связывало бы их вместе — это два разных хранилища с двумя разными механизмами отката

Дальше сценарий на выбор. Процесс умер после коммита — данные есть, события нет, письмо не уйдёт никогда. Событие ушло, а транзакция откатилась — письмо про платёж, которого не было. Оба случая всплывают редко и разбираются мучительно

один COMMIT → publisher → mark sent

Запись в базу и событие в Kafka не расходятся

INSERT + outboxSELECT unsentpublishmark sent
Notificationhandlerдва разных хранилища
PostgreSQLодна tx
Publisherpoll 200ms
Kafkatopic
1/6

Наивный код пишет строку в базу, а следом шлёт событие в Kafka. Между этими двумя действиями нет транзакции: упали после COMMIT — событие не ушло, упали после publish и откатили — событие есть, а данных нет

Одна транзакция на данные и на событие
await db.transaction().execute(async trx => {
  await trx.insertInto('notifications').values(notification).execute();

  await trx
    .insertInto('outbox')
    .values({
      aggregate_id: notification.id,
      topic: 'notification.created',
      payload: JSON.stringify(payload),
    })
    .execute();
});
// Kafka здесь нет. Публикует отдельный процесс — из того, что уже закоммичено
  • Данные и строка outbox пишутся одним COMMIT — атомарность обеспечивает база
  • Publisher читает неотправленные строки с FOR UPDATE SKIP LOCKED, поэтому его можно масштабировать
  • После публикации строка помечается отправленной
  • Падение между publish и отметкой даёт повтор — это at-least-once по определению, и потребитель обязан быть к нему готов

Памятка

Продакшн-кейс

Уведомление и запись в outbox создаются одной транзакцией. Publisher с интервалом 200 мс разбирает очередь и публикует в Kafka. За счёт SKIP LOCKED три экземпляра publisher работают параллельно и не дерутся за одни и те же строки

Совет

Поллинг можно заменить чтением WAL через Debezium — база разгружается, но в поддержке появляется ещё один компонент со своим состоянием. До нескольких тысяч событий в секунду поллинг честно выигрывает по простоте

8. Transactional Inbox

Проблема

Брокер гарантирует at-least-once, то есть дубли будут. Обработали событие дважды — пользователь получил два одинаковых пуша, а клиент два списания

Решение

Идентификатор сообщения пишется в таблицу с уникальным индексом — в той же транзакции, что и эффект. Повтор отбивается конфликтом

Outbox гарантирует, что событие уйдёт хотя бы раз. Ключевое слово — «хотя бы». Ребаланс группы, повтор publisher, переподключение консьюмера: одно и то же сообщение приезжает второй раз, и это штатная работа брокера, а не сбой

Inbox — зеркальная половина outbox. Не «на всякий случай», а обязательное условие: при at-least-once дубли гарантированы, вопрос только в том, заметите вы их или пользователь

дубль → уникальный индекс → пропуск

Событие пришло дважды, пуш ушёл один раз

pollINSERT idsend
Kafkaat-least-once
Notificationconsumer
inboxmessage_idINSERT ok
Pushдоставка
1/6

Приходит событие «начислен платёж». Сервис вставляет его message_id в таблицу inbox — в той же транзакции, что и сама обработка

Проверка и обработка в одной транзакции
await db.transaction().execute(async trx => {
  const inserted = await trx
    .insertInto('inbox_messages')
    .values({ message_id: message.id, consumer: 'notification' })
    .onConflict(oc => oc.columns(['message_id', 'consumer']).doNothing())
    .executeTakeFirst();

  // Строка уже была — событие обработано раньше, второй раз не отправляем
  if (Number(inserted.numInsertedOrUpdatedRows ?? 0) === 0) return;

  await sendPush(trx, message.payload);
});
  • Уникальный индекс по (message_id, consumer) — дедупликация лежит на базе, а не на коде
  • Проверка и сам эффект живут в одной транзакции, иначе между ними снова появляется щель
  • Оффсет коммитится и для дубликата — он обработан корректно, то есть осознанно пропущен
  • Записи чистятся по расписанию, иначе таблица растёт вечно

Памятка

Продакшн-кейс

После ребаланса группы событие о платеже приехало дважды. Уникальный индекс отбил вставку, обработка не запустилась — пользователь получил один пуш вместо двух. В логах это видно как duplicate key, и это не ошибка, а сработавшая защита

Совет

Иногда inbox не нужен: если операция идемпотентна сама по себе — UPDATE ... SET status = paid или upsert по ключу, — повтор просто перезапишет тот же результат. Inbox нужен там, где эффект внешний и необратимый: отправка письма, списание, вызов чужого API

9. Идемпотентность и Idempotency-Key

Проблема

Ответ на POST /payments не доехал до клиента. Клиент не знает, случилось списание или нет, и повторяет запрос. Наивный обработчик спишет второй раз

Решение

Клиент присылает ключ попытки. Первый запрос выполняется и сохраняет свой ответ под этим ключом, повтор получает тот же ответ — без повторного эффекта

Три предыдущих паттерна опираются на это слово: retry повторяет, outbox доставляет минимум один раз, inbox отбивает дубли на стороне потребителя. Но у публичного API нет ни inbox, ни оффсетов — там повтор приходит от живого клиента, и защищаться надо иначе

Идемпотентность — это свойство операции: повторный вызов не меняет результат. GET и DELETE идемпотентны сами по себе, PUT обычно тоже. POST — нет, и именно ему нужен ключ

ключ → сохранённый ответ → 409

Клиент повторил запрос — списание осталось одно

POST + ключключ?списать
Клиентмобильный
Payments APIобработчик
Ключиответ + хешне найден
PostgreSQLсписание
1/6

Клиент присылает Idempotency-Key — случайный идентификатор попытки, а не платежа. Такого ключа ещё нет, значит, операция новая

Ключ, ответ и хеш запроса — одной транзакцией
const existing = await keys.find(idempotencyKey, userId);

if (existing) {
  // Тот же ключ с другим телом — это ошибка клиента, а не повтор
  if (existing.requestHash !== hashOf(body)) throw new ConflictException();
  return existing.response;
}

return db.transaction().execute(async trx => {
  const payment = await charge(trx, body);
  const response = { id: payment.id, status: payment.status };

  await trx
    .insertInto('idempotency_keys')
    .values({ key: idempotencyKey, user_id: userId, request_hash: hashOf(body), response })
    .execute();

  return response;
});
  • Ключ генерирует клиент — это идентификатор попытки, а не платежа
  • Хранится сам ответ, а не только факт «ключ был»: повтору надо что-то вернуть
  • Хеш запроса ловит тот же ключ с другим телом и отдаёт 409
  • Ключ скоуплен на владельца, иначе чужой запрос с угаданным ключом получит ваш ответ
  • У ключа есть TTL — суток хватает на любые сетевые повторы

Памятка

Продакшн-кейс

Загрузка записи собеседования идёт с ключом идемпотентности, привязанным к пользователю. Повтор с тем же ключом возвращает исходный результат, чужой ключ отдаёт 409, а от гонки двух параллельных запросов защищает частичный уникальный индекс — приложение может проверить и не успеть, база не может

Совет

Иногда ключ не нужен: если у операции есть естественный идентификатор от клиента — номер заказа, externalId вакансии, — достаточно уникального индекса по нему и ON CONFLICT DO NOTHING. Ключ нужен там, где естественного идентификатора нет

10. Dead Letter Queue

Проблема

Одно битое сообщение не обработается никогда, а консьюмер падает на нём снова и снова. Оффсет не двигается — вся партиция стоит за ним в очереди

Решение

После N попыток сообщение уезжает в отдельный топик вместе с причиной. Очередь разблокирована, сообщение сохранено для разбора

Такое сообщение называют ядовитым: пришло без обязательного поля, схема разъехалась, данные битые. Повторы его не вылечат — оно не станет валидным от того, что вы попробуете ещё раз

Поэтому бесконечный ретрай здесь — это не устойчивость, а вечная остановка партиции. Лечится одним способом: убрать сообщение с дороги, но не потерять

retry → DLT → алерт → переигровка

Одно битое сообщение больше не блокирует партицию

partition 3после 3 попытокметрикаreplay
Kafkanotificationsочередь встала
SMS Workerconsumerошибка парсинга
DLTnotif.DLT
Алертдежурному
1/6

В партиции лежит сообщение без номера телефона. Обработчик падает на нём, оффсет не двигается — и все следующие сообщения этой партиции стоят за ним в очереди

  • N попыток с бэкоффом, потом перенос в topic.DLT
  • Вместе с телом переносятся причина, стектрейс, исходный топик, партиция и оффсет
  • Счётчик сообщений в DLT — обязательная метрика, а не приятное дополнение
  • Алерт на первое же сообщение — в норме очередь пуста
  • После починки сообщения переигрываются обратно в основной топик

Памятка

Продакшн-кейс

SMS-воркер спотыкался на сообщениях без номера телефона. Три попытки, перенос в notifications.DLT, алерт дежурному через минуту. Партиция разблокирована, отправка остальным идёт как обычно, а битые сообщения ждут разбора

Не делай так

Грабли

DLQ без алерта — это молчаливая свалка. Сообщения тихо копятся неделями, никто их не смотрит, и обнаруживается это от пользователя, который третий месяц не получает уведомления. Если на очередь нет метрики и правила «больше нуля — буди», значит, вы не отложили проблему, а спрятали её

11. Pub/Sub в Redis

Проблема

WebSocket пользователя открыт к одному инстансу, а событие для него обработал другой. Написать в чужой сокет он физически не может

Решение

Инстанс публикует сообщение в канал Redis, его получают все, доставляет тот, у кого есть соединение. Карту «кто где» держать не нужно

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

PUBLISH → все инстансы → доставляет один

Сокет пользователя висит на другом инстансе

consumePUBLISH user:42SUBSCRIBESUBSCRIBE
Событиеиз Kafka
API #10 сокетовсокета нет
Redischannel
API #2сокет юзера
API #30 сокетов
1/6

Событие для пользователя 42 обработал инстанс API #1. Только вот WebSocket этого пользователя открыт к API #2 — инстанс с событием физически не может ему написать

  • PUBLISH доставляется только тем, кто подписан прямо сейчас
  • Подписчик был отключён — сообщение для него не существовало, повтора не будет
  • Ни очереди, ни оффсетов, ни подтверждений — доставка не гарантирована в принципе
  • Зато задержка в миллисекундах и никакой отдельной инфраструктуры

Памятка

Продакшн-кейс

Колокольчик с непрочитанными обновляется через канал user:{id}. Событие приходит на любой инстанс, публикуется в Redis, доставляет тот, где висит сокет. Само уведомление при этом уже лежит в PostgreSQL — если сообщение потеряется, пользователь увидит его при следующем открытии экрана

Важно

Где проходит граница

Pub/Sub годится для того, что не жалко потерять: живые счётчики, presence, инвалидация кеша. Всё, что обязано дойти, идёт через Kafka или Redis Streams с подтверждением. Признак ошибки в проектировании простой: если для Pub/Sub понадобилось «а если не дошло, то повторить» — вы выбрали не тот инструмент

12. Saga и виды саг

Проблема

Оформление подписки трогает три сервиса, у каждого своя база. Общей транзакции между ними не существует — списали деньги, а доступ не открылся

Решение

Цепочка локальных транзакций, у каждой есть компенсация. Отказ на последнем шаге разворачивает предыдущие обратными действиями

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

Компенсация — это не rollback. Откатывать уже нечего: чужой сервис давно закоммитил своё. Это новое бизнес-действие, обратное по смыслу — возврат вместо отмены списания

шаги вперёд → компенсации назад

Оплата прошла, письмо не ушло — откатываем по шагам

chargeactivatesend
Оркестраторsaga
Billingсписаниесписано 1 490 ₽
Subscriptionдоступ
Notificationписьмо
1/6

Распределённой транзакции на три сервиса не существует, поэтому сага делает шаги по очереди. Первый — списать деньги, он подтверждён

Оркестрация

Отдельный компонент знает весь сценарий, вызывает шаги по очереди и хранит состояние саги. Плюс — сценарий целиком читается в одном файле, и по состоянию видно, где именно всё встало. Минус — оркестратор становится центром, от которого зависят все, и его отказ останавливает процесс

Хореография

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

Памятка

Продакшн-кейс

Деньги списались, доступ открылся, письмо не ушло — адрес не существует, повторы не помогут. Сага пошла назад: закрыла доступ, вернула деньги. В выписке остались обе операции, списание и возврат, и это правильно: в реальном мире событие уже произошло, его можно только уравновесить

Важно

Компенсация тоже падает

Возврат денег может не пройти с первого раза. Компенсациям нужны повторы, идемпотентность и предел попыток, после которого поднимается человек. Сага, у которой компенсация «просто вызывается», разваливается на первом же таймауте платёжного шлюза

Совет

Порядок шагов выбирается так, чтобы необратимое было последним. Сначала то, что можно отменить, — резерв, блокировка средств; в конце то, что отменить нельзя, — отправка письма или физическая отгрузка


Что дальше

Двенадцать паттернов выше отвечают на один вопрос: как система переживает отказы и не теряет данные. Это половина разговора на собеседовании

Вторая половина — про то, как всё это живёт в кластере: как сервисы находят друг друга, как выкатываются без потерь, как за ними смотреть и как менять контракты, ничего не сломав

СсылкаЧасть 2. Эксплуатация: кластер, наблюдаемость, измененияService discovery, sidecar, health check, graceful shutdown, распределённые локи, API-ключи, фича-флаги, контракты, метрики и трассировка
/demetra
Для КандидатаСтоимостьСтатьиДля Ментора

Политика обработки ПДнПубличная офертаКонтактыТех. поддержка

© 2026 ИП Пастушенко Д. С. — Все права защищены. При цитировании информации с сайта ИП Пастушенко Д. С. ссылка на первоисточник обязательна.