DarkRiDDeR15 мин

Согласованность данных между сервисами: начать с владельца заказа и инварианта

АрхитектураДанныеПрактика

Симптом простой: сервис заказов уже показывает cancelled v3, а сервис исполнения ещё хранит awaiting-reservation v2. Оба говорят об одном order-417, но в разных состояниях. Цена ошибки появляется, когда второй сервис делает следующий шаг по своему старому значению: готовит отгрузку, повторно просит резерв или сообщает пользователю не тот результат. Если сравнивать только строки статуса, спор быстро превращается в поиск «плохого сервиса». Нужны факты: кто владеет состоянием, какая версия уже принята и какое действие запрещено до сверки.

В апреле 2021 года я бы не начинал с общей транзакции между двумя приложениями. Сначала фиксируется один owner для заказа. Он меняет доменное состояние и создаёт след изменения. Второй сервис строит свою проекцию и обязан показывать её как проекцию, а не как независимую истину. В этом тексте order-service владеет состоянием заказа, а fulfillment владеет только локальным состоянием исполнения. Все id, версии и деньги ниже учебные; fixture работает в памяти Node и не запускает broker, базу, реальные сервисы или заказы.

Состояние начинается с владельца, а не с таблицы статусов

Owner отвечает на вопрос, кто имеет право принять следующее доменное решение. Для order-417 это сервис заказа: он может перевести paid v2 в cancelled v3, если получил подтверждённую учебную причину отказа резерва. Сервис исполнения не переписывает заказ задним числом. Он принимает событие, хранит последнюю применённую версию и решает, можно ли выполнять свою локальную работу. Такая граница не делает данные мгновенно одинаковыми. Она делает различие объяснимым: известно, какая запись является источником решения и какая должна догнать её.

Контракт одного заказа на границе двух учебных сервисов
ФактВладелецДоказательствоЧто может сделать другой сервис
paid v2order-serviceorder id, version 2, event idпостроить проекцию awaiting-reservation, но не считать заказ отгруженным
отказ учебного резерварешение owner-а по входному reasonorder id, исходная version, compensation keyпередать evidence; не отменять заказ напрямую
cancelled v3order-serviceновая version и событие отменыперевести свою проекцию в cancelled после применения gap-free версии
локальная готовность к отгрузкеfulfillmentсовпавшая version и evidence резерваразрешить следующий шаг только по этому локальному контракту
временное расхождениеникто не «владеет» задержкойowner version больше projection versionискать недостающую версию или отложенное событие, а не перетирать статус

В таблице намеренно нет строки «все сервисы согласованы». Это не полезное состояние для проверки. Полезнее сформулировать инвариант действия: заказ нельзя отдавать на отгрузку, пока проекция исполнения не применит ту же версию owner-а и не имеет явного evidence успешного резерва. При paid v2 у owner-а и accepted v1 у projection расхождение допустимо, но readyToShip остаётся ложным. При cancelled v3 оно также остаётся ложным. Так eventual consistency получает границу: разница версий допустима только пока не запускается необратимое или дорогое действие.

Событие переносит наблюдение, а не владение

Для сообщения достаточно назвать, о каком объекте и какой его версии идёт речь. Я использую id, source, type, subject и orderVersion. Первые четыре поля похожи на словарь CloudEvents 1.0.1, который уже существовал к апрелю 2021 года. Это не означает, что объект ниже совместим со всеми transport binding или что его можно отправить в выбранный broker без адаптера. Здесь он нужен, чтобы consumer мог объяснить, откуда пришёл факт, а не угадывать состояние по payload.

// Учебный конверт. Это не подключение к broker и не полная реализация CloudEvents.
const orderPaid = {
  id: 'evt-order-417-paid-v2',
  source: 'training/order-service',
  type: 'training.order.paid',
  subject: 'order-417',
  orderVersion: 2,
};

// source + id различает доставку, orderVersion — состояние одного owner.

Важно не смешать два ключа. source + id отвечает на вопрос о доставке конкретного сообщения: этот экземпляр уже применён или пришёл повторно. orderVersion отвечает на вопрос о последовательности состояния одного заказа. Два разных события могут иметь разные id, но быть неправильными для текущей проекции, если версия пропущена. И наоборот, один и тот же event id не описывает сам по себе доменный эффект. Такое разделение продолжает мартовскую тему про duplicate: ключ доставки нельзя выдавать за доказательство согласованности всей модели.

Инвариант должен запрещать следующий шаг

Фраза «данные в итоге сойдутся» не говорит обработчику, что делать сейчас. Инвариант должен быть проверяем в момент действия. В нашем учебном контракте fulfillment не может поставить readyToShip, если owner не находится в paid, версия не совпадает или нет evidence резерва. Это намеренно уже, чем «все поля одинаковы». Сервис исполнения может хранить свою полезную информацию: номер склада, попытку резерва, локальную ошибку. Но она не даёт права изменить owner state и не заменяет факт версии.

const ownerContract = {
  orderId: 'order-417',
  owner: 'order-service',
  state: 'paid',
  version: 2,
  shippingRule: 'only after matching reservation evidence',
};

// Projection может временно отставать, но не может называть себя readyToShip без evidence.

Проверка не требует распределённого lock. До следующего действия consumer читает собственную проекцию и сохранённый пакет evidence. Если она отстала, действие блокируется и запускается диагностический маршрут: найти missing event, дочитать owner или отправить запись на ручную проверку. Какая именно операция допустима, зависит от предметной области. В учебном примере это отгрузка; в другом месте это может быть письмо, выдача доступа или создание счёта. Общий только принцип: действие опирается на факты своего owner-а и явно заданную версию, а не на удачное совпадение текста статуса.

Вертикальная схема состояния заказа: owner хранит paid версии 2, затем по контролируемому отказу создаёт cancelled версии 3; fulfillment сначала ждёт версию 2, откладывает пришедшую раньше версию 3, применяет версии по порядку и не разрешает отгрузку
Расхождение версий здесь видно как состояние ожидания, а не скрытая ошибка: consumer откладывает v3 до v2 и не получает права на следующий шаг.

Граница eventual consistency — это известное ожидание

Слова eventual consistency полезны только после трёх уточнений. Первое: какая запись уже считается решением owner-а. Второе: какой consumer может временно не успеть за ней. Третье: какое действие запрещено в это окно. В примере owner уже записал cancelled v3, а projection ещё содержит awaiting-reservation v2. Это не повод подменять v2 вручную строкой cancelled: исчезнет evidence о том, что v3 была отложена из-за gap. Правильное действие — зафиксировать version gap, дождаться или найти v2, затем применить v3.

Эта дисциплина особенно важна, когда сервисы ведут разные счётчики. Один счётчик может означать «оплата принята», второй — «резерв подтверждён». Они не обязаны совпадать в каждый момент и не обязаны иметь одинаковые имена. Ошибка начинается, если отчёт или обработчик складывает их как одно поле «заказы готовы». Для отчёта нужен владелец метрики и условие включения. Для команды нужен маленький список states, которые нельзя использовать как разрешение на действие без version и evidence.

Локальная транзакция остаётся локальной

PostgreSQL 13 описывает изоляцию и serialization failure внутри одной базы. Эта гарантия полезна, когда owner обновляет свой заказ и ledger компенсации в одном хранилище. Но она не переносится автоматически на отдельный consumer или broker. Например, уникальный compensation_key может сделать повтор локальной записи безопасным для одной таблицы; он не доказывает, что другое приложение получило событие или отменило внешний резерв. Поэтому SQL ниже показывает только границу одного owner-а.

-- Локальная граница одной базы. Не делает две базы атомарными.
BEGIN;
UPDATE orders SET status = 'cancelled', version = version + 1
WHERE id = 'order-417' AND status = 'paid' AND version = 2;

INSERT INTO compensation_ledger (compensation_key, order_id, order_version)
VALUES ('compensation:order-417:reservation:v2', 'order-417', 2)
ON CONFLICT (compensation_key) DO NOTHING;
COMMIT;

Если два изменения действительно должны быть атомарны, они должны находиться в одной описанной транзакционной границе. Если они находятся в разных сервисах, вместо ложного обещания атомарности нужны версия, источник события, отдельное повторяемое действие и компенсирующее решение владельца. Такой путь не «чинит сеть». Он делает видимым, какая часть уже подтверждена, какая ещё ждёт и что будет сделано, если внешнее условие не выполнилось.

Короткий маршрут внедрения контракта

  1. Выбрать один owner для каждого доменного состояния. Записать, какой сервис имеет право менять его и какая запись считается доказательством.
  2. Назвать следующее рискованное действие: отгрузка, доступ, письмо или счёт. Сформулировать для него инвариант с state, version и evidence.
  3. Добавить к изменению owner-а стабильный event id, source, type, subject и версию объекта. Не выдавать этот конверт за готовый transport protocol.
  4. В consumer хранить последнюю применённую версию и отдельную отметку уже обработанного event id. Version gap не применять «как получится».
  5. Определить, какое условие создаёт compensation request, кто принимает это решение и какой key защищает повтор именно этого решения.
  6. Собрать in-memory fixture с duplicate и gap, затем отдельно проверить выбранную базу, broker и внешнее действие в интеграционном окружении.
  7. Отдельно описать, что видит пользователь и оператор во время gap. Статус без version и owner-а не должен быть единственным evidence.

Источники ограничивают обещание

CloudEvents даёт словарь контекстных атрибутов события, PostgreSQL 13 — конкретную семантику локальной изоляции и INSERT ... ON CONFLICT, а Kafka 2.7 прямо ограничивает область идемпотентности producer одной session и предупреждает о прикладных resend. Эти источники полезны не как готовый рецепт для текущего кода, а как границы формулировок. Ни один из них не превращает Array и Map из fixture в распределённую платформу и не обещает exactly-once для бизнес-эффекта.

В этой статье не запускались database, message broker, HTTP, сторонний резерв, реальный сервис, browser, CI или production build. Нет измеренной задержки, реальных заказов и данных пользователей. Следующий проверяемый шаг — на одном тестовом заказе собрать owner version, event id, состояние projection и compensation key. Если по этим четырём фактам нельзя объяснить расхождение, сначала уточняем контракт, а не добавляем ещё один retry.

Проверяемые источники

  • CloudEvents Specification v1.0.1: release record — официальная карточка выпуска, опубликованного в декабре 2020 года; поля id, source и type служат словарём учебного конверта, но fixture не является реализацией CloudEvents
  • PostgreSQL 13: Transaction Isolation — версионная документация PostgreSQL 13, доступная к апрелю 2021 года; описывает границы локальной транзакции и необходимость retry при serialization failure
  • PostgreSQL 13: INSERT и ON CONFLICT — официальный синтаксис и семантика локального уникального ограничения; пример ниже не утверждает атомарность между несколькими сервисами
  • Apache Kafka 2.7.0: KafkaProducer API — versioned API линии 2.7, существовавшей в апреле 2021 года; ограничивает идемпотентность producer одной session и не устраняет повторную отправку прикладным кодом