Урок 07.03: Интеграционные паттерны и системная архитектура
Цель урока
Изучить фундаментальные паттерны интеграции систем, с которыми системный аналитик сталкивается ежедневно: синхронные и асинхронные взаимодействия, гарантии доставки, проблемы упорядочивания сообщений, паттерны отказоустойчивости и распределённых транзакций. После урока вы сможете грамотно формулировать нефункциональные требования к интеграции и выбирать правильный паттерн под задачу.
Ключевые понятия
- Синхронная интеграция — клиент ждёт ответа сервера (REST, gRPC, SOAP)
- Асинхронная интеграция — клиент отправляет сообщение и не ждёт ответа (Kafka, RabbitMQ, SQS)
- Message Broker — посредник, который принимает, хранит и доставляет сообщения
- Event-Driven Architecture (EDA) — архитектура, управляемая событиями
- Гарантии доставки — At-most-once, At-least-once, Exactly-once
- Партиционирование — механизм распределения сообщений по разделам (partitions) в Kafka
- Saga — паттерн распределённой транзакции с компенсациями
- CQRS — Command Query Responsibility Segregation (разделение команд и запросов)
- Event Sourcing — хранение всех событий вместо текущего состояния
- API Gateway — единая точка входа для всех клиентов
- Idempotency — способность повторного запроса давать тот же результат
- Circuit Breaker — «предохранитель», отключающий вызовы при сбоях
- Retry — повторная попытка при временной ошибке
1. Фундамент: синхронная vs асинхронная интеграция
1.1. Базовое сравнение
| Аспект | Синхронная | Асинхронная |
|---|---|---|
| Ожидание ответа | Да, клиент блокируется | Нет, клиент продолжает работу |
| Связь | Request → Response (1:1) | Event → 0..N получателей |
| Производительность клиента | Блокируется на время запроса | Не блокируется |
| Надёжность | Если сервер упал — ошибка | Сообщение сохраняется в очереди |
| Идемпотентность | Естественная для GET | Требует явной реализации |
| Трассировка | Проще (один запрос → один ответ) | Сложнее (нужен Correlation ID) |
| Сложность отладки | Ниже (можно прочитать ответ) | Выше (нужно читать логи брокера) |
| Пример | REST: GET /users/42 |
Kafka: событие user.created |
| Когда выбирать | Нужен немедленный ответ | Нужна надёжная доставка, слабая связанность |
1.2. Анатомия синхронного вызова
Клиент Сервер
│ │
│─── HTTP Request ─────────────→│
│ │── Обработка запроса
│ │ (может быть 5 мс или 30 с)
│←─── HTTP Response ───────────│
│ │
Время жизни клиента:
═══ блокировка ════╗
║
Другие операции ───╝ (после ответа)
Проблема каскадного ожидания (Cascade Waiting): Если сервис A синхронно вызывает B, B вызывает C, а C зависает — A, B и C блокируют потоки. При 100 параллельных запросах к A, каждый из которых ждёт C 30 секунд, пул потоков A исчерпывается за секунды. Система падает.
Для аналитика: В требованиях к синхронной интеграции всегда указывайте timeout (максимальное время ожидания ответа) и стратегию при превышении тайм-аута (вернуть fallback-ответ, вернуть ошибку, повторить).
1.3. Асинхронная интеграция: внутреннее устройство
Producer Broker Consumer(s)
│ │ │
│─── Publish(topic, msg) ──→│ │
│ │── Store message ─────────│
│ │ (на диск / в памяти) │
│ (не ждёт!) │ │
│ │── Deliver ──────────────→│
│ │←── Ack (подтверждение) ──│
│ │ │
│ │ (если Ack не получен │
│ │ → redelivery) │
Ключевое отличие: Producer не ждёт Consumer. Сообщение живёт в брокере независимо от того, работает ли Consumer прямо сейчас.
1.4. Алгоритм выбора типа интеграции
┌─────────────────────────────────────┐
│ Клиенту нужен ответ немедленно? │
│ Да ──→ Синхронная (REST/gRPC) │
│ Нет ─→ ┌──────────────────────┐ │
│ │ Возможна потеря? │ │
│ │ Да → Async без ACK │ │
│ │ Нет → Async с ACK │ │
│ └──────────────────────┘ │
└─────────────────────────────────────┘
2. Тёмная сторона асинхронного взаимодействия: гарантии доставки
Асинхронная интеграция — не «волшебная таблетка». Каждый уровень (брокер, сеть, потребитель) вносит неопределённость. Главный вопрос системного аналитика: «Сколько раз сообщение может быть доставлено?»
2.1. Три уровня гарантий
At-most-once (Максимум один раз)
Как работает: Брокер отправляет сообщение ровно один раз. Если Consumer не подтвердил получение (сбой сети, падение сервиса), сообщение теряется.
Producer → Broker ──→ Consumer (упал) → сообщение потеряно
Где используется:
- Мониторинг и метрики (потеря одного замера температуры не критична)
- Логи (если одно событие потеряно — не страшно)
- Уведомления в реальном времени (чат, видеозвонки)
Риски: Потерянные заказы, неотправленные уведомления, пропущенные транзакции.
Для аналитика: Используйте только когда потеря сообщения допустима бизнесом. В требованиях пишите: «Допускается потеря не более X% сообщений за период».
At-least-once (Как минимум один раз) — САМЫЙ РАСПРОСТРАНЁННЫЙ
Как работает: Consumer подтверждает получение (ACK). Если ACK не получен — брокер переотправляет сообщение.
Producer → Broker ──→ Consumer (получил)
──→ Consumer (ACK потерян → переотправка)
──→ Consumer (обработал повторно)
Последствия: Сообщение может быть обработано два и более раз.
Где используется:
- Платежи (лучше дважды проверить, чем потерять платёж)
- Уведомления (два письма лучше, чем ноль)
- Большинство систем по умолчанию
Риски: Дубликат заказа, двойное списание, два письма.
Для аналитика: При At-least-once обязательно требуется идемпотентность обработчика. В требованиях: «Сервис-потребитель должен гарантировать идемпотентность обработки сообщений: повторная обработка сообщения с тем же ID не должна менять состояние системы».
Exactly-once (Ровно один раз) — МИФ И РЕАЛЬНОСТЬ
Теория: Каждое сообщение доставляется ровно один раз. Ни потери, ни дубликатов.
Практика: Exactly-once невозможен на уровне протокола в распределённой системе — это следует из Теоремы FLP (Fischer, Lynch, Paterson, 1985): в асинхронной распределённой системе невозможно гарантировать консенсус при наличии хотя бы одного сбойного узла.
Как эмулируется:
- Сообщение доставляется с гарантией At-least-once
- Обработчик реализует идемпотентность
- Результат — логическая exactly-once (effectively-once)
Схема effectively-once:
Получено сообщение (At-least-once)
│
▼
┌─────────────┐
│ Проверить ID │──→ Уже обработан → пропустить
│ в БД / кэше │
└──────┬──────┘
│ ID новый
▼
┌─────────────┐
│ Выполнить │
│ бизнес- │
│ операцию │
└──────┬──────┘
│
▼
┌─────────────┐
│ Сохранить ID │
│ + результат │
└─────────────┘
│
▼
Подтвердить ACK
Где используется:
- Финансовые транзакции
- Бронирования
- Критичные бизнес-операции
Для аналитика: Не требуйте «гарантированной exactly-once доставки» от брокера — это технически некорректно. Требуйте «effectively-once» = At-least-once + идемпотентность.
2.2. Таблица гарантий доставки
| Гарантия | Потеря сообщения | Дубликат | Когда использовать |
|---|---|---|---|
| At-most-once | Да | Нет | Мониторинг, логи, неважные события |
| At-least-once | Нет | Да | Большинство бизнес-сценариев |
| Exactly-once (effectively) | Нет | Нет | Финансы, бронирования, критические транзакции |
| Exactly-once (теоретический) | — | — | Невозможен в распределённых системах |
2.3. Практика: как аналитик специфицирует гарантии доставки
Пример нефункционального требования (NFR):
Req-ID: NFR-INT-001
Заголовок: Гарантии доставки событий домена
Описание: Все события домена (task.created, task.status.changed)
должны доставляться с гарантией At-least-once. Сервисы-
потребители обязаны реализовать идемпотентность по полю
eventId (UUID).
Критерий приёмки: При повторной доставке события task.status.changed
с тем же eventId статус задачи не изменяется повторно.
3. Проблема нарушения порядка сообщений
3.1. Как сообщения теряют порядок
Представьте: пользователь создаёт задачу, а потом сразу её удаляет.
Правильный порядок: [task.created] → [task.deleted]
Реальный порядок: [task.deleted] → [task.created]
Почему это происходит:
Producer ──→ Сообщение 1 (task.created) ──→ Partition 0
──→ Сообщение 2 (task.deleted) ──→ Partition 1 (ошибка!)
Consumer читает Partition 1 раньше Partition 0
→ Удаление задачи, которая ещё не создана
→ Бизнес-ошибка
Причины нарушения порядка:
| Причина | Описание | Пример |
|---|---|---|
| Разные партиции | Сообщения по одной сущности попали в разные партиции | task.created в P0, task.deleted в P1 |
| Сетевые задержки | Второе сообщение «обогнало» первое из-за сети | Ретрай первого сообщения задержал его |
| Ретраи | Первое сообщение не доставилось → повтор → пришло после второго | Сбой сети при первой отправке |
| Разные продюсеры | Два экземпляра сервиса отправляют события | Асинхронный вызов из разных нод |
| Consumer-ребаланс | Перераспределение партиций между consumer-ами | Нарушение порядка чтения на время ребаланса |
3.2. Как Kafka решает (и не решает) проблему порядка
Гарантия Kafka:
- Внутри одной партиции порядок строго гарантирован — сообщения читаются в том же порядке, в котором были записаны.
- Между партициями порядка нет.
┌─────────┐ ┌──────────────────┐
│ Producer │ │ Kafka │
│ (Order │────→│ Partition 0: [1][2][3] │──→ Consumer (порядок есть)
│ Service)│ │ Partition 1: [4][5][6] │──→ Consumer (порядок есть)
│ │ │ Partition 2: [7][8][9] │──→ Consumer (порядок есть)
└─────────┘ └──────────────────┘
Но: 3 может быть прочитано раньше 4 — порядок между партициями НЕ ГАРАНТИРОВАН
3.3. Ключ партиционирования: что должен спроектировать аналитик
Золотое правило: Все сообщения об одной бизнес-сущности должны попадать в одну партицию.
Как это работает:
partition = hash(partitionKey) % numberOfPartitions
Правильный ключ = идентификатор сущности:
| Сущность | Ключ партиционирования | Почему |
|---|---|---|
| Задача | taskId (UUID задачи) |
Все события по задаче — в одну партицию |
| Пользователь | userId |
Все события пользователя — последовательно |
| Заказ | orderId |
Создание → Оплата → Доставка — в порядке |
| Клиент в CRM | clientId |
Изменения контакта — строго по порядку |
Пример правильной схемы для Task Manager:
События по задаче task_42:
┌────────────────────────────────────────────┐
│ Partition 3: │
│ [task.created {taskId: 42}] │
│ [task.status.changed {taskId: 42, to: "Исполняется"}] │
│ [task.assigned {taskId: 42, user: "Иван"}] │
│ [task.commented {taskId: 42}] │
│ [task.deleted {taskId: 42}] │
└────────────────────────────────────────────┘
Все 5 событий — в одной партиции → строгий порядок
3.4. Частые ошибки аналитика при проектировании ключей
Ошибка 1: Ключ = тип события
producer.send(new ProducerRecord<>("task-events",
event.getType(), // "created", "deleted" — НЕПРАВИЛЬНО!
event));
Последствия: Все created — в Partition 0, все deleted — в Partition 1. deleted может прийти раньше created. Катастрофа.
Ошибка 2: Ключ = случайный UUID
producer.send(new ProducerRecord<>("task-events",
UUID.randomUUID().toString(), // НЕПРАВИЛЬНО!
event));
Последствия: Каждое сообщение — в случайную партицию. Порядок не гарантируется никогда.
Ошибка 3: Ключ = пустая строка (null key)
producer.send(new ProducerRecord<>("task-events",
null, // НЕПРАВИЛЬНО!
event));
Последствия: Kafka распределяет null-key сообщения по Round-Robin — все партиции, никакого порядка.
Ошибка 4: Изменение ключа в середине жизненного цикла
// Создание задачи — ключ taskId
// Переназначение исполнителя — ключ userId (ОШИБКА!)
Последствия: Событие assigned может уйти в другую партицию. Consumer увидит уведомление до создания задачи.
3.5. Кризисный кейс: «Исчезающие заказы»
Реальная история из индустрии:
Интернет-магазин перешёл на микросервисы. При создании заказа Order Service отправляет события в Kafka: order.created, payment.processed, order.confirmed.
Было (проблема):
Producer: key = randomUUID()
→ order.created {id: 1001} → Partition 5 (Consumer: склад)
→ payment.processed {id: 1001} → Partition 2 (Consumer: склад)
→ order.confirmed {id: 1001} → Partition 9 (Consumer: склад)
Результат: Склад получил order.confirmed раньше order.created. Заказ «зарезервирован, но не создан». Система склада упала. 15% заказов не доехали до клиентов.
Стало (решение):
Producer: key = orderId (1001)
→ order.created {id: 1001} → Partition 3
→ payment.processed {id: 1001} → Partition 3
→ order.confirmed {id: 1001} → Partition 3
Consumer видит все три события строго по порядку. Проблема решена.
Для аналитика: В требованиях к асинхронной интеграции всегда указывайте ключ партиционирования. Шаблон: «Сообщения о сущности {EntityName} должны публиковаться с ключом {entityId}. Гарантируется строгий порядок обработки сообщений в пределах одной сущности».
3.6. Исключения: когда порядок неважен
Есть сценарии, где порядок не имеет значения. Аналитик должен их распознавать:
| Сценарий | Порядок нужен? | Почему |
|---|---|---|
| Уведомление о новой задаче | ❌ | Каждое уведомление самодостаточно |
| Обновление аналитики | ❌ | Счётчики можно пересчитать |
| Индексация в поиске | ❌ | Поисковый индекс можно обновлять в любом порядке |
| Синхронизация с CRM (полная) | ❌ | Каждое сообщение содержит полное состояние |
| Изменение статуса заказа | ✅ | Статусы строго последовательны |
| Применение скидки к заказу | ✅ | Скидка должна применяться после создания заказа |
| Обновление баланса счёта | ✅ | Каждая операция зависит от предыдущей |
Для неважного порядка можно использовать:
- null key (round-robin) — равномерное распределение
- Ключ = тип события (если важно только для подмножества)
- Случайный ключ
4. Паттерны коммуникации: очередь и Pub-Sub
4.1. Message Queue (Очередь сообщений) — точка-точка
Как работает:
┌──────────┐
Producer ───────→│ Queue │──→ Consumer A (получает сообщение)
│ (FIFO) │ Consumer B (ждёт следующее)
└──────────┘
Характеристики:
- Одно сообщение → ровно один потребитель (competitive consumption)
- FIFO — порядок гарантирован внутри очереди
- После чтения и ACK — сообщение удаляется
- Если Consumer упал — сообщение переходит другому
Аналогия: Касса в супермаркете. Покупатели (сообщения) выстраиваются в очередь. Каждого обслуживает один кассир (Consumer). Если кассир ушёл на обед — покупатель ждёт следующего.
Пример использования: SendTask для Task Manager
Task Service → Queue: "send-email" → Notification Service
┌─────────┐
Задача 42 │ Queue │──→ Email Worker 1: читает "Задача 42"
создана │ │ Email Worker 2: ждёт
└─────────┘
Когда выбирать:
- Задача должна быть выполнена ровно один раз (один email)
- Нужна балансировка нагрузки между воркерами
- Важен порядок обработки
4.2. Event Bus / Pub-Sub (Publisher-Subscriber)
Как работает:
┌──────────────┐
Producer ───────→│ Topic │──→ Consumer A (Task Service)
│ (Pub-Sub) │──→ Consumer B (Notification Service)
│ │──→ Consumer C (Analytics Service)
└──────────────┘
Характеристики:
- Одно сообщение → N потребителей
- Паблишер НЕ ЗНАЕТ, кто подписан (loose coupling)
- Событие = факт свершившийся, а не команда
- Каждый Consumer читает все сообщения независимо
Аналогия: Радиостанция. Ведущий (Producer) говорит в эфир. Радиослушатели (Consumers) включают приёмник. Ведущий не знает, сколько слушателей его слушают. Если слушатель опоздал — он пропустил новость (если нет replay).
Пример: событие task.created в Task Manager
Task Service → Topic: "task.events"
│
├──→ Notification Service: отправить email
├──→ Analytics Service: записать метрику
├──→ Audit Service: записать в лог аудита
└──→ Search Service: обновить поисковый индекс
Когда выбирать:
- Одно событие нужно нескольким сервисам
- Слабая связанность (добавление нового подписчика без изменения отправителя)
- Аудит и аналитика (каждый сервис реагирует по-своему)
4.3 Queue vs Pub-Sub на примере Task Manager
| Сценарий | Паттерн | Почему |
|---|---|---|
| Отправка email уведомления | Queue | Письмо должен получить ровно один человек. 2 письма — спам |
| Событие "задача создана" | Pub-Sub | Уведомление + аналитика + аудит — три разных подписчика |
| Импорт задач из Excel | Queue | Каждая строка = 1 сообщение. Воркеры параллельно обрабатывают |
| Обновление кэша после изменения задачи | Pub-Sub | Поиск, рекомендации, кэш профиля — все должны узнать |
4.4 Сравнение Message Broker
| Брокер | Тип | Язык | Модель | Гарантии | Порядок | Особенность |
|---|---|---|---|---|---|---|
| Apache Kafka | Log-based | Java/Scala | Pub-Sub + Queue (Consumer Group) | At-least-once | Внутри партиции | Высокая пропускная способность, хранение событий, replay, retention |
| RabbitMQ | Queue | Erlang | Queue + Pub-Sub (Exchange) | At-least-once | Внутри очереди | Гибкая маршрутизация (direct, topic, fanout, headers), dead letter queue |
| AWS SQS | Queue | — | Queue | At-least-once | Approximate FIFO | Управляемый, дешёвый, стандартный + FIFO очередь |
| AWS SNS | Pub-Sub | — | Pub-Sub | At-least-once | — | Pub-Sub + интеграция с SQS, Lambda, Email, SMS |
| NATS | Pub-Sub | Go | Pub-Sub | At-most-once | — | Лёгкий, быстрый, 10M msg/s |
| Redis Pub-Sub | Pub-Sub | C | Pub-Sub | At-most-once | — | Крайне быстрый, НЕ хранит сообщения |
| RabbitMQ Streams | Log-based | Erlang | Queue | At-least-once | Внутри потока | Новый тип очередей для больших данных |
Для аналитика: Не нужно знать детали реализации каждого брокера. Достаточно понимать:
- Queue (точка-точка) vs Topic (Pub-Sub)
- At-least-once vs At-most-once
- Гарантирует ли брокер порядок и как (партиция / очередь)
- Dead Letter Queue (DLQ) — куда попадают «плохие» сообщения
5. Паттерн: API Gateway
5.1. Проблема: микросервисный хаос
Клиент (SPA, мобильное приложение) хочет отобразить задачу с данными исполнителя и количеством комментариев. В микросервисной архитектуре без Gateway клиент делает 4 запроса:
Client → GET /api/tasks/42 (Task Service)
→ GET /api/users/5 (User Service)
→ GET /api/comments?taskId=42 (Comment Service)
→ GET /api/files?taskId=42 (File Service)
Проблемы:
- N + 1 запросов с клиента — мобильное приложение тратит батарею и трафик
- Клиент знает про все сервисы — изменение архитектуры (расщепление сервиса) ломает клиент
- Аутентификация N раз — каждый сервис проверяет JWT
- Разные протоколы — REST + gRPC + WebSocket → клиент сложный
5.2. Решение: API Gateway
┌─────────────────┐
Client ─── HTTPS ────────→│ API Gateway │
│ │
│ • Аутентификация │
│ • Rate Limiting │
│ • Маршрутизация │
│ • Агрегация │
│ • Трансформация │
└────────┬─────────┘
│
┌───────────────────┼───────────────────┐
│ │ │
┌──────▼──────┐ ┌──────▼──────┐ ┌──────▼──────┐
│ Task Service │ │ User Service │ │Notify Service│
│ (REST) │ │ (gRPC) │ │ (Kafka) │
└──────────────┘ └──────────────┘ └──────────────┘
5.3. Функции API Gateway
| Функция | Описание | Пример |
|---|---|---|
| Маршрутизация | Направляет запрос к нужному сервису | /api/v1/tasks → Task Service |
| Аутентификация | Проверяет JWT, API Key, OAuth2 | Один раз на Gateway, не в каждом сервисе |
| Rate Limiting | Ограничивает количество запросов от клиента | 100 req/min на пользователя |
| Агрегация | Собирает ответы из нескольких сервисов в один | Task + User + Comments в одном ответе |
| Трансформация | Меняет формат ответа (XML → JSON, версионирование) | API v1 → v2 маппинг полей |
| Кэширование | Кэширует ответы для GET-запросов | TTL = 60 секунд для списка статусов |
| Мониторинг | Собирает метрики всех запросов | Latency, error rate, request count |
| Circuit Breaker | Отключает проблемные сервисы | Если Task Service падает — возвращать 503 |
5.4. Пример агрегации (backend-for-frontend)
Запрос клиента:
GET /api/v1/tasks/42?include=assignee,comments,attachments
Authorization: Bearer eyJhbGciOiJI...
Что делает Gateway:
Gateway:
1. Проверяет JWT → userId = 7
2. Параллельно вызывает:
├── Task Service: GET /internal/tasks/42 → {id:42, title:"Настроить CI", assigneeId:5, ...}
├── User Service: GET /internal/users/5 → {id:5, name:"Иван", email:"ivan@mail.ru"}
├── Comment Service: GET /internal/comments?taskId=42 → [{...}, {...}]
└── File Service: GET /internal/files?taskId=42 → [{...}]
3. Собирает в один JSON-ответ
4. Возвращает клиенту
Ответ:
{
"id": 42,
"title": "Настроить CI/CD",
"assignee": {
"id": 5,
"name": "Иван",
"email": "ivan@mail.ru"
},
"comments": [
{ "id": 1, "text": "Готово", "author": "Иван" }
],
"attachments": [
{ "id": 10, "fileName": "config.yml", "url": "/files/10" }
],
"_links": {
"self": "/api/v1/tasks/42",
"assignee": "/api/v1/users/5"
}
}
Преимущества для клиента:
- 1 запрос вместо 4
- Меньше трафика (не передаются промежуточные данные)
- Единый формат ответа
5.5. Опасности API Gateway
- Single Point of Failure (SPOF) — если Gateway падает, вся система недоступна
- Monolith Gateway — Gateway превращается в «толстого» монолита с бизнес-логикой
- Latency overhead — лишний сетевой хоп (клиент → Gateway → сервис)
- Сложность версионирования — Gateway должен поддерживать все версии API
Для аналитика: В требованиях укажите, какие функции выполняет Gateway (аутентификация, агрегация, rate limiting) и какие НЕ выполняет. Чётко определите границу: Gateway НЕ содержит бизнес-логики (проверки прав, валидации бизнес-правил).
6. Паттерн: Saga — распределённая транзакция
6.1. Проблема: монолитная ACID-транзакция в микросервисах
В монолите создание заказа — одна транзакция:
BEGIN;
INSERT INTO orders ...;
UPDATE inventory SET quantity = quantity - 1 ...;
UPDATE account SET balance = balance - 100 ...;
COMMIT; -- Всё или ничего
В микросервисах каждый шаг выполняется в своём сервисе:
Order Service: создать заказ
Inventory Service: уменьшить остаток
Payment Service: списать средства
Проблема: Нет распределённых ACID-транзакций. 2PC (Two-Phase Commit) слишком медленный, блокирующий и не поддерживается большинством брокеров.
6.2. Решение: Saga
Saga — последовательность локальных транзакций с компенсациями. Если шаг N упал — запускаются компенсации для шагов N-1...1.
6.3. Choreography (Хореография) — децентрализованный подход
Каждый сервис публикует событие и подписывается на события других.
Order Service Inventory Service Payment Service
│ │ │
│── Создать заказ │ │
│ │ │
│── publish(order.created) ────→│ │
│ │── Зарезервировать товар │
│ │ │
│ │── publish(inventory.reserved)─→│
│←─────────────────────────────│ │── Списать средства
│ │
│ │── publish(payment.done)─→
│←────────────────────────────────────────────────────────────│
│ │
│── Подтвердить заказ │
Плюсы:
- Нет единой точки отказа (оркестратор не нужен)
- Простота (каждый сервис знает только своих соседей)
- Хорошо для небольшого числа сервисов (2-3)
Минусы:
- Сложно отслеживать (циклические зависимости событий)
- Нет единого места, где видно статус всей саги
- Сложно добавлять новые шаги (нужно менять существующие сервисы)
6.4. Orchestration (Оркестрация) — централизованный подход
Центральный оркестратор управляет шагами Saga.
┌─────────────────────────────────────────────────────────────┐
│ Saga Orchestrator │
│ │
│ 1. → Order Service: создать заказ │
│ 2. → Payment Service: списать средства │
│ 3. → Inventory Service: зарезервировать товар │
│ 4. → Notification Service: отправить подтверждение │
│ │
│ Если шаг 3 упал: │
│ 2c. → Payment Service: отменить списание (refund) │
│ 1c. → Order Service: отменить заказ │
└─────────────────────────────────────────────────────────────┘
Реализация оркестратора:
- BPMN-процесс (Camunda, Temporal)
- State machine (AWS Step Functions)
- Код (Spring State Machine, собственный оркестратор)
Плюсы:
- Единое место управления и мониторинга
- Чёткая последовательность шагов
- Простота добавления/изменения шагов
Минусы:
- Единая точка отказа (оркестратор)
- Оркестратор знает про все сервисы (повышенная связанность)
- Может стать «God Object» (слишком много логики)
6.5. Компенсационные транзакции (Compensation)
Принцип: Каждый шаг Саги должен иметь компенсацию — операцию, отменяющую его эффект.
| Шаг | Действие | Компенсация |
|---|---|---|
| Создать заказ | INSERT INTO orders |
UPDATE orders SET status = 'CANCELLED' |
| Списать средства | UPDATE balance SET amount = amount - 100 |
UPDATE balance SET amount = amount + 100 (refund) |
| Зарезервировать товар | UPDATE inventory SET quantity = quantity - 1 |
UPDATE inventory SET quantity = quantity + 1 |
| Отправить email | sendEmail(...) |
sendEmail("Извините, заказ отменён") — или ничего |
Для аналитика: В требованиях к каждой Saga явно пропишите обратные операции (compensation). Если операцию нельзя отменить (например, отправлен физический товар), это must-have в требованиях к бизнес-процессу.
6.6. Choreography vs Orchestration
| Аспект | Choreography | Orchestration |
|---|---|---|
| Управление | Децентрализованное | Централизованное |
| Единая точка отказа | Нет | Оркестратор |
| Сложность отслеживания | Высокая (нужно смотреть все сервисы) | Низкая (один дашборд) |
| Связанность | Низкая (сервисы знают только о событиях) | Высокая (оркестратор знает все сервисы) |
| Добавление шага | Менять существующие подписки | Добавить шаг в оркестратор |
| Когда выбирать | Простые процессы, 2-3 сервиса | Сложные процессы, 5+ сервисов |
Подробнее: Паттерн Saga также описан в уроке 05.03 «Примеры моделирования процессов в BPMN» с диаграммами.
7. Паттерн: CQRS
7.1. Проблема: одна модель для чтения и записи
В традиционном CRUD одна и та же модель (Entity) используется и для чтения, и для записи.
@Entity
public class Task {
// 15 полей для работы приложения
// 5 полей для отчётов
// 3 поля для поиска
// Все вместе — одна таблица
}
Проблемы:
- Оптимизация для записи (индексы) мешает чтению (другие индексы)
- Отчёты выполняют сложные JOIN, блокирующие запись
- Разные права доступа (чтение — всем, запись — админам)
7.2. Решение: CQRS
Разделяем модель на две:
Client ──→ Command (POST/PATCH/DELETE) ──→ Write Model ──→ Write DB (normalized)
──→ Query (GET) ──→ Read Model ──→ Read DB (denormalized, cached)
Write Model:
- Оптимизирована для записи
- Валидация бизнес-правил
- Нормализованные данные (3NF)
Read Model:
- Оптимизирована для чтения
- Денормализованные данные
- Кэши, Materialized Views, Elasticsearch
7.3. Как синхронизируются модели
Client → POST /tasks (создать задачу)
│
▼
Write Model
│
│── event: task.created ──→ Event Bus
│
┌───────┴────────┐
│ │
Read Model 1 Read Model 2
(PostgreSQL) (Elasticsearch)
Синхронизация:
- Write Model создаёт задачу (INSERT в
tasks) - Write Model публикует событие
task.created - Read Model 1 ловит событие → денормализованный INSERT в
tasks_view - Read Model 2 ловит событие → индексация в Elasticsearch
Задержка: Несколько миллисекунд (Eventual Consistency).
7.4. Когда нужен CQRS
| Ситуация | CQRS? | Почему |
|---|---|---|
| Чтение и запись сильно отличаются по нагрузке (10k reads/s, 10 writes/s) | ✅ | Можно отдельно масштабировать Read и Write |
| Сложные отчёты требуют денормализованных данных | ✅ | Read Model специально подготовлена для отчётов |
| Разные права доступа (чтение — всем, запись — админам) | ✅ | Можно физически разделить модели |
| Простой CRUD (блог, TODO list) | ❌ | Избыточно. Одна модель справится |
| Нужна строгая консистентность (банковский перевод) | ❌ | Eventual Consistency не подходит |
| Только одна операция в секунду | ❌ | Нет выгоды |
Для аналитика: CQRS — архитектурный паттерн. Вы должны знать, что он существует, понимать, когда его предлагают архитекторы, и уметь обосновать/оспорить его необходимость.
8. Паттерн: Event Sourcing
8.1. Проблема: потеря истории
Традиционный подход (State):
-- Сейчас: статус "Выполняется"
UPDATE tasks SET status = 'Done', updated_at = NOW() WHERE id = 42;
Вопросы без ответа:
- Когда статус изменился на «Выполняется»?
- Кто изменил статус?
- Что было до этого?
- Можно ли откатить на состояние «В работе»?
8.2. Решение: Event Sourcing
Вместо хранения текущего состояния храним все события, которые привели к этому состоянию.
Традиционный подход (State):
-- Таблица: tasks
-- id | title | status | assignee_id | updated_at
-- 42 | "Настроить CI" | "Done" | 5 | 2026-05-29
Event Sourcing:
-- Таблица: events
-- id | entity_id | type | data | timestamp
-- 1 | 42 | TaskCreated | {"title":"Настроить CI", "assigneeId":5} | 2026-05-28
-- 2 | 42 | TaskStatusChanged | {"from":"New","to":"In Progress"} | 2026-05-28
-- 3 | 42 | TaskAssigned | {"from":null,"to":5} | 2026-05-28
-- 4 | 42 | TaskStatusChanged | {"from":"In Progress","to":"Done"} | 2026-05-29
-- 5 | 42 | TaskCommented | {"userId":5,"text":"Готово, проверяйте"} | 2026-05-29
Текущее состояние = сумма всех событий:
- Создана → В работе → Назначена Ивану → Выполнена → Комментарий
8.3. Как восстановить состояние (Projection)
Все события по task_42 (Event Store)
│
│ Свернуть (fold/reduce)
▼
┌────────────────────────────┐
│ Projection: Task State │
│ id: 42 │
│ title: "Настроить CI" │
│ status: "Done" │
│ assignee: {id: 5, ...} │
│ comments: [...] │
└────────────────────────────┘
function buildTaskState(events: Event[]): Task {
let task = new Task();
for (const event of events) {
switch (event.type) {
case 'TaskCreated':
task.id = event.entityId;
task.title = event.data.title;
break;
case 'TaskStatusChanged':
task.status = event.data.to;
break;
case 'TaskAssigned':
task.assigneeId = event.data.to;
break;
case 'TaskCommented':
task.comments.push(event.data);
break;
}
}
return task;
}
8.4. Event Sourcing + CQRS = идеальная пара
Write Side (Command) Read Side (Query)
┌────────────────────┐ ┌────────────────────┐
│ Event Store │────event──→│ Projection (Read │
│ (все события) │ │ Model) │
│ │ │ │
│ • append-only │ │ • денормализовано │
│ • immutable │ │ • кэшировано │
│ • полная история │ │ • быстрые чтения │
└────────────────────┘ └────────────────────┘
8.5. Когда выбирать Event Sourcing
| За | Против |
|---|---|
| Аудит из коробки (все события сохраняются) | Сложность (проекции, версионирование событий) |
| Полная история (Time Travel до любой точки) | Много событий (большой объём данных) |
| Отладка инцидентов (можно воспроизвести всё) | Не все запросы эффективны по событиям |
| Естественная интеграция с Event Bus | Версионирование событий (меняем структуру — перепроецируем) |
| Свобода изменений (можно добавить новую проекцию) | Кривая обучения для команды |
Для аналитика: Event Sourcing нужен, когда бизнес требует полного аудита (финансы, медицина, compliance) или когда история — часть бизнес-логики (корзина, версионирование документов). Не используйте Event Sourcing для простых CRUD-систем.
9. Паттерны надёжности: Circuit Breaker, Retry, Timeout, Bulkhead
9.1. Проблема: каскадный отказ (Cascading Failure)
Сервис A → Сервис B → Сервис C (завис)
↓
A ждёт B → пул потоков A исчерпан
↓
Запросы к A → очередь → A падает
↓
Frontend → ошибка 503
↓
Все пользователи видят "Сервис недоступен"
Недостаточно просто повторять запросы при ошибке. Нужны умные паттерны защиты.
9.2. Circuit Breaker (CB) — полный разбор
Аналогия: Электрический автомат в щитке. Если ток превышает порог — автомат «выбивает» (размыкает цепь), чтобы проводка не загорелась. Через некоторое время можно включить обратно.
Конечный автомат Circuit Breaker
┌─────────────────────────────────────────────────────────────┐
│ │
│ Запрос успешен ┌───────────┐ Счётчик ошибок │
│ ─────────────────────→│ CLOSED │────→ = порог? │
│ │ (Закрыт) │ │ │
│ └─────┬─────┘ │ Да │
│ │ │ │
│ │ Ошибок ▼ │
│ │ больше ┌───────────┐ │
│ │ порога ───→│ OPEN │ │
│ │ │ (Открыт) │ │
│ │ └─────┬─────┘ │
│ │ │ │
│ │ Таймер │ │
│ │ истёк │ │
│ │ ▼ │
│ │ ┌───────────┐ │
│ │ │ HALF-OPEN │ │
│ │ │(Полуоткр.)│ │
│ │ └─────┬─────┘ │
│ │ │ │
│ │ ┌──────────────┼──────┐ │
│ │ │ Пробный │ │ │
│ │ │ запрос │ │ │
│ │ │ успешен │ │ │
│ │ │ ▼ │ │
│ │ │ ┌───────────┐ │ │
│ │ │ │ CLOSED │ │ │
│ │ │ └───────────┘ │ │
│ │ │ │ │
│ │ │ Пробный запрос │ │
│ │ │ НЕуспешен │ │
│ │ │ │ │
│ │ └────────→ OPEN ──────┘ │
│ │ │
└───────────────────────────────┴────────────────────────────┘
Состояния CB
| Состояние | Что происходит | Когда переходит |
|---|---|---|
| CLOSED (Закрыт) | Запросы проходят нормально. Считаются ошибки (5xx, таймауты) | Порог ошибок превышен → OPEN |
| OPEN (Открыт) | Запросы НЕ ПРОХОДЯТ. Мгновенный возврат ошибки (fallback). Таймер ожидания запущен | Таймер истёк → HALF-OPEN |
| HALF-OPEN (Полуоткрыт) | Пропускается один пробный запрос. Если успешен → CLOSED. Если ошибка → OPEN | Один запрос сделан |
Что должен спроектировать аналитик
Ключевые параметры Circuit Breaker:
| Параметр | Описание | Пример | Как выбрать |
|---|---|---|---|
| failureThreshold | Количество ошибок для OPEN | 5 | Зависит от критичности. Для критичных — 3 |
| timeout | Время ожидания тайм-аута запроса | 2 с | Должно быть < SLO клиента |
| resetTimeout | Через сколько OPEN → HALF-OPEN | 30 с | nginx upstream timeout × 2 |
| halfOpenMaxRequests | Сколько пробных запросов в HALF-OPEN | 3 | Не больше 1-3, чтобы не перегрузить |
| successThreshold | Сколько успешных пробных запросов для CLOSED | 2 | >= 1, зависит от стабильности |
Пример нефункционального требования:
Req-ID: NFR-CB-001
Заголовок: Circuit Breaker для интеграции с Payment Service
Описание:
- failureThreshold: 5 последовательных ошибок 5xx
- resetTimeout: 30 секунд (через 30 с → пробный запрос)
- halfOpenMaxRequests: 1 (один пробный запрос)
- timeout: 3 секунды на запрос
- Fallback: вернуть {"status": "pending", "message": "Сервис временно недоступен"}
Пример работы CB в коде (псевдо)
class CircuitBreaker {
state: 'CLOSED' | 'OPEN' | 'HALF_OPEN' = 'CLOSED';
failureCount = 0;
failureThreshold = 5;
resetTimeout = 30000; // 30 секунд
lastFailureTime: number | null = null;
halfOpenProbeSent = false;
async call(serviceFn: () => Promise<Response>): Promise<Response> {
if (this.state === 'OPEN') {
if (Date.now() - this.lastFailureTime > this.resetTimeout) {
this.state = 'HALF_OPEN';
this.halfOpenProbeSent = false;
} else {
return this.fallback(); // Мгновенный отказ
}
}
if (this.state === 'HALF_OPEN' && this.halfOpenProbeSent) {
return this.fallback(); // Пробный запрос уже идёт
}
try {
const response = await serviceFn();
this.onSuccess();
return response;
} catch (error) {
this.onFailure();
throw error;
}
}
private onSuccess(): void {
this.failureCount = 0;
if (this.state === 'HALF_OPEN') {
this.state = 'CLOSED'; // Восстановился!
}
}
private onFailure(): void {
this.failureCount++;
this.lastFailureTime = Date.now();
if (this.failureCount >= this.failureThreshold) {
this.state = 'OPEN';
}
}
private fallback(): Response {
return { status: 503, body: { message: 'Service temporarily unavailable' } };
}
}
Реальный кейс: Payment Service упал
Время | Событие | Состояние CB
─── ───┼────────────────────────────────────────────┼─────────────
10:00:00 | 5 запросов подряд → 502 Bad Gateway | CLOSED → OPEN
10:00:01 | 6-й запрос → получил fallback "Сервис | OPEN
| временно недоступен" (2 мс вместо 3 с!) |
10:00:05 | 50 запросов → все мгновенный fallback | OPEN
10:00:30 | Таймер сработал → пробный запрос | HALF-OPEN
10:00:30 | Payment Service ответил 200 OK | HALF-OPEN → CLOSED
10:00:31 | Все запросы снова проходят нормально | CLOSED
Что произошло:
- Без CB: 50 запросов ждали бы по 3 секунды → 150 секунд ожидания, пул потоков исчерпан
- С CB: 50 запросов получили fallback за 2 мс каждый → 0.1 секунды общих потерь
Для аналитика: CB — один из самых важных паттернов интеграции. В требованиях всегда указывайте:
- Failure threshold (когда отключать)
- Reset timeout (когда пробовать снова)
- Fallback strategy (что возвращать при отключении)
9.3. Retry (Повторная попытка)
Простой Retry
Клиент ──→ Запрос 1 ──→ Ошибка (503)
──→ Запрос 2 ──→ Ошибка (таймаут)
──→ Запрос 3 ──→ Успех
Экспоненциальная задержка с джиттером
retry:
max_attempts: 3
initial_delay: 100ms
multiplier: 2 # 100ms → 200ms → 400ms
max_delay: 5000ms # Не больше 5 секунд
jitter: true # +- случайное значение (чтобы не было Thundering Herd)
Принцип: После каждой неудачи ждём дольше, чтобы не перегружать сервис.
Jitter (дрожание): Если 100 клиентов одновременно retry с одинаковым интервалом — они создадут «стадо» (Thundering Herd), которое добьёт ослабленный сервис. Jitter добавляет случайное смещение:
Без jitter: 1с, 1с, 1с, 1с → все одновременно → Thundering Herd
С jitter: 1.0с, 1.2с, 0.8с, 1.1с → равномерно распределены
Retry vs Circuit Breaker
| Аспект | Retry | Circuit Breaker |
|---|---|---|
| Когда | Временный сбой (сеть моргнула) | Системный сбой (сервис умер) |
| Поведение | Повторяет запрос с задержкой | Отключает запросы мгновенно |
| Длительность | Секунды | Минуты |
| Риск | Thundering Herd | Ложное срабатывание |
| Вместе | Retry 3 раза, если не помогло → CB OPEN |
Паттерн «Retry with Backoff + Circuit Breaker» — стандарт индустрии:
- Попробовать запрос с exponential backoff (до 3 раз)
- Если не помогло → Circuit Breaker думает, что делать дальше
- CB отключает вызовы на время → потом пробует снова
9.4. Timeout (Тайм-аут)
Самая простая и самая нарушаемая практика. Каждый синхронный вызов ДОЛЖЕН иметь тайм-аут.
// НЕПРАВИЛЬНО — ждёт вечно
const response = await httpClient.get('/api/tasks/42');
// ПРАВИЛЬНО
const response = await httpClient.get('/api/tasks/42', {
timeout: 5000 // 5 секунд
});
Тайм-ауты по уровням:
nginx → upstream: 10s
Gateway → Task Service: 5s
Task Service → DB: 3s
DB query: 2s
Правило: Тайм-аут клиента > сумма тайм-аутов всех вызовов + запас (буфер).
9.5. Bulkhead (Перегородка) — изоляция ресурсов
Аналогия: На корабле — водонепроницаемые перегородки (bulkheads). Если отсек затоплен — вода не заливает весь корабль.
В программировании: Ограничиваем количество одновременных вызовов к каждому сервису.
Без Bulkhead:
├── Request 1 → Service A
├── Request 2 → Service A ── Service A упал → все потоки заняты ожиданием
├── ... ── Service B тоже недоступен (нет свободных потоков)
└── Request N → Service B ── Система упала
С Bulkhead:
┌────────────────┐
│ Thread Pool A │──→ Service A (максимум 5 потоков)
│ (max 5) │
└────────────────┘
┌────────────────┐
│ Thread Pool B │──→ Service B (максимум 10 потоков) — НЕ ЗАТРАГИВАЕТСЯ
│ (max 10) │
└────────────────┘
Для аналитика: В требованиях к критичным интеграциям укажите требование изоляции: «Сбой в одном внешнем сервисе не должен блокировать обработку запросов к другим сервисам».
9.6. Idempotency (Идемпотентность)
Определение: Повторное выполнение операции даёт тот же результат, что и первое.
Почему это важно в асинхронной интеграции: При At-least-once доставке сообщение может прийти дважды. Без идемпотентности — дубликаты.
Как реализуется
Клиент генерирует Idempotency-Key (UUID) и передаёт его в запросе:
POST /api/v1/tasks
Idempotency-Key: 550e8400-e29b-41d4-a716-446655440000
Content-Type: application/json
{ "title": "Настроить CI/CD" }
Сервер проверяет:
-- 1. Проверить, обработан ли ключ
SELECT * FROM idempotency_keys WHERE key = '550e8400-e29b-41d4-a716-446655440000';
-- Если найден → вернуть предыдущий ответ (201 Created)
-- Если не найден → выполнить запрос, сохранить ключ + ответ
INSERT INTO idempotency_keys (key, response_code, response_body, created_at)
VALUES ('550e8400-e29b-41d4-a716-446655440000', 201, '{"id": 42}', NOW());
Где хранить ключи
| Хранилище | Плюсы | Минусы |
|---|---|---|
| PostgreSQL | ACID, надёжность | Медленнее, чем кэш |
| Redis | Быстро, TTL автоматически | Неатомарно, может потерять при сбое |
| In-Memory (HashMap) | Очень быстро | Потеря при рестарте |
Для аналитика: Определите TTL для хранения ключей. Обычно 24 часа. Через 24 часа клиент должен сгенерировать новый ключ.
HTTP-методы и идемпотентность
| HTTP-метод | Идемпотентен? | Комментарий |
|---|---|---|
| GET | ✅ Да | Всегда |
| PUT | ✅ Да | Замена ресурса целиком |
| DELETE | ✅ Да | Первый раз — 200, второй — 404 (состояние то же) |
| HEAD | ✅ Да | По определению |
| OPTIONS | ✅ Да | По определению |
| PATCH | ❌ Нет | Может инкрементально менять поле |
| POST | ❌ Нет | Создаёт новый ресурс (нужен Idempotency-Key) |
Для аналитика: В требованиях к API всегда указывайте, какие методы требуют Idempotency-Key. Для POST — всегда. Для PATCH — если операция не идемпотентна.
10. Пример: архитектура интеграций Task Manager
10.1. Схема полной архитектуры
┌──────────────┐
│ Клиент │
│ (SPA/Mobile) │
└──────┬───────┘
│ HTTPS (REST)
┌──────▼───────┐
│ API Gateway │ ← JWT, Rate Limiting, CB
└──────┬───────┘
│
┌──────────────────┼──────────────────┐
│ │ │
┌───────▼───────┐ ┌──────▼───────┐ ┌──────▼───────┐
│ Task Service │ │ User Service │ │Notification │
│ │ │ │ │ Service │
└───────┬───────┘ └──────┬───────┘ └──────┬───────┘
│ │ │
│ ┌────────▼────────┐ │
│ │ PostgreSQL │ │
│ │ (Write Model) │ │
│ └─────────────────┘ │
│ │ │
│ ┌────────▼────────┐ │
│ │ Redis (Кэш) │ │
│ │ (Read Model) │ │
│ └─────────────────┘ │
│ │
│ ┌───────────────────────────┘
│ │
┌───────▼─────────▼──────┐
│ Kafka (Event Bus) │
│ │
│ Топик: task.events │
│ key: taskId │
│ partition: 6 │
│ retention: 7 дней │
│ │
│ События: │
│ • task.created │
│ • task.status.changed │
│ • task.assigned │
│ • task.commented │
│ • task.deleted │
└────────────────────────┘
10.2. Поток «Создание задачи» — полный путь
Шаг 1: Клиент → POST /api/v1/tasks
Idempotency-Key: uuid-xxx
Body: {"title": "Настроить CI/CD", "assigneeId": 5}
Шаг 2: API Gateway
├── Проверка JWT → userId = 7 (пропускаем)
├── Rate Limit → 99/100 (пропускаем)
└── Маршрутизация → POST /internal/tasks (Task Service)
Шаг 3: Task Service
├── Idempotency check → ключ новый (пропускаем)
├── Валидация → assigneeId = 5 существует?
│ → User Service: GET /internal/users/5 (синхронно, 2s timeout)
│ → ответ: 200 OK, user существует (пропускаем)
├── INSERT INTO tasks (синхронно, PostgreSQL)
└── Отправка → Kafka topic: task.events, key: taskId (42), msg: TaskCreated
Шаг 4: Клиент ← 201 Created
Body: {"id": 42, "title": "Настроить CI/CD", "status": "New"}
Шаг 5: Notification Service → читает Kafka
├── message: TaskCreated {taskId: 42, assigneeId: 5}
├── Проверка: статус уведомления = critical?
│ └── Да → немедленно Slack
│ ├── Slack API → 200 OK
│ └── В случае ошибки → Retry (3 раза, exponential backoff)
│ └── Не помогло → log + DLQ
├── Получатель = assignee → Email
│ └── Email Queue (RabbitMQ) → Email Worker → SMTP
└── ACK → Kafka (offset commit)
Шаг 6: Analytics Service → читает Kafka
├── message: TaskCreated {taskId: 42, ...}
├── UPDATE statistics (PostgreSQL)
└── ACK → Kafka
Шаг 7: Search Service → читает Kafka
├── message: TaskCreated {taskId: 42, ...}
├── Index → Elasticsearch
└── ACK → Kafka
10.3. Поток «Ошибка Payment Service» — с Circuit Breaker
1. Task Service → Payment Service (создание платного задания)
├── 502 Bad Gateway
├── Retry 1 → 502
├── Retry 2 → 502 (exponential backoff: 1s → 2s)
├── Retry 3 → timeout (4s → всё, не дождались)
└── CB: failureCount = 3 → State: CLOSED → OPEN
└── Все последующие вызовы → fallback + DLQ
2. Через 30 секунд:
└── CB: HALF-OPEN → пробный запрос
├── 200 OK → CB: HALF-OPEN → CLOSED
└── 502 → CB: HALF-OPEN → OPEN (ещё 30 секунд тишины)
10.4. Поток «Нарушение порядка сообщений» — что если ключ неправильный
Было (ключ = random UUID):
Кафка-топик: task.events (6 партиций)
├── task.created {id:42} → Partition 3 (hash случайный)
├── task.assigned {id:42} → Partition 0
└── task.deleted {id:42} → Partition 5
Consumer читает параллельно из P0, P3, P5:
Поток 1 (P0): "Назначена" — но задача ещё не создана в Read Model!
Поток 2 (P3): "Создана" — теперь видим задачу
Поток 3 (P5): "Удалена" — задача есть, удаляем
→ Временная консистентность нарушена. User получил уведомление о назначении
на несуществующую задачу.
Стало (ключ = taskId):
Кафка-топик: task.events (6 партиций)
├── partition = hash(42) % 6 = 0
├── task.created {id:42} → Partition 0
├── task.assigned {id:42} → Partition 0
└── task.deleted {id:42} → Partition 0
Consumer читает P0 последовательно:
Создана → Назначена → Удалена (строгий порядок). Всё корректно.
11. Итоговая таблица: какой паттерн когда выбирать
| Паттерн | Проблема | Когда применять | Когда НЕ применять |
|---|---|---|---|
| Queue | Задача должна быть выполнена ровно один раз | Email, уведомления, обработка файлов | Одно событие нужно N потребителям |
| Pub-Sub | О событии нужно уведомить N сервисов | Аудит, аналитика, кэширование | Нужна гарантия ровно одного получателя |
| API Gateway | Клиент делает слишком много запросов | Мобильное приложение, SPA, публичное API | Маленькая система (2-3 сервиса) |
| Saga | Нужна консистентность через несколько сервисов | Создание заказа, бронирование | Простые 1-шаговые операции |
| CQRS | Разная нагрузка на чтение и запись | Отчёты, дашборды, аналитика | Простой CRUD |
| Event Sourcing | Нужен полный аудит | Финансы, медицина, compliance | Хранилище «только последнее состояние» |
| Circuit Breaker | Каскадный отказ при падении сервиса | Интеграция с внешними системами | Внутренние вызовы в рамках одного процесса |
| Retry | Временная ошибка сети | API calls, DB queries | Длительные ошибки (нужен CB) |
| Idempotency | Дубликаты при повторных запросах | POST, PATCH в асинхронных сценариях | GET (идемпотентен по природе) |
| Bulkhead | Один сбой ломает все сервисы | Разные внешние системы | Один сервис, один пул потоков |
Вопросы для самопроверки
- В чём разница между синхронной и асинхронной интеграцией? Когда что выбирать?
- Какие три гарантии доставки существуют? Почему Exactly-once — миф?
- Что такое effectively-once и как он реализуется?
- Почему в Kafka важен ключ партиционирования? Что будет, если использовать random UUID?
- Придумайте пример, где нарушение порядка сообщений приводит к бизнес-ошибке. Как исправить?
- Чем Queue отличается от Pub-Sub? Приведите примеры для каждого.
- Нарисуйте (в уме) конечный автомат Circuit Breaker. Какие три состояния?
- Что такое Half-Open? Какой запрос делается в этом состоянии?
- Как Retry и Circuit Breaker работают вместе?
- Что такое компенсационная транзакция в Saga? Приведите пример.
- В чём отличие Choreography от Orchestration в Saga?
- Для чего нужен Idempotency-Key? Как долго его хранить?
- Что такое Bulkhead? Как он предотвращает каскадный отказ?
- Когда стоит применять Event Sourcing? Какие у него недостатки?
- Аналитик спроектировал Kafka-топик с ключом
eventType. Хорошее ли это решение? Почему?
Практическое задание
Задание 1. Выбор паттерна и гарантий
Для каждой ситуации выберите:
- Паттерн интеграции (Queue / Pub-Sub / API Gateway / Saga / CQRS / Event Sourcing)
- Гарантию доставки (At-most-once / At-least-once + идемпотентность / Not applicable)
- Обоснование (1–2 предложения)
| Ситуация | Паттерн | Гарантия | Обоснование |
|---|---|---|---|
| При регистрации пользователя нужно отправить приветственное письмо и записать событие в аналитику | |||
| Мобильное приложение запрашивает профиль пользователя — нужен ответ сейчас | |||
| При создании заказа нужно одновременно списать средства, зарезервировать товар и отправить уведомление | |||
| Система должна хранить историю всех изменений статуса задачи для аудита | |||
| Несколько клиентов (веб, мобильное, сторонний сервис) обращаются к разным микросервисам | |||
| Датчик температуры отправляет показания каждую секунду. Потеря одного не критична | |||
| Платёжная система: клиент может повторно отправить POST при тайм-ауте |
Задание 2. Спроектируйте интеграцию
Кейс: Система Task Manager интегрируется с внешней системой уведомлений (Slack и Email).
Требования:
- При создании задачи с приоритетом Critical → уведомление в Slack (немедленно)
- При смене статуса задачи → email исполнителю (можно в течение 5 минут)
- При ошибке отправки Slack → повторить 3 раза с интервалом 30 секунд
- Если Slack недоступен после 3 попыток → ошибка логируется, но процесс не прерывается
- Событие
task.status.changedтакже должно быть получено Analytics Service и Search Service
Вопросы:
- Какую интеграцию (синхронную/асинхронную) выбрать для Slack? Для email? Для Analytics/Search?
- Какой брокер и какой паттерн (Queue/Pub-Sub) выбрать для каждого канала?
- Спроектируйте Kafka-топик: название, ключ партиционирования, количество партиций, retention
- Нарисуйте схему взаимодействия (текстовое описание в 5–7 шагов)
Задание 3. Анализ нарушения порядка
Кейс:
- События заказа публикуются в Kafka-топик
order.events - Ключ партиционирования:
orderType("standard", "express", "wholesale") - 6 партиций (0–5)
- Consumer читает события и обновляет статус заказа в базе
Ситуация:
Событие 1: order.created {orderId: 101, type: "standard"}
Событие 2: payment.done {orderId: 101, type: "standard"}
Событие 3: order.confirmed {orderId: 101, type: "standard"}
Вопросы:
- В какие партиции попадут события 1, 2 и 3? (hash("standard") % 6 = ?)
- В каком порядке их прочитает Consumer?
- Какая бизнес-ошибка может произойти?
- Какой ключ партиционирования нужно использовать вместо
orderType?
Задание 4. Проектирование Circuit Breaker
Кейс: Сервис Task Manager интегрируется с внешним Payment Gateway.
Требования:
- Payment Gateway может быть недоступен до 5 минут (плановая профилактика)
- При недоступности Gateway → задача создаётся со статусом "Pending payment"
- При восстановлении Gateway → Pending задачи автоматически оплачиваются
- Gateway обрабатывает до 100 запросов в секунду
- Нужно избежать лавинообразного повтора при восстановлении
Спроектируйте Circuit Breaker:
| Параметр | Значение | Обоснование |
|---|---|---|
| failureThreshold | ? | — |
| resetTimeout | ? | — |
| halfOpenMaxRequests | ? | — |
| fallback | ? | — |
| Retry strategy | ? | — |
Задание 5. Анализ ошибки Idempotency
Ситуация: Клиент отправил POST /api/v1/tasks (создать задачу). Сервер создал задачу, вернул 201 Created, но из-за сетевой ошибки клиент не получил ответ. Клиент повторно отправил тот же запрос. Создалась вторая задача (дубликат).
Вопросы:
- Какая техника предотвратила бы дубликат?
- Где должен храниться Idempotency-Key — на клиенте или на сервере?
- Какой HTTP-заголовок используется для передачи ключа?
- Как долго сервер должен хранить обработанные ключи? От чего зависит TTL?
- Что произойдёт, если клиент отправил запрос с тем же ключом, но c другим телом?
Дополнительные материалы
- Книга: Sam Newman — «Building Microservices: Designing Fine-Grained Systems»
- Книга: Chris Richardson — «Microservices Patterns» (Saga, CQRS, Event Sourcing)
- Паттерны: «Enterprise Integration Patterns» (EIP) — классический каталог интеграционных паттернов (https://www.enterpriseintegrationpatterns.com/)
- Статья: Martin Fowler — «Circuit Breaker» (martinfowler.com)
- Статья: Martin Fowler — «Event Sourcing» (martinfowler.com)
- Статья: Jay Kreps — «The Log: What every software engineer should know about real-time data's unifying abstraction»
- Инструмент: Apache Kafka Documentation — конфигурация партиционирования
- Паттерны: microservices.io — каталог паттернов микросервисов
- Теория: «FLP Impossibility Theorem» — почему Exactly-once невозможен в распределённых системах
- Документ: Марк Ричардс — «Microservices Architecture Pattern» (O'Reilly)