{ "index": 241, "slug": "editorial-2021-04-field-data-consistency", "title": "Когда сервисы видят разные данные: диагностика рассинхронизации по версиям и событиям", "excerpt": "Заказ уже отменён в owner-сервисе, но downstream-система всё ещё разрешает отгрузку. Разбираем, как отличить задержку события от ошибки consumer-а и какое evidence собрать до replay, correction или компенсации.", "contentHtml": "
Пользователь отменил заказ, а экран отгрузки ещё показывает его готовым. Через несколько минут статус обычно меняется, но иногда фоновый consumer пропускает событие, применяет его раньше предыдущего или отмечает доставку выполненной до записи projection. Цена ошибки — не только неверный текст в интерфейсе. Система может отправить товар, открыть доступ, списать деньги или создать вторую компенсацию.
\nНе исправляйте такой случай ручной записью статуса во второй сервис. Сначала остановите рискованный эффект и соберите факты для одного object id. Иначе вы затрёте gap, потеряете event id и лишите себя ответа на вопрос, почему копии разошлись.
\nВ распределённой системе один сервис владеет решением, а другие держат производные представления. Назовём первый сервис owner, а такое представление — projection. Между записью owner и применением события в projection существует граница времени. Она может быть нормальной задержкой. Она может быть отказом доставки. Она может быть ошибкой порядка или локальной транзакции.
\nПроверяйте не только значение state, но и его версию. Owner cancelled v3 и projection awaiting-reservation v2 показывают, что projection не видит часть истории. Если обе системы хранят cancelled, но одна имеет v2, а другая v3, downstream всё ещё может работать по устаревшим правилам. State без версии — неполное доказательство.
Для первого разбора достаточно связанного evidence packet. В него входят objectId, owner state и version, projection state и version, source и event id, а также причина перехода и compensation key, если система выполняла компенсацию. Каждый факт должен иметь источник: строка базы, запись delivery, consumer ledger или лог конкретной операции.
{\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}\nЗначения в примере учебные. Они не описывают production-инцидент и не доказывают доставку через конкретный брокер. Пример показывает минимальный формат проверки. Его можно перенести на HTTP-события, очередь сообщений или запись в журнале, если ваш контракт хранит те же факты.
\n| Симптом | Вероятная причина | Проверка | Действие |
|---|---|---|---|
| Owner v3, projection v2 | Missing или deferred event | Найти delivery v3 и проверить, что projection действительно зафиксировала v2 | Сохранить gap; после проверки применить или повторно обработать v3 |
| v3 пришла раньше v2 | Нарушен порядок или consumer не дождался версии | Проверить deferred-хранилище и sequence | Отложить v3; не применять её поверх v1 |
| Один event id виден дважды | Retry доставки | Сверить consumer ledger и state transition | Подавить duplicate; проверить, что effect не повторился |
| Та же version, другой event id | Конфликт контракта | Сравнить source, payload и правило перехода | Отклонить конфликт и передать owner-у |
| Owner cancelled, projection readyToShip | Устаревшее представление | Сверить версии и evidence резерва | Заблокировать отгрузку до доказанного состояния |
| Compensation key уже существует | Повторное решение по тому же входу | Сверить object id, version и reason | Вернуть существующее решение; не создавать вторую компенсацию |
В этом контракте owner увеличивает версию при каждом принятом доменном решении. Событие переносит эту версию, стабильный event id и subject — идентификатор объекта, к которому относится событие. Projection принимает событие только после проверки subject и правила перехода. Если пришла ожидаемая следующая версия, consumer применяет её. Если версия больше ожидаемой, он сохраняет gap или deferred event. Если версия меньше текущей, он не возвращает state назад. Если версия совпадает, но event id другой, это не duplicate: событие отклоняется как конфликт и требует отдельного решения.
\nfunction acceptEvent(projection, event) {\n if (event.subject !== projection.objectId) {\n return { status: 'wrong-subject-rejected' };\n }\n\n if (event.version < 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 > 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}\nЭто учебная функция. Она не запускает broker, не пишет базу и не делает транзакцию между owner и projection. Её задача — явно разделить пять путей: чужой subject, stale event, duplicate, gap и конфликт одной version. Реальный consumer должен атомарно или иным проверяемым способом связать запись ledger и изменение projection. Если ledger помечен раньше state update, появится ложное «event уже применён». Если state записан раньше ledger, retry должен быть безопасен.
\nДля события cancelled v3, которое пришло раньше v2, безопасный ответ — не «подождать ещё минуту». Consumer должен сохранить v3 вместе с ожидаемой версией 2 и его event id. Когда v2 появится, он применяет v2 и повторно рассматривает отложенную v3. Если транспорт не гарантирует получение v2, нужен отдельный контракт поиска пропущенного перехода или ручной маршрут восстановления. Нельзя объявлять v3 конечным состоянием, пока не проверен порядок.
Компенсация — новое доменное решение, а не удаление старой записи. Например, owner получает training-reservation-rejected, проверяет orderId и исходную версию, затем создаёт решение cancelled v3 с одним compensationKey. Уникальный ключ защищает owner от повторной записи одного и того же решения.
Но ключ не доказывает, что внешний резерв отменён. Timeout означает только, что ответ не получен. Внешняя система могла принять отмену, отклонить её или выполнить исходный резерв до обрыва связи. Поэтому результат компенсации должен иметь собственное evidence: ответ владельца внешнего эффекта, подтверждённый event или ручное решение с журналом. Не называйте компенсацию завершённой только потому, что запись с ключом появилась в локальной базе.
\nBEGIN;\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;\nУчебный SQL показывает локальное уникальное ограничение и идемпотентный путь записи. Он не делает атомарными изменения в другой базе, отправку сообщения и внешний вызов. Реализация должна отдельно определить границы транзакции, retry и восстановление после частичного успеха.
\nProjection не должна разрешать отгрузку только по локальному флагу readyToShip. Перед необратимым или дорогим действием она сверяет object id, owner state, свою версию и нужное локальное evidence. В учебном контракте отгрузка разрешена, когда состояние paid, версии равны, а резерв подтверждён. После cancelled v3 функция возвращает запрет, даже если старый UI-флаг ещё не очищен.
function canShip(owner, projection) {\n return owner.objectId === projection.objectId\n && owner.state === 'paid'\n && projection.state === 'readyToShip'\n && owner.version === projection.version\n && projection.reservationEvidence === 'confirmed';\n}\nЭто не универсальное бизнес-правило. В некоторых доменах действие обратимо. В других нужен manual decision или отдельная компенсация. Универсальным остаётся вопрос: какой эффект нужно запретить, пока состояние не подтверждено владельцем и актуальной версией?
\nВерсия и event id не гарантируют доставку. Они делают пропуск и повтор различимыми. Уникальное ограничение защищает локальную запись, но не несколько сервисов сразу. Eventual consistency не означает, что любое запаздывание безопасно: следующий эффект может произойти в неправильном состоянии. Replay не является исправлением сам по себе. Он безопасен только при известном контракте идемпотентности и сохранённом порядке.
\nСтатья не утверждает production-результаты. Кодовые фрагменты учебные. Они не запускают реальную базу, брокер, внешний резерв, HTTP-клиент или deployment. Для рабочей системы отдельно проверяйте storage, transport, retry policy, транзакционный порядок и владельца внешнего действия.
\nРазбор готов, когда для одного object id можно воспроизвести цепочку owner decision → event delivery → consumer transition и ответить на четыре вопроса: какая версия является последней, какой event id её переносит, почему projection отстаёт или расходится и какой рискованный эффект заблокирован. После исправления повторная доставка не создаёт второй transition или компенсацию, stale event не возвращает состояние назад, а same-version conflict не применяется молча. Если хотя бы один ответ основан на предположении, случай ещё не закрыт.
\nid, source, specversion и type, а subject относится к дополнительному контексту. Учебный конверт выше не является полной реализацией стандарта.