8 lines
22 KiB
JSON
8 lines
22 KiB
JSON
{
|
||
"index": 242,
|
||
"slug": "editorial-2021-04-mechanism-data-consistency",
|
||
"title": "Согласованность между сервисами: почему event id не заменяет версию и компенсацию",
|
||
"excerpt": "Повтор сообщения, пропущенная версия и неизвестный результат действия создают разные виды расхождения. Учебный пример показывает, как owner, consumer и компенсация удерживают состояние от отката и повторного эффекта.",
|
||
"contentHtml": "<p>Сервис заказов уже показывает <code>cancelled v3</code>, а сервис исполнения всё ещё хранит <code>awaiting-reservation v2</code>. Оператор видит два правдивых ответа для одного заказа. Проблема начинается, когда второй сервис продолжает работу по старой проекции: готовит отгрузку, повторяет резерв или отправляет пользователю неверный результат. Цена ошибки — не только задержка. Старое состояние может запустить необратимое действие.</p><p>Такое расхождение часто называют одной фразой: «данные не синхронны». Она скрывает три разных случая. Consumer мог получить тот же event второй раз. Он мог получить новую версию раньше предыдущей. Внешнее действие могло завершиться неизвестно, а код решил автоматически отменить заказ. Для этих случаев нужны разные ключи, проверки и пути отказа.</p><h2>Тезис: согласованность начинается с границы решения</h2><p>Один сервис должен владеть доменным состоянием. Назовём его <code>order-service</code>. Он принимает решение, что заказ оплачен или отменён, и выпускает последовательные версии. Сервис <code>fulfillment</code> владеет только своей проекцией: резервом, складом и готовностью к отгрузке. Он не переписывает состояние заказа по своему локальному таймауту.</p><p>Временное расхождение допустимо, если система знает четыре факта: кто владеет состоянием, какую версию принял owner, какую версию применил consumer и какое действие запрещено до сверки. Если этих фактов нет, «eventual consistency» становится оправданием для угадывания.</p><div class=\"table-scroll\"><table><caption>Контракт учебного заказа на границе сервисов</caption><thead><tr><th scope=\"col\">Факт</th><th scope=\"col\">Владелец</th><th scope=\"col\">Доказательство</th><th scope=\"col\">Разрешённое действие</th></tr></thead><tbody><tr><td><code>paid v2</code></td><td><code>order-service</code></td><td>order id, version, event id</td><td>создать проекцию ожидания резерва</td></tr><tr><td>отказ резерва</td><td>решение owner-а</td><td>reason, исходная version, compensation key</td><td>создать новое решение или остановить разбор</td></tr><tr><td><code>cancelled v3</code></td><td><code>order-service</code></td><td>новая version и событие</td><td>применить после закрытия gap</td></tr><tr><td>готовность к отгрузке</td><td><code>fulfillment</code></td><td>совпавшая version и reservation evidence</td><td>разрешить локальный шаг</td></tr><tr><td>owner version > projection version</td><td>задержка</td><td>gap и сохранённое событие</td><td>ждать, найти пропуск или передать на разбор</td></tr></tbody></table></div><p>Инвариант формулируется через действие: <strong>отгрузка запрещена, пока проекция исполнения не применит версию owner-а и не имеет доказательства успешного резерва</strong>. Это полезнее, чем требование мгновенно сделать все копии одинаковыми. Сервис может временно показывать старый статус, но не должен на его основе совершать дорогой шаг.</p><h2>Три ключа, три вопроса</h2><p><code>event id</code> отвечает на вопрос о конкретном событии: видел ли consumer этот конверт раньше? Для CloudEvents проверяют составной ключ <code>source + id</code>. <code>orderVersion</code> отвечает на вопрос о последовательности: какой переход должен быть следующим для одного заказа? <code>compensationKey</code> отвечает на вопрос о решении: создавалась ли уже эта компенсация по данной причине и исходной версии?</p><p>Эти ключи нельзя слить в один. Повтор одного event и новая версия с тем же order id — разные случаи. Два разных event id могут описывать одну и ту же версию, что является конфликтом контракта. Один event id не доказывает, что внешний резерв освобождён. Уникальность записи в локальной таблице также не подтверждает доставку в другой сервис.</p><p>CloudEvents задаёт формат события и его обязательную идентичность, но не назначает бизнес-порядок версий и не делает внешний вызов атомарным. Поэтому <code>orderVersion</code> и <code>compensationKey</code> — части нашего доменного контракта, а не свойства самого формата события.</p><pre><code>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 });</code></pre><p>Это запускаемый пример для Node.js: он выводит <code>gap</code>, затем два <code>applied</code>, затем <code>duplicate</code>, а итоговая версия равна <code>3</code>. В настоящем consumer чтение состояния, запись версии и ledger обработанных событий должны быть атомарны в выбранной базе. Для конкурентной обработки также нужна уникальная защита ключей и повторная попытка при конфликте транзакций.</p><h2>Пример: версия пришла не по порядку</h2><p>Пусть owner записал <code>paid v2</code>. Затем резерв вернул контролируемый отказ. Owner создаёт новое решение <code>cancelled v3</code>, записывает причину и один <code>compensationKey</code>. Consumer получает событие v3 раньше v2. Он не должен применить отмену поверх v1: v2 может содержать обязательный переход или факт, который объясняет дальнейшее решение.</p><figure><img src=\"/assets/editorial/2021/data-consistency-compensation-2021.svg\" alt=\"Owner переводит заказ из paid v2 в cancelled v3, а consumer откладывает v3 до применения v2 и затем повторно обрабатывает её\" loading=\"lazy\" /><figcaption>Gap — это фиксируемое ожидание: consumer откладывает v3, применяет v2, затем повторяет v3. Повтор того же event не создаёт нового перехода.</figcaption></figure><p>В projection появляется запись: «ожидалась v2, пришла v3». Статус остаётся на v1, а событие v3 сохраняется вместе с evidence. После доставки v2 consumer выполняет переход <code>v1 → v2</code>, затем достаёт v3 и выполняет <code>v2 → v3</code>. Порядок проверяет контракт проекции, а не удачную сортировку сообщений.</p><p>Если v2 не приходит, автоматический путь заканчивается. Можно запросить повтор owner-а, найти событие по журналу или передать объект на ручной разбор. Нельзя считать gap безопасным по таймауту. Нельзя подменять проекцию строкой <code>cancelled</code>, если при этом исчезает факт пропущенной версии.</p><h2>Симптом → причина → проверка → действие</h2><div class=\"table-scroll\"><table><caption>Диагностика расхождения для одного object id</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Один event виден дважды</td><td>повторная доставка</td><td>есть ли его ключ в consumer ledger</td><td>подавить повтор и проверить отсутствие state change</td></tr><tr><td>Пришла v3, projection на v1</td><td>version gap</td><td>есть ли deferred event и evidence ожидаемой v2</td><td>отложить v3, найти v2, затем replay</td></tr><tr><td>Два разных event имеют v3</td><td>конфликт версии</td><td>совпадают ли source и transition rule</td><td>отклонить второй event и передать owner-у</td></tr><tr><td>Owner отменён, UI готов к отгрузке</td><td>старый локальный флаг</td><td>равны ли версии и есть ли reservation evidence</td><td>заблокировать отгрузку и собрать факты</td></tr><tr><td>Внешний резерв дал timeout</td><td>результат неизвестен</td><td>есть ли подтверждение или operation key</td><td>не отменять автоматически; выполнить сверку</td></tr><tr><td>Повторно создаётся компенсация</td><td>нет уникального ключа решения</td><td>есть ли запись по order, version и reason</td><td>сделать owner ledger идемпотентным локально</td></tr></tbody></table></div><p>Timeout не равен отказу. Внешняя система могла принять запрос и потерять ответ. Если автоматически создать компенсацию, можно получить двойной эффект: резерв создан, заказ отменён, а повторная попытка создаёт ещё одну операцию. Без evidence безопаснее удержать состояние и запустить сверку.</p><h2>Компенсация — новое решение, а не распределённый rollback</h2><p>Компенсация не стирает <code>paid v2</code>. Owner сохраняет историю и создаёт следующий переход <code>cancelled v3</code> по узкому набору причин. В учебной модели допустима причина <code>training-reservation-rejected</code>, если она относится к версии v2. Неизвестный timeout не проходит это условие.</p><pre><code>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}</code></pre><p>Вызов с тем же входом должен вернуть уже записанное решение, а не создать вторую отмену. Это защита одного решения в одной учебной границе. Она не делает внешний API exactly-once. Для внешнего эффекта нужен отдельный operation key, владелец результата и способ проверить, что произошло после потери ответа.</p><p>Идемпотентность producer-а тоже не закрывает весь путь. В документации Kafka 2.7 указано, что application-level resend нельзя дедуплицировать этой настройкой, а гарантия producer-а действует в рамках одной сессии. Если consumer вызывает внешний API или меняет свою базу, ему всё равно нужны собственный ledger и ключ операции.</p><h2>Локальная транзакция не пересекает границу сервиса</h2><p>В одной базе можно обновить owner, записать ledger компенсации и положить событие в outbox в одной транзакции. Уникальное ограничение защищает повтор записи в этой базе. Изоляция транзакции помогает согласовать конкурентные изменения внутри неё, но выбранный уровень нужно проверять отдельно: например, PostgreSQL 13 использует Read Committed по умолчанию, и сложная проверка может требовать более строгого контракта или явной блокировки.</p><pre><code>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;</code></pre><p>В прикладном коде нужно проверить, что <code>UPDATE ... RETURNING</code> изменил ровно одну строку; иначе транзакцию следует отклонить, а не записывать компенсацию для устаревшей версии. Outbox помогает надёжно передать намерение из локальной базы в publisher, но не подтверждает результат внешнего резерва.</p><p>Этот SQL — только граница локального owner-а. Он не гарантирует, что consumer увидит событие, что broker не потеряет сообщение и что внешний резерв уже освобождён. Нельзя приписывать учебному SQL свойства, которых в нём нет.</p><h2>Порядок действий</h2><ol><li>Выбрать owner для каждого доменного состояния и зафиксировать его право менять состояние.</li><li>Назвать рискованное следующее действие: отгрузка, доступ, списание или письмо. Сформулировать запрет через state, version и evidence.</li><li>Разделить event id, version объекта и compensation key. Для каждого указать место хранения.</li><li>В consumer хранить последнюю применённую версию и обработанные event id. При gap сохранять evidence и не менять projection.</li><li>Описать узкий список причин для компенсации. Не переводить неизвестный отказ в отмену автоматически.</li><li>Защитить повтор компенсации локальным ключом. Отдельно защитить consumer от duplicate event.</li><li>Проверить fixture для duplicate, gap, stale event, same-version conflict и неизвестного timeout.</li><li>После этого проверить реальные database, broker и внешний API интеграционными тестами.</li></ol><h2>Ограничения и критерий готовности</h2><p>Все id, версии, причины и состояния в статье учебные. Пример запускает только чистый Node.js-скрипт из первого блока; он не поднимает PostgreSQL, Kafka, HTTP, внешний резерв, два независимых процесса, CI или production build. Он не измеряет задержку и не доказывает отсутствие потери сообщений. Иллюстрация показывает логику state machine, а не topology конкретной платформы.</p><p>Критерий готовности можно проверить на одном тестовом заказе. Для каждого расхождения доступны owner state и version, projection state и version, source с event id, запись gap или duplicate, а также compensation key и reason. При повторе event состояние не меняется второй раз. При gap consumer не применяет более позднюю версию. При неизвестном timeout система не создаёт автоматическую компенсацию. Рискованное действие остаётся заблокированным без равной версии и evidence. Если хотя бы один факт нельзя получить из журнала или хранилища, контракт ещё не готов к безопасному восстановлению.</p><h2>Проверяемые источники</h2><ul><li><a href=\"https://github.com/cloudevents/spec/blob/v1.0.1/spec.md\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Specification v1.0.1</a> — официальный текст спецификации для идентичности события, ролей producer/consumer и границ формата.</li><li><a href=\"https://www.postgresql.org/docs/13/transaction-iso.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 13: Transaction Isolation</a> — версия документации, близкая к дате исходной статьи, о границах изоляции и конкурентных транзакциях.</li><li><a href=\"https://kafka.apache.org/27/javadoc/org/apache/kafka/clients/producer/KafkaProducer.html\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka 2.7.0 KafkaProducer API</a> — официальная versioned-документация об идемпотентности producer, повторной отправке и транзакциях.</li></ul>"
|
||
}
|