Перейти к содержанию

Данные и согласованность

Данные и согласованность

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

Главная идея: общей транзакции больше нет. Атомарность через границы сервисов недостижима задёшево. Дальше — про то, как с этим жить.

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 (тяжёлые чтения). Перед любым из этого спроси: точно нужны отдельные сервисы? Цена — вот эта глава целиком.