Симптом простой: сервис заказов уже показывает cancelled v3, а сервис исполнения ещё хранит awaiting-reservation v2. Оба говорят об одном order-417, но в разных состояниях. Цена ошибки появляется, когда второй сервис делает следующий шаг по своему старому значению: готовит отгрузку, повторно просит резерв или сообщает пользователю не тот результат. Если сравнивать только строки статуса, спор быстро превращается в поиск «плохого сервиса». Нужны факты: кто владеет состоянием, какая версия уже принята и какое действие запрещено до сверки.
В апреле 2021 года я бы не начинал с общей транзакции между двумя приложениями. Сначала фиксируется один owner для заказа. Он меняет доменное состояние и создаёт след изменения. Второй сервис строит свою проекцию и обязан показывать её как проекцию, а не как независимую истину. В этом тексте order-service владеет состоянием заказа, а fulfillment владеет только локальным состоянием исполнения. Все id, версии и деньги ниже учебные; fixture работает в памяти Node и не запускает broker, базу, реальные сервисы или заказы.
Состояние начинается с владельца, а не с таблицы статусов
Owner отвечает на вопрос, кто имеет право принять следующее доменное решение. Для order-417 это сервис заказа: он может перевести paid v2 в cancelled v3, если получил подтверждённую учебную причину отказа резерва. Сервис исполнения не переписывает заказ задним числом. Он принимает событие, хранит последнюю применённую версию и решает, можно ли выполнять свою локальную работу. Такая граница не делает данные мгновенно одинаковыми. Она делает различие объяснимым: известно, какая запись является источником решения и какая должна догнать её.
| Факт | Владелец | Доказательство | Что может сделать другой сервис |
|---|---|---|---|
paid v2 | order-service | order id, version 2, event id | построить проекцию awaiting-reservation, но не считать заказ отгруженным |
| отказ учебного резерва | решение owner-а по входному reason | order id, исходная version, compensation key | передать evidence; не отменять заказ напрямую |
cancelled v3 | order-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-а и явно заданную версию, а не на удачное совпадение текста статуса.
Граница 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;
Если два изменения действительно должны быть атомарны, они должны находиться в одной описанной транзакционной границе. Если они находятся в разных сервисах, вместо ложного обещания атомарности нужны версия, источник события, отдельное повторяемое действие и компенсирующее решение владельца. Такой путь не «чинит сеть». Он делает видимым, какая часть уже подтверждена, какая ещё ждёт и что будет сделано, если внешнее условие не выполнилось.
Короткий маршрут внедрения контракта
- Выбрать один owner для каждого доменного состояния. Записать, какой сервис имеет право менять его и какая запись считается доказательством.
- Назвать следующее рискованное действие: отгрузка, доступ, письмо или счёт. Сформулировать для него инвариант с state, version и evidence.
- Добавить к изменению owner-а стабильный event id, source, type, subject и версию объекта. Не выдавать этот конверт за готовый transport protocol.
- В consumer хранить последнюю применённую версию и отдельную отметку уже обработанного event id. Version gap не применять «как получится».
- Определить, какое условие создаёт compensation request, кто принимает это решение и какой key защищает повтор именно этого решения.
- Собрать in-memory fixture с duplicate и gap, затем отдельно проверить выбранную базу, broker и внешнее действие в интеграционном окружении.
- Отдельно описать, что видит пользователь и оператор во время 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 и не устраняет повторную отправку прикладным кодом