Данные и согласованность¶
Данные и согласованность¶
Самая тяжёлая часть микросервисов. Не сеть, не деплой — именно данные. Здесь ломаются интуиции, наработанные годами на монолитах с одной БД и одной транзакцией.
Главная идея: общей транзакции больше нет. Атомарность через границы сервисов недостижима задёшево. Дальше — про то, как с этим жить.
Database per service
Database per service — это принцип, по которому каждый сервис владеет своей приватной БД, а чужие
сервисы лезут к этим данным только через его API, не напрямую в схему (популяризовано Chris Richardson,
Microservices Patterns, 2018; принцип также у Sam Newman, Building Microservices, 2015).
Зачем так строго: - Прямой доступ к чужой таблице — это скрытый coupling. Завтра владелец меняет схему — и у тебя всё падает, причём он об этом не узнает. - Связанность через БД незаметна в архитектурных диаграммах. Её видно только когда сломалось. - Если все ходят в общую базу — у тебя не микросервисы, а distributed monolith с лишним сетевым хопом. Про эту ловушку подробнее в Macro-Architecture.
Частое заблуждение: «database per service» значит отдельный физический инстанс на каждый сервис. Нет. Речь о логической изоляции владения. В одном инстансе Postgres можно держать schema-per-service или даже table-per-service. Ключ один: чужой сервис не открывает коннект к твоей схеме напрямую.
Что отсюда следует:
- Нет foreign key через границу сервиса. Ссылочную целостность держишь сам, в коде.
- Нет JOIN между сервисами. Запрос, которому нужны данные из двух сервисов, — отдельная задача
(см. ниже про API composition и CQRS).
- Нет общей ACID-транзакции на два сервиса. И вот это — главная боль.
Почему 2PC избегают
Two-Phase Commit (2PC) — это блокирующий протокол атомарного коммита: координатор сначала опрашивает
всех участников (prepare/vote), потом рассылает единое решение commit или abort (формализован
Jim Gray, 1978).
Идея простая:
ФАЗА 1 (prepare) ФАЗА 2 (commit)
Coordinator --prepare--> Svc A Coordinator --commit--> Svc A
Coordinator --prepare--> Svc B Coordinator --commit--> Svc B
<--yes----- (locks держатся всю фазу 1->2)
2PC даёт настоящую атомарность: либо все закоммитили, либо все откатились. Тогда почему в микросервисах
его почти не берут:
- Блокирующий. Между голосованием и решением участники держат locks. Чем больше участников и сети
между ними — тем дольше висят блокировки.
- Координатор — single point. Упал после того, как все проголосовали yes, но до рассылки решения —
участники зависают in-doubt с захваченными locks, пока он не вернётся.
- Плохо масштабируется и не дружит с доступностью при network partition. По сути выбираешь
consistency ценой availability.
Точнее, чем «2PC устаревший»: он не неправильный, он даёт сильную согласованность. Просто цена — блокировки и хрупкость координатора. В мире, где сервисов десятки и сеть ненадёжна, это слишком дорого. Поэтому берут saga.
Note
2PC не вымер. Внутри одной БД-системы, в распределённых транзакциях через XA, в брокерах — он
живёт. Избегают именно межсервисного 2PC поверх ненадёжной сети, а не протокола как такового.
Saga: транзакция как цепочка с компенсациями
Saga — это последовательность локальных транзакций, где каждая публикует событие или команду,
триггерящие следующий шаг, а при сбое выполняются компенсирующие транзакции, откатывающие уже сделанное
(Hector Garcia-Molina и Kenneth Salem, SIGMOD 1987).
Паттерн старше микросервисов на десятилетия. Гарсиа-Молина и Салем придумали saga для long-lived transactions внутри одной СУБД — чтобы долгая транзакция не держала locks часами. В микросервисы её перенесли уже потом.
Идея простая: разбиваешь бизнес-операцию на шаги, каждый шаг — локальная ACID-транзакция в своём сервисе. Не получилось на шаге N — не откатываешь как в БД (нечего откатывать, всё уже закоммичено), а запускаешь компенсации для шагов N-1, N-2, ... 1.
Что важно понять про гарантии: - Saga — это не распределённая транзакция в смысле ACID. Это eventual consistency. - Нет изоляции. Промежуточные состояния видны другим. Заказ уже создан, но оплата ещё не прошла — и кто-то этот заказ в этот момент увидит. - Компенсация — это не rollback. Это новая транзакция, семантически отменяющая предыдущую. Списал деньги — компенсация делает возврат, а не «делает вид, что списания не было».
Компенсации продумываешь как часть бизнес-логики. Не каждое действие откатывается чисто: письмо клиенту уже ушло, его не «раскоммитишь». Иногда компенсация — это второе письмо «извините, отменили».
Choreography vs orchestration
Два стиля координации saga. Разница — есть ли центральный дирижёр (популяризовано в микросервисах Sam Newman и Chris Richardson; термины восходят к BPM/WS-BPEL).
Choreography — это координация без центрального координатора: каждый сервис реагирует на события
других и публикует свои.
OrderSvc --OrderCreated--> [broker] --> PaymentSvc
PaymentSvc --PaymentDone--> [broker] --> InventorySvc
InventorySvc --StockReserved--> [broker] --> ShippingSvc
Плюсы: - Сервисы decoupled, нет узкого места. Каждый знает только свои события. - Легко добавить нового слушателя на существующее событие.
Минусы: - Поток саги нигде не виден целиком. Чтобы понять «что вообще происходит при заказе», читаешь код пяти сервисов. Это и есть event spaghetti. - Циклические зависимости по событиям подкрадываются незаметно. - Тяжело отвечать на вопрос «на каком шаге застряло».
Orchestration — это явный координатор (оркестратор), который командует участниками и держит
состояние саги.
+---------------------+
| Order Orchestrator |
+---------------------+
| 1.charge | 2.reserve | 3.ship
v v v
PaymentSvc InventorySvc ShippingSvc
(команды идут от оркестратора, ответы возвращаются ему)
Плюсы: - Поток виден в одном месте. Логика саги и компенсаций — в оркестраторе. - Легко мониторить, на каком шаге сага и почему упала.
Минусы: - Оркестратор — потенциальное узкое место и точка, куда стягивается логика. Следи, чтобы он не превратился в god service, который тянет бизнес-правила из других сервисов себе.
Частое заблуждение: это не выбор «или-или», и choreography не равно «асинхронная», а orchestration не равно «синхронная». Оба обычно асинхронные. Их можно смешивать в одной системе. Грубое правило: короткая сага на 2-3 шага — choreography; сложная сага с ветвлениями и компенсациями — orchestration.
Dual-write problem
Прежде чем про outbox — какую именно проблему он решает.
Сага живёт на событиях. Сервис меняет своё состояние в БД и должен опубликовать событие в broker. Два действия, два разных хранилища. И вот ловушка:
func (s *OrderService) CreateOrder(ctx context.Context, o Order) error {
if err := s.db.Save(ctx, o); err != nil { // 1. записали в БД
return err
}
// <-- сервис падает здесь
return s.broker.Publish(ctx, OrderCreated{ID: o.ID}) // 2. опубликовали в broker
}
Dual-write problem — это невозможность атомарно записать в два разных хранилища без распределённой
транзакции. Любой из вариантов ломается:
- Упал между 1 и 2 — данные в БД есть, события нет. Сага не стартовала, заказ завис навсегда.
- Сначала publish, потом save, а save упал — событие ушло, данных под него нет. Consumer обработает
фантом.
- Обернуть в 2PC поверх БД и брокера — вернулись ровно к тому, чего избегали.
Суть: запись в БД и публикация в broker не атомарны. Решение — свести их к одной локальной транзакции. Так появляется outbox.
Transactional outbox + CDC
Transactional outbox — это паттерн, где сервис в одной локальной транзакции пишет и бизнес-данные,
и сообщение в таблицу outbox, а отдельный relay-процесс потом читает outbox и публикует сообщение
в broker (популяризовано Chris Richardson, Microservices Patterns, 2018).
Идея простая: убираем второе хранилище из критичного пути. Событие пишем в ту же БД, в той же транзакции, что и данные. БД даёт атомарность бесплатно — она и так ACID.
BEGIN;
INSERT INTO orders (id, status, amount) VALUES ('o-42', 'CREATED', 100);
INSERT INTO outbox (id, aggregate_id, type, payload)
VALUES ('e-7', 'o-42', 'OrderCreated', '{"orderId":"o-42","amount":100}');
COMMIT;
Либо закоммитились обе вставки, либо ни одной. Dual-write больше нет: данные и событие связаны одной транзакцией. Дальше событие надо доставить в broker. Тут два механизма.
Polling publisher: relay-процесс периодически читает outbox, где не доставлено, шлёт в broker,
помечает доставленным. Просто, но даёт нагрузку на БД и лишнюю латентность поллинга.
CDC (log-based): отдельная платформа читает не таблицу, а transaction log БД (WAL в Postgres) и эмитит события из изменений. Поллинга нет, нагрузки на основную БД почти нет.
Change Data Capture (CDC) — это подход, при котором изменения строк извлекаются из transaction log
БД и публикуются как поток событий; Debezium — открытая CDC-платформа поверх Kafka Connect,
читающая лог БД (Debezium создан Randall Hauch в Red Hat, 2016).
Вся схема целиком:
┌─────────────────────────────────────────────┐
│ Order Service │
│ ┌────────────────────────────────────────┐ │
│ │ BEGIN │ │
│ │ INSERT orders (бизнес-данные) │ │
│ │ INSERT outbox (событие) │ │ одна локальная
│ │ COMMIT │ │ транзакция
│ └────────────────────────────────────────┘ │
└───────────────────┬───────────────────────────┘
│ запись попадает в WAL
v
┌──────────────────┐
│ Postgres WAL │ transaction log
└────────┬─────────┘
│ читает лог (не таблицу)
v
┌──────────────────┐
│ Debezium / CDC │ (Kafka Connect)
└────────┬─────────┘
│ публикует событие
v
┌──────────────────┐
│ Kafka │ broker
└────────┬─────────┘
│
v
consumer'ы саги (другие сервисы)
Грабли:
- Outbox даёт at-least-once, а не exactly-once. Relay упал после publish, но до пометки «доставлено» —
при перезапуске пошлёт то же событие снова. Дубли неизбежны, значит consumer должен быть idempotent.
- CDC и outbox — не одно и то же. CDC — это механизм чтения изменений из лога. Outbox — паттерн с
таблицей-почтовым-ящиком. Часто их совмещают: Debezium умеет читать именно outbox-таблицу через
Outbox Event Router и не стримить сырые изменения всех таблиц.
- Чистить outbox надо. Доставленные строки удаляешь или архивируешь, иначе таблица распухает.
- Порядок событий внутри одного aggregate надо сохранять. В Kafka — слать с ключом по aggregate_id,
чтобы события одного заказа легли в одну партицию.
Idempotent consumer
Раз доставка at-least-once, дубли прилетят гарантированно. Защита от них — на стороне consumer'а.
Idempotent consumer — это потребитель, спроектированный так, что повторная обработка того же
сообщения даёт тот же результат, что и однократная (популяризовано Chris Richardson, Microservices
Patterns, 2018).
Частое заблуждение: at-least-once можно «починить» до exactly-once настройками брокера. Нельзя. Дедупликацию обеспечивает приложение. Exactly-once в обработке достигается на стороне consumer'а, а не магией транспорта.
Два способа добиться идемпотентности:
- Отслеживать обработанные message id. Храни id в таблице, перед обработкой проверяй — видел ли уже.
Запись id и эффект — в одной транзакции, иначе тот же dual-write на новом уровне.
- Сделать саму операцию естественно идемпотентной. UPDATE account SET status='paid' WHERE id=42 —
можно гонять сколько угодно, результат тот же. А вот balance = balance - 100 — нет.
BEGIN;
-- атомарно: либо вставился id и применился эффект, либо ничего
INSERT INTO processed_messages (message_id) VALUES ('e-7')
ON CONFLICT (message_id) DO NOTHING;
-- если строка не вставилась (дубль) — эффект не применяем
UPDATE orders SET status = 'PAID' WHERE id = 'o-42'
AND EXISTS (SELECT 1 FROM processed_messages WHERE message_id = 'e-7');
COMMIT;
На практике чаще: проверил processed_messages, если дубль — ack и выход; если нет — обрабатываешь
и пишешь id в той же транзакции.
Schema registry и эволюция событий
Событие из outbox живёт долго и читается многими. Producer и consumer деплоятся независимо. Producer поменял формат события — старые consumer'ы ломаются. Это версионная проблема контрактов в данных, не в API.
Schema registry — это централизованный сервис, который хранит и версионирует схемы сообщений и
проверяет их совместимость, чтобы producer'ы и consumer'ы безопасно меняли формат (Confluent Schema
Registry открыт ~2015 для Kafka).
Не путай роли:
- Avro и Protobuf — это форматы сериализации и описания схемы. Avro — из Hadoop-экосистемы
(Apache, ~2009). Protobuf — Google (открыт 2008).
- Schema registry — это про governance: хранит схемы, версионирует, проверяет compatibility. Сам
wire-формат — не его забота.
- Registry — не часть Kafka. Изначально отдельный компонент Confluent. Частое заблуждение, что он
встроен в брокер.
Эволюция схемы должна быть совместимой — новые consumer'ы читают старые события, или наоборот, смотря какой режим. Правила, которые почти всегда безопасны: - Добавлять поле с default — можно. Старый consumer его не заметит, новый возьмёт default из старых событий. - Удалять или переименовывать поле — ломает. Если надо — через deprecation, не резким сносом. - Менять тип поля — почти всегда ломает.
Типы совместимости, которые проверяет registry:
| Режим | Что гарантирует | Кого деплоишь первым |
|---|---|---|
| backward | новый consumer читает старые события | consumer |
| forward | старый consumer читает новые события | producer |
| full | и то, и другое | любой порядок |
Грабли: events — это контракт, такой же как REST/gRPC. Менять схему старых событий задним числом
нельзя, особенно если где-то применяется event sourcing и события — источник правды. Старое событие
неизменно. Нужна новая форма — версионируй (OrderCreatedV2), а не правь старую.
Запросы через границы сервисов
JOIN-а между БД сервисов нет. А запрос «покажи заказ с именем клиента и названиями товаров» нужен.
Данные лежат в трёх сервисах. Два основных подхода.
API composition — это реализация запроса через границы: композитор вызывает сервисы-владельцы данных
и делает in-memory join их ответов (Chris Richardson, Microservices Patterns, 2018).
┌──────────────────┐
запрос -> │ Composer │
│ (gateway/BFF) │
└──┬─────┬─────┬────┘
│ │ │ параллельные вызовы
v v v
OrderSvc CustomerSvc ProductSvc
│ │ │
└─────┴─────┘
in-memory join -> ответ
Когда подходит: - Простые запросы, немного сервисов, разумные объёмы данных. - Не нужна сложная фильтрация/сортировка через границы.
Грабли: - In-memory join большого объёма — дорого. Тянешь 10k заказов и для каждого дёргаешь CustomerSvc — это N+1 на сетевом уровне. - Доступность падает мультипликативно. Композитору нужны все вызываемые сервисы живыми. Каждый добавленный вызов снижает суммарную доступность. - Латентность по самому медленному из параллельных вызовов.
CQRS (Command Query Responsibility Segregation) — это разделение модели на write-модель (команды,
меняющие состояние) и read-модель (запросы), которые могут иметь разные схемы и хранилища (термин ввёл
Greg Young, ~2010, поверх CQS Бертрана Мейера).
В контексте межсервисных запросов CQRS даёт read model — отдельное хранилище, заранее собранное под конкретный запрос. Сервис-вьюшка слушает события от OrderSvc, CustomerSvc, ProductSvc и поддерживает у себя денормализованную таблицу, готовую отвечать одним запросом.
OrderSvc --events--> ┐
CustomerSvc --events--> ├─> [Read Model Service]
ProductSvc --events--> ┘ денормализованная вьюшка
(своя БД, заранее собранный JOIN)
│
запрос -> │ один быстрый SELECT
Когда подходит: - Тяжёлые запросы, высокая частота чтений, сложная фильтрация/сортировка через границы. - Чтений сильно больше, чем записей.
Грабли: - Read model eventual consistent. Между событием и обновлением вьюшки — лаг. Записал заказ, тут же читаешь — можешь не увидеть. Это надо закладывать в UX. - Лишнее хранилище, которое надо поддерживать и пересобирать. - Частое заблуждение: CQRS обязательно требует event sourcing и двух разных баз. Нет. CQRS — это только разделение моделей чтения и записи; может жить и на одной БД. ES и CQRS независимы. Сам Фаулер предупреждал: не тащи CQRS без нужды, это лишняя сложность.
Если коротко: Нет атомарности через границу → saga + компенсации. Надо надёжно опубликовать событие → outbox (атомарно с данными) + CDC/relay. Доставка at-least-once → дубли → idempotent consumer. События меняются во времени → schema registry + backward-compatible эволюция. Запрос через границу → API composition (просто) или CQRS read model (тяжёлые чтения). Перед любым из этого спроси: точно нужны отдельные сервисы? Цена — вот эта глава целиком.