Files
2026-09-03 21:13:46 +03:00

8 lines
20 KiB
JSON
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
{
"index": 241,
"slug": "editorial-2021-04-field-data-consistency",
"title": "Когда сервисы видят разные данные: диагностика рассинхронизации по версиям и событиям",
"excerpt": "Заказ уже отменён в owner-сервисе, но downstream-система всё ещё разрешает отгрузку. Разбираем, как отличить задержку события от ошибки consumer-а и какое evidence собрать до replay, correction или компенсации.",
"contentHtml": "<p>Пользователь отменил заказ, а экран отгрузки ещё показывает его готовым. Через несколько минут статус обычно меняется, но иногда фоновый consumer пропускает событие, применяет его раньше предыдущего или отмечает доставку выполненной до записи projection. Цена ошибки — не только неверный текст в интерфейсе. Система может отправить товар, открыть доступ, списать деньги или создать вторую компенсацию.</p>\n<p>Не исправляйте такой случай ручной записью статуса во второй сервис. Сначала остановите рискованный эффект и соберите факты для одного object id. Иначе вы затрёте gap, потеряете event id и лишите себя ответа на вопрос, почему копии разошлись.</p>\n<h2>Тезис: одинаковый статус не доказывает одинаковое состояние</h2>\n<p>В распределённой системе один сервис владеет решением, а другие держат производные представления. Назовём первый сервис owner, а такое представление — projection. Между записью owner и применением события в projection существует граница времени. Она может быть нормальной задержкой. Она может быть отказом доставки. Она может быть ошибкой порядка или локальной транзакции.</p>\n<p>Проверяйте не только значение state, но и его версию. Owner <code>cancelled v3</code> и projection <code>awaiting-reservation v2</code> показывают, что projection не видит часть истории. Если обе системы хранят <code>cancelled</code>, но одна имеет <code>v2</code>, а другая <code>v3</code>, downstream всё ещё может работать по устаревшим правилам. State без версии — неполное доказательство.</p>\n<h2>Какие факты собрать до изменения данных</h2>\n<p>Для первого разбора достаточно связанного evidence packet. В него входят <code>objectId</code>, owner state и version, projection state и version, source и event id, а также причина перехода и compensation key, если система выполняла компенсацию. Каждый факт должен иметь источник: строка базы, запись delivery, consumer ledger или лог конкретной операции.</p>\n<pre><code>{\n \"objectId\": \"order-417\",\n \"owner\": {\"objectId\": \"order-417\", \"state\": \"cancelled\", \"version\": 3},\n \"projection\": {\"objectId\": \"order-417\", \"state\": \"awaiting-reservation\", \"version\": 2},\n \"event\": {\"id\": \"evt-order-417-cancelled-v3\", \"source\": \"order-service\", \"subject\": \"order-417\", \"type\": \"order.cancelled\", \"specversion\": \"1.0\"},\n \"reason\": \"training-reservation-rejected\",\n \"compensationKey\": \"order-417:v3:reservation\"\n}</code></pre>\n<p>Значения в примере учебные. Они не описывают production-инцидент и не доказывают доставку через конкретный брокер. Пример показывает минимальный формат проверки. Его можно перенести на HTTP-события, очередь сообщений или запись в журнале, если ваш контракт хранит те же факты.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<div class=\"table-scroll\"><table><caption>Диагностика одного расхождения без ручного затирания evidence</caption><thead><tr><th scope=\"col\">Симптом</th><th scope=\"col\">Вероятная причина</th><th scope=\"col\">Проверка</th><th scope=\"col\">Действие</th></tr></thead><tbody><tr><td>Owner v3, projection v2</td><td>Missing или deferred event</td><td>Найти delivery v3 и проверить, что projection действительно зафиксировала v2</td><td>Сохранить gap; после проверки применить или повторно обработать v3</td></tr><tr><td>v3 пришла раньше v2</td><td>Нарушен порядок или consumer не дождался версии</td><td>Проверить deferred-хранилище и sequence</td><td>Отложить v3; не применять её поверх v1</td></tr><tr><td>Один event id виден дважды</td><td>Retry доставки</td><td>Сверить consumer ledger и state transition</td><td>Подавить duplicate; проверить, что effect не повторился</td></tr><tr><td>Та же version, другой event id</td><td>Конфликт контракта</td><td>Сравнить source, payload и правило перехода</td><td>Отклонить конфликт и передать owner-у</td></tr><tr><td>Owner cancelled, projection readyToShip</td><td>Устаревшее представление</td><td>Сверить версии и evidence резерва</td><td>Заблокировать отгрузку до доказанного состояния</td></tr><tr><td>Compensation key уже существует</td><td>Повторное решение по тому же входу</td><td>Сверить object id, version и reason</td><td>Вернуть существующее решение; не создавать вторую компенсацию</td></tr></tbody></table></div>\n<h2>Механизм: версия отделяет gap от устаревшей копии</h2>\n<p>В этом контракте owner увеличивает версию при каждом принятом доменном решении. Событие переносит эту версию, стабильный event id и subject — идентификатор объекта, к которому относится событие. Projection принимает событие только после проверки subject и правила перехода. Если пришла ожидаемая следующая версия, consumer применяет её. Если версия больше ожидаемой, он сохраняет gap или deferred event. Если версия меньше текущей, он не возвращает state назад. Если версия совпадает, но event id другой, это не duplicate: событие отклоняется как конфликт и требует отдельного решения.</p>\n<pre><code>function acceptEvent(projection, event) {\n if (event.subject !== projection.objectId) {\n return { status: 'wrong-subject-rejected' };\n }\n\n if (event.version &lt; projection.version) {\n return { status: 'stale-event-rejected' };\n }\n\n if (event.version === projection.version) {\n if (event.id === projection.lastEventId) {\n return { status: 'duplicate-event-suppressed' };\n }\n return {\n status: 'same-version-event-rejected',\n conflictingEventId: event.id,\n };\n }\n\n if (event.version &gt; projection.version + 1) {\n return {\n status: 'gap-recorded',\n expected: projection.version + 1,\n deferredEventId: event.id,\n };\n }\n\n return { status: 'apply', version: event.version, eventId: event.id };\n}</code></pre>\n<p>Это учебная функция. Она не запускает broker, не пишет базу и не делает транзакцию между owner и projection. Её задача — явно разделить пять путей: чужой subject, stale event, duplicate, gap и конфликт одной version. Реальный consumer должен атомарно или иным проверяемым способом связать запись ledger и изменение projection. Если ledger помечен раньше state update, появится ложное «event уже применён». Если state записан раньше ledger, retry должен быть безопасен.</p>\n<h2>Иллюстрация маршрута диагностики</h2>\n<figure><img src=\"/assets/editorial/2021/data-consistency-diagnosis-2021.svg\" alt=\"Схема диагностики расхождения owner и projection через версии, event id и compensation key\" loading=\"lazy\" /><figcaption>Сначала блокируется рискованный следующий шаг, затем сохраняется evidence. Только после этого выбирают повторную обработку, correction или ручное решение.</figcaption></figure>\n<p>Для события <code>cancelled v3</code>, которое пришло раньше <code>v2</code>, безопасный ответ — не «подождать ещё минуту». Consumer должен сохранить v3 вместе с ожидаемой версией 2 и его event id. Когда v2 появится, он применяет v2 и повторно рассматривает отложенную v3. Если транспорт не гарантирует получение v2, нужен отдельный контракт поиска пропущенного перехода или ручной маршрут восстановления. Нельзя объявлять v3 конечным состоянием, пока не проверен порядок.</p>\n<h2>Компенсация не заменяет подтверждение внешнего эффекта</h2>\n<p>Компенсация — новое доменное решение, а не удаление старой записи. Например, owner получает <code>training-reservation-rejected</code>, проверяет <code>orderId</code> и исходную версию, затем создаёт решение <code>cancelled v3</code> с одним <code>compensationKey</code>. Уникальный ключ защищает owner от повторной записи одного и того же решения.</p>\n<p>Но ключ не доказывает, что внешний резерв отменён. Timeout означает только, что ответ не получен. Внешняя система могла принять отмену, отклонить её или выполнить исходный резерв до обрыва связи. Поэтому результат компенсации должен иметь собственное evidence: ответ владельца внешнего эффекта, подтверждённый event или ручное решение с журналом. Не называйте компенсацию завершённой только потому, что запись с ключом появилась в локальной базе.</p>\n<pre><code>BEGIN;\n -- Требуется UNIQUE(compensation_key) в этом owner-хранилище.\n INSERT INTO compensation_ledger (compensation_key, order_id, source_version)\n VALUES ('order-417:v3:reservation', 'order-417', 2)\n ON CONFLICT (compensation_key) DO NOTHING;\n\n INSERT INTO order_events (order_id, state, version)\n VALUES ('order-417', 'cancelled', 3);\nCOMMIT;</code></pre>\n<p>Учебный SQL показывает локальное уникальное ограничение и идемпотентный путь записи. Он не делает атомарными изменения в другой базе, отправку сообщения и внешний вызов. Реализация должна отдельно определить границы транзакции, retry и восстановление после частичного успеха.</p>\n<h2>Право на следующий эффект</h2>\n<p>Projection не должна разрешать отгрузку только по локальному флагу <code>readyToShip</code>. Перед необратимым или дорогим действием она сверяет object id, owner state, свою версию и нужное локальное evidence. В учебном контракте отгрузка разрешена, когда состояние <code>paid</code>, версии равны, а резерв подтверждён. После <code>cancelled v3</code> функция возвращает запрет, даже если старый UI-флаг ещё не очищен.</p>\n<pre><code>function canShip(owner, projection) {\n return owner.objectId === projection.objectId\n &amp;&amp; owner.state === 'paid'\n &amp;&amp; projection.state === 'readyToShip'\n &amp;&amp; owner.version === projection.version\n &amp;&amp; projection.reservationEvidence === 'confirmed';\n}</code></pre>\n<p>Это не универсальное бизнес-правило. В некоторых доменах действие обратимо. В других нужен manual decision или отдельная компенсация. Универсальным остаётся вопрос: какой эффект нужно запретить, пока состояние не подтверждено владельцем и актуальной версией?</p>\n<h2>Порядок действий</h2>\n<ol><li>Остановить отгрузку, доступ, списание или другой рискованный эффект для конкретного object id.</li><li>Сохранить owner state и version, projection state и version, source, event id, reason и compensation key.</li><li>Сравнить версии. При owner &gt; projection искать missing или deferred event. При равных версиях проверять правило перехода и локальный effect evidence.</li><li>Проверить consumer ledger отдельно от projection. Duplicate event id, stale version и same-version conflict не сводить к одной операции удаления.</li><li>Проверить компенсацию: известны ли причина и исходная версия, уникален ли ключ, создано ли новое owner state.</li><li>Выбрать действие по контракту: controlled replay, поиск пропущенного события, correction с журналом или manual decision. Не повторять внешний timeout вслепую.</li><li>После восстановления проверить найденную границу отдельным тестом или интеграционной проверкой: gap, duplicate, stale event, split между ledger и effect или неопределённый внешний результат.</li></ol>\n<h2>Ограничения</h2>\n<p>Версия и event id не гарантируют доставку. Они делают пропуск и повтор различимыми. Уникальное ограничение защищает локальную запись, но не несколько сервисов сразу. Eventual consistency не означает, что любое запаздывание безопасно: следующий эффект может произойти в неправильном состоянии. Replay не является исправлением сам по себе. Он безопасен только при известном контракте идемпотентности и сохранённом порядке.</p>\n<p>Статья не утверждает production-результаты. Кодовые фрагменты учебные. Они не запускают реальную базу, брокер, внешний резерв, HTTP-клиент или deployment. Для рабочей системы отдельно проверяйте storage, transport, retry policy, транзакционный порядок и владельца внешнего действия.</p>\n<h2>Проверяемый критерий готовности</h2>\n<p>Разбор готов, когда для одного object id можно воспроизвести цепочку owner decision → event delivery → consumer transition и ответить на четыре вопроса: какая версия является последней, какой event id её переносит, почему projection отстаёт или расходится и какой рискованный эффект заблокирован. После исправления повторная доставка не создаёт второй transition или компенсацию, stale event не возвращает состояние назад, а same-version conflict не применяется молча. Если хотя бы один ответ основан на предположении, случай ещё не закрыт.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://github.com/cloudevents/spec/releases/tag/ce%40v1.0.1\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Specification v1.0.1: release record</a> — официальная запись релиза; в core-модели обязательны <code>id</code>, <code>source</code>, <code>specversion</code> и <code>type</code>, а <code>subject</code> относится к дополнительному контексту. Учебный конверт выше не является полной реализацией стандарта.</li><li><a href=\"https://www.postgresql.org/docs/13/transaction-iso.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 13: Transaction Isolation</a> — официальное описание границ локальной транзакции и serialization failure.</li><li><a href=\"https://www.postgresql.org/docs/13/sql-insert.html\" target=\"_blank\" rel=\"noopener noreferrer\">PostgreSQL 13: INSERT и ON CONFLICT</a> — официальная семантика локального уникального ограничения и conflict path.</li></ul>"
}