Интеграционные паттерны и системная архитектура

Урок 3 из 3

Урок 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): в асинхронной распределённой системе невозможно гарантировать консенсус при наличии хотя бы одного сбойного узла.

Как эмулируется:

  1. Сообщение доставляется с гарантией At-least-once
  2. Обработчик реализует идемпотентность
  3. Результат — логическая 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)

Проблемы:

  1. N + 1 запросов с клиента — мобильное приложение тратит батарею и трафик
  2. Клиент знает про все сервисы — изменение архитектуры (расщепление сервиса) ломает клиент
  3. Аутентификация N раз — каждый сервис проверяет JWT
  4. Разные протоколы — 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

  1. Single Point of Failure (SPOF) — если Gateway падает, вся система недоступна
  2. Monolith Gateway — Gateway превращается в «толстого» монолита с бизнес-логикой
  3. Latency overhead — лишний сетевой хоп (клиент → Gateway → сервис)
  4. Сложность версионирования — 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)

Синхронизация:

  1. Write Model создаёт задачу (INSERT в tasks)
  2. Write Model публикует событие task.created
  3. Read Model 1 ловит событие → денормализованный INSERT в tasks_view
  4. 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 — один из самых важных паттернов интеграции. В требованиях всегда указывайте:

  1. Failure threshold (когда отключать)
  2. Reset timeout (когда пробовать снова)
  3. 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» — стандарт индустрии:

  1. Попробовать запрос с exponential backoff (до 3 раз)
  2. Если не помогло → Circuit Breaker думает, что делать дальше
  3. 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 Один сбой ломает все сервисы Разные внешние системы Один сервис, один пул потоков

Вопросы для самопроверки

  1. В чём разница между синхронной и асинхронной интеграцией? Когда что выбирать?
  2. Какие три гарантии доставки существуют? Почему Exactly-once — миф?
  3. Что такое effectively-once и как он реализуется?
  4. Почему в Kafka важен ключ партиционирования? Что будет, если использовать random UUID?
  5. Придумайте пример, где нарушение порядка сообщений приводит к бизнес-ошибке. Как исправить?
  6. Чем Queue отличается от Pub-Sub? Приведите примеры для каждого.
  7. Нарисуйте (в уме) конечный автомат Circuit Breaker. Какие три состояния?
  8. Что такое Half-Open? Какой запрос делается в этом состоянии?
  9. Как Retry и Circuit Breaker работают вместе?
  10. Что такое компенсационная транзакция в Saga? Приведите пример.
  11. В чём отличие Choreography от Orchestration в Saga?
  12. Для чего нужен Idempotency-Key? Как долго его хранить?
  13. Что такое Bulkhead? Как он предотвращает каскадный отказ?
  14. Когда стоит применять Event Sourcing? Какие у него недостатки?
  15. Аналитик спроектировал 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

Вопросы:

  1. Какую интеграцию (синхронную/асинхронную) выбрать для Slack? Для email? Для Analytics/Search?
  2. Какой брокер и какой паттерн (Queue/Pub-Sub) выбрать для каждого канала?
  3. Спроектируйте Kafka-топик: название, ключ партиционирования, количество партиций, retention
  4. Нарисуйте схему взаимодействия (текстовое описание в 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. В какие партиции попадут события 1, 2 и 3? (hash("standard") % 6 = ?)
  2. В каком порядке их прочитает Consumer?
  3. Какая бизнес-ошибка может произойти?
  4. Какой ключ партиционирования нужно использовать вместо 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, но из-за сетевой ошибки клиент не получил ответ. Клиент повторно отправил тот же запрос. Создалась вторая задача (дубликат).

Вопросы:

  1. Какая техника предотвратила бы дубликат?
  2. Где должен храниться Idempotency-Key — на клиенте или на сервере?
  3. Какой HTTP-заголовок используется для передачи ключа?
  4. Как долго сервер должен хранить обработанные ключи? От чего зависит TTL?
  5. Что произойдёт, если клиент отправил запрос с тем же ключом, но 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)

📚 Материалы модуля

🖼️ Схема и инфографика

🎬 Видео-лекция

🎬 API и интеграции

📄 Дополнительные материалы (PDF)

📄API Integration Blueprints
Скачать
Спросить ИИ