diff --git a/editorial/agent-rewrites/242.json b/editorial/agent-rewrites/242.json index 69f2079..7eb8472 100644 --- a/editorial/agent-rewrites/242.json +++ b/editorial/agent-rewrites/242.json @@ -3,5 +3,5 @@ "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 v2order-serviceorder id, version, event idсоздать проекцию ожидания резерва
отказ резерварешение owner-аreason, исходная version, compensation keyсоздать новое решение или остановить разбор
cancelled v3order-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 может содержать обязательный переход или факт, который объясняет дальнейшее решение.

\"Owner
Gap — это фиксируемое ожидание: consumer откладывает v3, применяет v2, затем повторяет v3. Повтор того же event не создаёт нового перехода.

В projection появляется запись: «ожидалась v2, пришла v3». Статус остаётся на v1, а событие v3 сохраняется вместе с evidence. После доставки v2 consumer выполняет переход v1 → v2, затем достаёт v3 и выполняет v2 → v3. Порядок проверяет контракт проекции, а не удачная сортировка сообщений.

Если v2 не приходит, автоматический путь заканчивается. Можно запросить повтор owner-а, найти событие по журналу или передать объект на ручной разбор. Нельзя считать gap безопасным по таймауту. Нельзя подменять проекцию строкой cancelled, если при этом исчезает факт пропущенной версии.

Симптом → причина → проверка → действие

Диагностика расхождения для одного object id
СимптомПричинаПроверкаДействие
Один event виден дваждыduplicate доставкиесть ли его ключ в consumer ledgerподавить повтор и проверить отсутствие state change
Пришла v3, projection на v1version 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 безопаснее удержать состояние и запустить сверку.

Компенсация — новое решение, а не распределённый rollback

Компенсация не стирает 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 свойства, которых в нём нет.

Порядок действий

  1. Выбрать owner для каждого доменного состояния и зафиксировать его право менять состояние.
  2. Назвать рискованное следующее действие: отгрузка, доступ, списание или письмо. Сформулировать запрет через state, version и evidence.
  3. Разделить event id, version объекта и compensation key. Для каждого указать место хранения.
  4. В consumer хранить последнюю применённую версию и обработанные event id. При gap сохранять evidence и не менять projection.
  5. Описать узкий список причин для компенсации. Не переводить неизвестный отказ в отмену автоматически.
  6. Защитить повтор компенсации локальным ключом. Отдельно защитить consumer от duplicate event.
  7. Проверить fixture для duplicate, gap, stale event, same-version conflict и неизвестного timeout.
  8. После этого проверить реальные database, broker и внешний API интеграционными тестами.

Ограничения и критерий готовности

Все 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. Если хотя бы один факт нельзя получить из журнала или хранилища, контракт ещё не готов к безопасному восстановлению.

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

" + "contentHtml": "

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

Такое расхождение часто называют одной фразой: «данные не синхронны». Она скрывает три разных случая. Consumer мог получить тот же event второй раз. Он мог получить новую версию раньше предыдущей. Внешнее действие могло завершиться неизвестно, а код решил автоматически отменить заказ. Для этих случаев нужны разные ключи, проверки и пути отказа.

Тезис: согласованность начинается с границы решения

Один сервис должен владеть доменным состоянием. Назовём его order-service. Он принимает решение, что заказ оплачен или отменён, и выпускает последовательные версии. Сервис fulfillment владеет только своей проекцией: резервом, складом и готовностью к отгрузке. Он не переписывает состояние заказа по своему локальному таймауту.

Временное расхождение допустимо, если система знает четыре факта: кто владеет состоянием, какую версию принял owner, какую версию применил consumer и какое действие запрещено до сверки. Если этих фактов нет, «eventual consistency» становится оправданием для угадывания.

Контракт учебного заказа на границе сервисов
ФактВладелецДоказательствоРазрешённое действие
paid v2order-serviceorder id, version, event idсоздать проекцию ожидания резерва
отказ резерварешение owner-аreason, исходная version, compensation keyсоздать новое решение или остановить разбор
cancelled v3order-serviceновая version и событиеприменить после закрытия gap
готовность к отгрузкеfulfillmentсовпавшая version и reservation evidenceразрешить локальный шаг
owner version > projection versionзадержкаgap и сохранённое событиеждать, найти пропуск или передать на разбор

Инвариант формулируется через действие: отгрузка запрещена, пока проекция исполнения не применит версию owner-а и не имеет доказательства успешного резерва. Это полезнее, чем требование мгновенно сделать все копии одинаковыми. Сервис может временно показывать старый статус, но не должен на его основе совершать дорогой шаг.

Три ключа, три вопроса

event id отвечает на вопрос о конкретном событии: видел ли consumer этот конверт раньше? Для CloudEvents проверяют составной ключ source + id. orderVersion отвечает на вопрос о последовательности: какой переход должен быть следующим для одного заказа? compensationKey отвечает на вопрос о решении: создавалась ли уже эта компенсация по данной причине и исходной версии?

Эти ключи нельзя слить в один. Повтор одного event и новая версия с тем же order id — разные случаи. Два разных event id могут описывать одну и ту же версию, что является конфликтом контракта. Один event id не доказывает, что внешний резерв освобождён. Уникальность записи в локальной таблице также не подтверждает доставку в другой сервис.

CloudEvents задаёт формат события и его обязательную идентичность, но не назначает бизнес-порядок версий и не делает внешний вызов атомарным. Поэтому orderVersion и compensationKey — части нашего доменного контракта, а не свойства самого формата события.

const state = { version: 1, seen: new Set(), deferred: new Map() };\nconst v2 = { source: 'order-service', id: 'order-417:v2', orderVersion: 2 };\nconst v3 = { source: 'order-service', id: 'order-417:v3', orderVersion: 3 };\n\nfunction consume(event) {\n  const key = event.source + ':' + event.id;\n  if (state.seen.has(key)) return 'duplicate';\n  if (event.orderVersion > state.version + 1) {\n    state.deferred.set(event.orderVersion, event);\n    return 'gap';\n  }\n  if (event.orderVersion <= state.version) {\n    state.seen.add(key);\n    return 'stale';\n  }\n  state.version = event.orderVersion;\n  state.seen.add(key);\n  return 'applied';\n}\n\nconst first = consume(v3); // gap: v2 ещё не применена\nconst second = consume(v2); // applied\nconst pending = state.deferred.get(state.version + 1);\nstate.deferred.delete(state.version + 1);\nconst replayed = consume(pending); // applied: v3 теперь следующая\nconst duplicate = consume(v3); // duplicate: состояние не меняется\nconsole.log({ first, second, replayed, duplicate, version: state.version });

Это запускаемый пример для Node.js: он выводит gap, затем два applied, затем duplicate, а итоговая версия равна 3. В настоящем consumer чтение состояния, запись версии и ledger обработанных событий должны быть атомарны в выбранной базе. Для конкурентной обработки также нужна уникальная защита ключей и повторная попытка при конфликте транзакций.

Пример: версия пришла не по порядку

Пусть owner записал paid v2. Затем резерв вернул контролируемый отказ. Owner создаёт новое решение cancelled v3, записывает причину и один compensationKey. Consumer получает событие v3 раньше v2. Он не должен применить отмену поверх v1: v2 может содержать обязательный переход или факт, который объясняет дальнейшее решение.

\"Owner
Gap — это фиксируемое ожидание: consumer откладывает v3, применяет v2, затем повторяет v3. Повтор того же event не создаёт нового перехода.

В projection появляется запись: «ожидалась v2, пришла v3». Статус остаётся на v1, а событие v3 сохраняется вместе с evidence. После доставки v2 consumer выполняет переход v1 → v2, затем достаёт v3 и выполняет v2 → v3. Порядок проверяет контракт проекции, а не удачную сортировку сообщений.

Если v2 не приходит, автоматический путь заканчивается. Можно запросить повтор owner-а, найти событие по журналу или передать объект на ручной разбор. Нельзя считать gap безопасным по таймауту. Нельзя подменять проекцию строкой cancelled, если при этом исчезает факт пропущенной версии.

Симптом → причина → проверка → действие

Диагностика расхождения для одного object id
СимптомПричинаПроверкаДействие
Один event виден дваждыповторная доставкаесть ли его ключ в consumer ledgerподавить повтор и проверить отсутствие state change
Пришла v3, projection на v1version 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 безопаснее удержать состояние и запустить сверку.

Компенсация — новое решение, а не распределённый rollback

Компенсация не стирает paid v2. Owner сохраняет историю и создаёт следующий переход cancelled v3 по узкому набору причин. В учебной модели допустима причина training-reservation-rejected, если она относится к версии v2. Неизвестный timeout не проходит это условие.

function compensationKey(orderId, version, reason) {\n  return 'compensation:' + orderId + ':' + version + ':' + reason;\n}\n\nfunction decideCompensation(order, failure, ledger) {\n  const key = compensationKey(order.id, failure.orderVersion, failure.reason);\n  if (failure.reason !== 'training-reservation-rejected') {\n    return { action: 'manual-review', key };\n  }\n  if (failure.orderVersion !== order.version) {\n    return { action: 'manual-review', key };\n  }\n  if (ledger.has(key)) return { action: 'reuse', key };\n  return { action: 'create-cancelled', key };\n}

Вызов с тем же входом должен вернуть уже записанное решение, а не создать вторую отмену. Это защита одного решения в одной учебной границе. Она не делает внешний API exactly-once. Для внешнего эффекта нужен отдельный operation key, владелец результата и способ проверить, что произошло после потери ответа.

Идемпотентность producer-а тоже не закрывает весь путь. В документации Kafka 2.7 указано, что application-level resend нельзя дедуплицировать этой настройкой, а гарантия producer-а действует в рамках одной сессии. Если consumer вызывает внешний API или меняет свою базу, ему всё равно нужны собственный ledger и ключ операции.

Локальная транзакция не пересекает границу сервиса

В одной базе можно обновить owner, записать ledger компенсации и положить событие в outbox в одной транзакции. Уникальное ограничение защищает повтор записи в этой базе. Изоляция транзакции помогает согласовать конкурентные изменения внутри неё, но выбранный уровень нужно проверять отдельно: например, PostgreSQL 13 использует Read Committed по умолчанию, и сложная проверка может требовать более строгого контракта или явной блокировки.

BEGIN;\nUPDATE orders\n   SET state = 'cancelled', version = 3\n WHERE id = 'order-417' AND state = 'paid' AND version = 2\n RETURNING id, version;\nINSERT INTO compensation_ledger\n  (order_id, order_version, reason, compensation_key)\nVALUES\n  ('order-417', 2, 'training-reservation-rejected',\n   'compensation:order-417:2:training-reservation-rejected')\nON CONFLICT (compensation_key) DO NOTHING;\nINSERT INTO outbox (event_id, aggregate_id, aggregate_version, payload)\nVALUES ('order-417:v3', 'order-417', 3, '{\\\"state\\\":\\\"cancelled\\\"}')\nON CONFLICT (event_id) DO NOTHING;\nCOMMIT;

В прикладном коде нужно проверить, что UPDATE ... RETURNING изменил ровно одну строку; иначе транзакцию следует отклонить, а не записывать компенсацию для устаревшей версии. Outbox помогает надёжно передать намерение из локальной базы в publisher, но не подтверждает результат внешнего резерва.

Этот SQL — только граница локального owner-а. Он не гарантирует, что consumer увидит событие, что broker не потеряет сообщение и что внешний резерв уже освобождён. Нельзя приписывать учебному SQL свойства, которых в нём нет.

Порядок действий

  1. Выбрать owner для каждого доменного состояния и зафиксировать его право менять состояние.
  2. Назвать рискованное следующее действие: отгрузка, доступ, списание или письмо. Сформулировать запрет через state, version и evidence.
  3. Разделить event id, version объекта и compensation key. Для каждого указать место хранения.
  4. В consumer хранить последнюю применённую версию и обработанные event id. При gap сохранять evidence и не менять projection.
  5. Описать узкий список причин для компенсации. Не переводить неизвестный отказ в отмену автоматически.
  6. Защитить повтор компенсации локальным ключом. Отдельно защитить consumer от duplicate event.
  7. Проверить fixture для duplicate, gap, stale event, same-version conflict и неизвестного timeout.
  8. После этого проверить реальные database, broker и внешний API интеграционными тестами.

Ограничения и критерий готовности

Все id, версии, причины и состояния в статье учебные. Пример запускает только чистый Node.js-скрипт из первого блока; он не поднимает 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. Если хотя бы один факт нельзя получить из журнала или хранилища, контракт ещё не готов к безопасному восстановлению.

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

" }