8 lines
18 KiB
JSON
8 lines
18 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> отвечает на вопрос о доставке: применялся ли этот конкретный конверт? Для него подходит ключ вроде <code>source + id</code>. <code>orderVersion</code> отвечает на вопрос о последовательности: какой переход должен быть следующим для одного заказа? <code>compensationKey</code> отвечает на вопрос о решении: создавалась ли уже эта компенсация по этой причине и исходной версии?</p><p>Эти ключи нельзя слить в один. Повтор одного event и новая версия с тем же order id — разные случаи. Два разных event id могут описывать одну и ту же версию, что является конфликтом контракта. Один event id не доказывает, что внешний резерв освобождён. Уникальность записи в локальной таблице также не подтверждает доставку в другой сервис.</p><pre><code>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();</code></pre><p>Код учебный. Он показывает только ветвление после чтения фактов. В нём нет очереди, базы и повторной доставки. Реальная проверка должна быть атомарной с записью версии и 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>duplicate доставки</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 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'; }</code></pre><p>Вызов с тем же входом должен вернуть уже записанное решение, а не создать вторую отмену. Это защита одного решения в одной учебной границе. Она не делает внешний API exactly-once. Для внешнего эффекта нужен отдельный operation key, владелец результата и способ проверить, что произошло после потери ответа.</p><h2>Локальная транзакция не пересекает границу сервиса</h2><p>В одной базе можно обновить owner и записать ledger компенсации в одной транзакции. Уникальное ограничение защищает повтор записи в этой базе. Изоляция транзакции помогает согласовать конкурентные изменения внутри неё. Но та же транзакция не отправляет надёжно сообщение в отдельный broker и не отменяет внешний резерв одной командой.</p><pre><code>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;</code></pre><p>Этот SQL — только граница локального owner-а. Он не гарантирует, что consumer увидит событие, что сообщение не потеряется и что внешний резерв уже освобождён. Нельзя приписывать учебному 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, версии, причины и состояния в статье учебные. Пример не запускает 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> — официальный текст спецификации для контекста события и его атрибутов.</li><li><a href=\"https://www.postgresql.org/docs/current/transaction-iso.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL: 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>"
|
||
}
|