Между монолитом и микросервисами
Когда говорят про распределённые системы, обычно скачут от "монолит" к "микросервисам". Пропуская середину. А посередине лежит service-based architecture.
Richards и Ford в "Fundamentals of Software Architecture" описывают её так: отдельно развёрнутый UI, 4-12 крупных предметных сервисов и единая монолитная база данных. Не 50 микро-контейнеров с 50 базами. Несколько крупных сервисов, которые делят одну БД.
Зачем это нужно? Внутри одного крупного сервиса работают обычные ACID-транзакции. Начал транзакцию, сделал три записи, одна упала, всё откатилось. Базу логически разбивают через общие библиотеки: каждый сервис работает со своей схемой, но физически это один PostgreSQL.
Это прагматичный распределённый стиль. Даёт гибкость (сервисы развёртываются независимо), но не загоняет в безумную сложность микросервисов. Если бизнес не требует экстремальной масштабируемости или real-time обработки потоков, этот стиль закрывает большинство кейсов. А зачем вам события, если ACID-транзакции решают задачу?
Event vs Command: семантика, которая определяет архитектуру
Прежде чем выбирать топологию, нужно различать два типа сообщений.
Event (событие) это факт, который уже произошёл. "Заказ оплачен". "Пользователь зарегистрировался". Отправитель не ждёт ответа. Получатели могут быть любыми, один или сто. Event это "огонь и забыл". Если никто не слушает, ничего страшного.
Command (команда) это запрос на действие. "Спиши деньги". "Зарезервируй товар". Отправитель ожидает, что действие будет выполнено. У команды есть конкретный получатель. Если получатель недоступен, это проблема.
Зачем это важно: Events естественным образом ложатся на брокер (хореографию). Commands требуют медиатора (оркестровку). Смешивать их в одну кучу и выбирать топологию "по интуиции" это первый шаг к архитектурному долгу.
Когда начинаются события
Событийная архитектура это асинхронный стиль. Компоненты системы ничего не запрашивают друг у друга напрямую. Они реагируют на события.
На этом месте часто путают скорость и отзывчивость. Асинхронность не делает систему быстрее. Она делает её отзывчивее. Если анализ текста занимает 3000 мс, при синхронном подходе пользователь ждёт 3000 мс. При асинхронном получает подтверждение через 25 мс, а система догоняет в фоне. Время обработки то же. Пользовательское восприятие другое.
Две топологии: брокер и медиатор.
Брокер (хореография). Центрального координатора нет. Сервис сделал работу, бросил событие в очередь, забыл. Следующий сервис подхватил. Скорость максимальная, масштабируемость отличная. Но если что-то сломалось на четвёртом шаге из пяти, и никто не знает, на каком шаге всё встало. Нет единого места, где записано состояние процесса.
Медиатор (оркестровка). Есть центральный координатор. Он говорит: "сначала ты спиши деньги, потом ты зарезервируй товар, потом ты отправь уведомление". Каждый шаг под контролем. Если третий шаг упал, медиатор знает где остановились и может перезапустить с этой точки. Но медиатор это узкое место, единая точка отказа и замедление всего процесса.
Выбор зависит от ответа на один вопрос: что дороже, скорость или корректность? Если потерянное событие не критично (логирование, аналитика, push-уведомление), брокер. Если каждый шаг должен быть подтверждён (платежи, заказы, резервирование), медиатор. Большинство реальных систем используют оба подхода: критичные процессы через медиатор, всё остальное через брокер.
Три типичные точки потери данных
В асинхронной системе есть минимум три места, где сообщение может исчезнуть бесследно. Это не исчерпывающий список (replication lag, partial writes на брокере, рассинхрон ISR, проблемы с порядком в партициях добавляют свои), но это три самые частые точки, на которые натыкаются на проде.
Точка 1: отправка брокеру. Продюсер отправил сообщение, но брокер не получил. Сеть моргнула, брокер перезапустился. Сообщение ушло в никуда.
Точка 2: сбой обработчика. Консюмер забрал сообщение из очереди, начал обрабатывать, упал. По умолчанию сообщение удалено из очереди в момент чтения. Результат: обработчик мёртв, сообщение исчезло.
Точка 3: сбой записи в БД. Всё посчитали, обработали, а PostgreSQL отверг транзакцию или сеть отвалилась. Бизнес-логика выполнена, результат нигде не сохранён.
Kafka + PostgreSQL: конкретные настройки
Точка 1: идемпотентный продюсер
Две настройки, которые идут вместе:
# Producer config
acks=all
enable.idempotence=true
retries=3
max.in.flight.requests.per.connection=5acks=all заставляет продюсера ждать, пока все in-sync реплики подтвердят запись на диск. Без этого сообщение может "застрять" в памяти лидера и потеряться при его падении.
Но тут есть ловушка. С acks=all и retries=3 без идемпотентности можно получить дубли при ретраях: продюсер отправил, брокер записал, но подтверждение не дошло до продюсера, продюсер ретраит, брокер записывает второй раз. Настройка enable.idempotence=true добавляет sequence number к каждому сообщению, и брокер отбрасывает дубликаты на своей стороне. Это не опция, это базовая настройка для production-Kafka.
Точка 2: ручной commit offset
# Consumer config
enable.auto.commit=falseKafka по умолчанию автоматически сдвигает offset (указатель прочитанных сообщений). Сообщение прочитано, offset сдвинут, сообщение из очереди исчезло. Упал обработчик после чтения, но до завершения, и сообщение потеряно.
Ручной commit отключает автосдвиг. Теперь приложение обязано отправить commit в Kafka только после завершения обработки. Но тут появляется другая проблема: если обработка не идемпотентна, повторная обработка того же сообщения создаст дубли в бизнес-данных.
Идемпотентность consumer это не опция, а обязательное условие для ручного commit. Каждый обработчик должен корректно обрабатывать повторное сообщение: проверять по уникальному ключу, обновлять а не вставлять, игнорировать уже обработанные.
message = consumer.poll()
try {
if (alreadyProcessed(message.key)) { // idempotency check
consumer.commitSync()
return
}
process(message)
db.commit()
consumer.commitSync()
} catch (Exception e) {
// offset не сдвинут, обработается заново
}Точка 3: Last Participant Support (LPS)
Паттерн из Richards/Ford: база данных выступает "последним участником" распределённой операции. Сначала COMMIT в PostgreSQL. Только после подтверждения от базы, сдвигаем offset в Kafka.
Но между этими двумя операциями есть окно уязвимости. Приложение сделало COMMIT в PostgreSQL, получило подтверждение, и упало до того как сдвинуло offset в Kafka. При рестарте сообщение обработается повторно.
Именно поэтому идемпотентность consumer не приписка, а обязательное условие корректности LPS. Без неё паттерн не работает.
Dead Letter Queue
Что делать с сообщениями, которые не удалось обработать после N попыток? Оставлять в основной очереди нельзя: они блокируют остальные. Выбрасывать нельзя: теряем данные.
Решение: Dead Letter Queue (DLQ). После N неудачных попыток сообщение перенаправляется в отдельную очередь "мёртвых писем". Администратор видит их через дашборд, разбирает причину, исправляет данные и возвращает в основную очередь для повторной обработки.
В Kafka это настраивается через deadletterqueue.topic.name (Spring Kafka) или ручное перенаправление в catch-блоке после исчерпания ретраев. В RabbitMQ DLQ поддерживается из коробки через x-dead-letter-exchange.
DLQ это не дополнительная фича, а часть базовой архитектуры. Без неё ошибки накапливаются незаметно, пока не станет поздно.
Transactional Outbox: не одна SQL-строчка
Выше мы разбирали потерю данных при чтении из Kafka. Но есть обратный кейс: ваша запись в PostgreSQL должна породить событие в Kafka.
Сохранили заказ в БД, нужно отправить "заказ создан" в Kafka. Если сделать COMMIT в PostgreSQL, а потом отправку в Kafka упадёт, база считает что заказ есть, а Kafka не знает. Данные рассинхронизированы.
Суть Outbox: вместо прямой отправки в Kafka, записываете событие в специальную таблицу outbox в той же транзакции, что и бизнес-данные.
BEGIN;
INSERT INTO orders (id, status, total) VALUES (42, 'new', 1500);
INSERT INTO outbox (aggregate_id, event_type, payload, created_at)
VALUES (42, 'order_created', '{"id":42,"status":"new"}', now());
COMMIT;Один COMMIT. Обе записи атомарны. Либо обе сохранены, либо обе откачены.
Но INSERT это начало истории. Дальше нужно вытащить события из outbox и отправить в Kafka. Тут два подхода:
Poller. Фоновый процесс раз в N секунд опрашивает outbox-таблицу: SELECT * FROM outbox WHERE processed = false ORDER BY created_at LIMIT 100. Отправляет в Kafka, помечает как обработанные.
Плюсы: просто реализовать, не требует дополнительной инфраструктуры. Минусы: задержка до N секунд, нагрузка на БД при частом поллинге, нужно управлять блокировками при параллельных poller-ах.
CDC (Change Data Capture). Инструмент вроде Debezium читает WAL (write-ahead log) PostgreSQL и автоматически отправляет изменения из outbox-таблицы в Kafka. Никакого поллинга, никаких блокировок.
Плюсы: real-time (задержка миллисекунды), нет нагрузки на БД, гарантия порядка (Debezium читает WAL последовательно). Минусы: дополнительный компонент в инфраструктуре, настройка Debezium, мониторинг его здоровья.
Очистка outbox. Таблица растёт. Нужна стратегия очистки: либо DELETE после успешной отправки (просто, но теряется аудит), либо архивация в отдельную таблицу, либо TTL с периодической очисткой.
Порядок событий. Важен ли порядок? Если "заказ создан" должен прийти раньше "заказ оплачен", outbox должен гарантировать порядок. С poller это проблема: параллельные потоки могут переставить события. С Debezium проще: он читает WAL последовательно и отправляет в одну партицию Kafka по ключу aggregate_id.
Дубли на стороне consumer. Даже с outbox, consumer может получить одно событие дважды (повторный poller после сбоя, redelivery от Kafka). Consumer должен быть идемпотентным. Проверка по event_id или aggregate_id + event_type перед обработкой.
Идемпотентность: сквозная тема
Она появилась уже три раза: в продюсере (enable.idempotence=true), в consumer (проверка ключей перед обработкой), в outbox (consumer outbox событий тоже должен быть идемпотентным). И это не совпадение.
В распределённых системах ровно один раз (exactly-once) почти невозможен. Realistically: at-least-once + idempotent consumer = effectively-once. Это не теоретический компромисс, а инженерный факт. Каждый компонент, который получает сообщение, должен корректно обрабатывать повтор.
Паттерны идемпотентности:
- уникальный ключ сообщения + INSERT IF NOT EXISTS
- UPSERT вместо INSERT
- флаг
processedс проверкой перед обработкой -乐观锁 (optimistic locking) с version field
Без идемпотентности none из описанных паттернов (LPS, Outbox, manual commit) не работают корректно.
Saga: когда у каждого сервиса своя база
Когда у каждого сервиса своя база (полные микросервисы), классические ACID не работают. Появляется паттерн Saga: цепочка локальных транзакций с компенсациями на ошибку. Это отдельная большая тема, разберу в следующем посте. Здесь важно: Saga не даёт изолированности, и это компромисс, который принимают осознанно.
Брокер vs медиатор: итоговая схема
Материал основан на главах 13-14 "Fundamentals of Software Architecture" Марка Ричардса и Нила Форда (O'Reilly, 2nd edition).