{ "index": 242, "slug": "editorial-2021-04-mechanism-data-consistency", "title": "Согласованность между сервисами: почему event id не заменяет версию и компенсацию", "excerpt": "Повтор сообщения, пропущенная версия и неизвестный результат действия создают разные виды расхождения. Учебный пример показывает, как owner, consumer и компенсация удерживают состояние от отката и повторного эффекта.", "contentHtml": "
Сервис заказов уже показывает cancelled v3, а сервис исполнения всё ещё хранит awaiting-reservation v2. Оператор видит два правдивых ответа для одного заказа. Проблема начинается, когда второй сервис продолжает работу по старой проекции: готовит отгрузку, повторяет резерв или отправляет пользователю неверный результат. Цена ошибки — не только задержка. Старое состояние может запустить необратимое действие.
Такое расхождение часто называют одной фразой: «данные не синхронны». Она скрывает три разных случая. Consumer мог получить тот же event второй раз. Он мог получить новую версию раньше предыдущей. Внешнее действие могло завершиться неизвестно, а код решил автоматически отменить заказ. Для этих случаев нужны разные ключи, проверки и пути отказа.
Один сервис должен владеть доменным состоянием. Назовём его order-service. Он принимает решение, что заказ оплачен или отменён, и выпускает последовательные версии. Сервис fulfillment владеет только своей проекцией: резервом, складом и готовностью к отгрузке. Он не переписывает состояние заказа по своему локальному таймауту.
Временное расхождение допустимо, если система знает четыре факта: кто владеет состоянием, какую версию принял owner, какую версию применил consumer и какое действие запрещено до сверки. Если этих фактов нет, «eventual consistency» становится оправданием для угадывания.
| Факт | Владелец | Доказательство | Разрешённое действие |
|---|---|---|---|
paid v2 | order-service | order id, version, event id | создать проекцию ожидания резерва |
| отказ резерва | решение owner-а | reason, исходная version, compensation key | создать новое решение или остановить разбор |
cancelled v3 | order-service | новая version и событие | применить после закрытия gap |
| готовность к отгрузке | fulfillment | совпавшая version и reservation evidence | разрешить локальный шаг |
| owner version > projection version | задержка | gap и сохранённое событие | ждать, найти пропуск или передать на разбор |
Инвариант формулируется через действие: отгрузка запрещена, пока проекция исполнения не применит версию owner-а и не имеет доказательства успешного резерва. Это полезнее, чем требование мгновенно сделать все копии одинаковыми. Сервис может временно показывать старый статус, но не должен на его основе совершать дорогой шаг.
event id отвечает на вопрос о доставке: применялся ли этот конкретный конверт? Для него подходит ключ вроде source + id. orderVersion отвечает на вопрос о последовательности: какой переход должен быть следующим для одного заказа? compensationKey отвечает на вопрос о решении: создавалась ли уже эта компенсация по этой причине и исходной версии?
Эти ключи нельзя слить в один. Повтор одного event и новая версия с тем же order id — разные случаи. Два разных event id могут описывать одну и ту же версию, что является конфликтом контракта. Один event id не доказывает, что внешний резерв освобождён. Уникальность записи в локальной таблице также не подтверждает доставку в другой сервис.
const decision = inspect(projection, event); // duplicate, gap, stale или next-version\\nif (decision.action === 'next-version') applyAtomically(projection, event);\\nif (decision.action === 'gap') deferWithEvidence(projection, event);\\nif (decision.action === 'duplicate') keepStateUnchanged();Код учебный. Он показывает только ветвление после чтения фактов. В нём нет очереди, базы и повторной доставки. Реальная проверка должна быть атомарной с записью версии и ledger обработанных событий в выбранной границе хранения.
Пусть owner записал paid v2. Затем резерв вернул контролируемый отказ. Owner создаёт новое решение cancelled v3, записывает причину и один compensationKey. Consumer получает событие v3 раньше v2. Он не должен применить отмену поверх v1: v2 может содержать обязательный переход или факт, который объясняет дальнейшее решение.
В projection появляется запись: «ожидалась v2, пришла v3». Статус остаётся на v1, а событие v3 сохраняется вместе с evidence. После доставки v2 consumer выполняет переход v1 → v2, затем достаёт v3 и выполняет v2 → v3. Порядок проверяет контракт проекции, а не удачная сортировка сообщений.
Если v2 не приходит, автоматический путь заканчивается. Можно запросить повтор owner-а, найти событие по журналу или передать объект на ручной разбор. Нельзя считать gap безопасным по таймауту. Нельзя подменять проекцию строкой cancelled, если при этом исчезает факт пропущенной версии.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Один event виден дважды | duplicate доставки | есть ли его ключ в consumer ledger | подавить повтор и проверить отсутствие state change |
| Пришла v3, projection на v1 | version gap | есть ли deferred event и evidence ожидаемой v2 | отложить v3, найти v2, затем replay |
| Два разных event имеют v3 | конфликт версии | совпадают ли source и transition rule | отклонить второй event и передать owner-у |
| Owner отменён, UI готов к отгрузке | старый локальный флаг | равны ли версии и есть ли reservation evidence | заблокировать отгрузку и собрать факты |
| Внешний резерв дал timeout | результат неизвестен | есть ли подтверждение или operation key | не отменять автоматически; выполнить сверку |
| Повторно создаётся компенсация | нет уникального ключа решения | есть ли запись по order, version и reason | сделать owner ledger идемпотентным локально |
Timeout не равен отказу. Внешняя система могла принять запрос и потерять ответ. Если автоматически создать компенсацию, можно получить двойной эффект: резерв создан, заказ отменён, а повторная попытка создаёт ещё одну операцию. Без evidence безопаснее удержать состояние и запустить сверку.
Компенсация не стирает paid v2. Owner сохраняет историю и создаёт следующий переход cancelled v3 по узкому набору причин. В учебной модели допустима причина training-reservation-rejected, если она относится к версии v2. Неизвестный timeout не проходит это условие.
function createCompensation(order, failure, ledger) { return failure.reason === 'training-reservation-rejected' && failure.orderVersion === order.version && !ledger.has(order.id + ':' + order.version) ? 'create-cancelled-v3' : 'manual-review'; }Вызов с тем же входом должен вернуть уже записанное решение, а не создать вторую отмену. Это защита одного решения в одной учебной границе. Она не делает внешний API exactly-once. Для внешнего эффекта нужен отдельный operation key, владелец результата и способ проверить, что произошло после потери ответа.
В одной базе можно обновить owner и записать ledger компенсации в одной транзакции. Уникальное ограничение защищает повтор записи в этой базе. Изоляция транзакции помогает согласовать конкурентные изменения внутри неё. Но та же транзакция не отправляет надёжно сообщение в отдельный broker и не отменяет внешний резерв одной командой.
BEGIN; UPDATE orders SET state = 'cancelled', version = 3 WHERE id = 'order-417' AND state = 'paid' AND version = 2; INSERT INTO compensation_ledger (compensation_key) VALUES ('compensation:order-417:reservation:v2') ON CONFLICT (compensation_key) DO NOTHING; COMMIT;Этот SQL — только граница локального owner-а. Он не гарантирует, что consumer увидит событие, что сообщение не потеряется и что внешний резерв уже освобождён. Нельзя приписывать учебному SQL свойства, которых в нём нет.
Все id, версии, причины и состояния в статье учебные. Пример не запускает PostgreSQL, Kafka, HTTP, внешний резерв, два независимых процесса, CI или production build. Он не измеряет задержку и не доказывает отсутствие потери сообщений. Иллюстрация показывает логику state machine, а не topology конкретной платформы.
Критерий готовности можно проверить на одном тестовом заказе. Для каждого расхождения доступны owner state и version, projection state и version, source с event id, запись gap или duplicate, а также compensation key и reason. При повторе event состояние не меняется второй раз. При gap consumer не применяет более позднюю версию. При неизвестном timeout система не создаёт автоматическую компенсацию. Рискованное действие остаётся заблокированным без равной версии и evidence. Если хотя бы один факт нельзя получить из журнала или хранилища, контракт ещё не готов к безопасному восстановлению.