8 lines
18 KiB
JSON
8 lines
18 KiB
JSON
{
|
||
"index": 243,
|
||
"slug": "editorial-2021-04-practice-data-consistency",
|
||
"title": "Согласованность данных между сервисами: версия, владелец и безопасное действие",
|
||
"excerpt": "Один сервис уже отменил заказ, а другой всё ещё готовит его к отгрузке. Разбираем, как отделить owner-состояние от проекции, пережить gap и не превратить повтор события в новый бизнес-эффект.",
|
||
"contentHtml": "<p>Симптом выглядит так: <code>order-service</code> показывает <code>cancelled v3</code>, а <code>fulfillment</code> всё ещё хранит <code>awaiting-reservation v2</code>. Оба сервиса говорят об одном заказе, но видят разные версии. Цена ошибки возникает в следующей строке кода: второй сервис разрешает отгрузку по старому флагу, повторно просит резерв или отправляет пользователю неверное уведомление. Ручная правка статуса скрывает причину и может создать второй эффект.</p>\n<p>Главный тезис простой: согласованность между сервисами начинается не с одинаковых строк в таблицах. Нужны владелец доменного состояния, монотонная версия, доказательство события и инвариант следующего рискованного действия. Проекция может временно отставать. Она не должна использовать отставание как разрешение на действие.</p>\n<h2>Сначала отделите владельца от проекции</h2>\n<p>Владелец, или <code>owner</code>, принимает доменное решение. В нашем примере <code>order-service</code> владеет состоянием заказа. Только он переводит <code>paid v2</code> в <code>cancelled v3</code>. <code>fulfillment</code> владеет своим локальным состоянием: получил ли он заказ, удалось ли зарезервировать товар, можно ли передавать его на склад. Он не переписывает заказ задним числом.</p>\n<p>Это разделение не делает систему синхронной. Оно делает расхождение проверяемым. Если версия owner-а больше версии проекции, consumer должен объяснить gap: событие задержалось, пришло не по порядку, было отфильтровано или не записало локальный эффект. Поле <code>status</code> без источника и версии такого объяснения не даёт.</p>\n<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><code>order-service</code></td><td>Причина, исходная версия, <code>compensationKey</code></td><td>Принять новое решение или оставить заказ на проверке</td></tr><tr><td><code>cancelled v3</code></td><td><code>order-service</code></td><td>Новое событие и версия 3</td><td>Применить в проекции после версии 2</td></tr><tr><td>Готовность к отгрузке</td><td><code>fulfillment</code></td><td>Та же версия и evidence резерва</td><td>Разрешить локальный шаг</td></tr></tbody></table></div>\n<p>Полезный инвариант звучит конкретно: <strong>отгрузка запрещена, если версия проекции не равна версии owner-а или нет явного доказательства резерва</strong>. Он запрещает действие в момент, когда ошибка ещё обратима. Формулировка «данные когда-нибудь сойдутся» для обработчика бесполезна.</p>\n<h2>Версия защищает порядок, id защищает повтор</h2>\n<p>Событие должно переносить не только новый статус. Ему нужны идентификатор, источник, тип, объект и версия этого объекта. В учебном примере используется такой конверт:</p>\n<pre><code>const event = {\n id: 'evt-order-417-cancelled-v3',\n source: 'training/order-service',\n type: 'training.order.cancelled',\n subject: 'order-417',\n orderVersion: 3,\n reason: 'reservation-rejected',\n};</code></pre>\n<p>Здесь <code>source + id</code> отвечает на один вопрос: применялся ли уже этот конверт. <code>orderVersion</code> отвечает на другой: допустим ли переход для данной проекции. Нельзя заменить одно другим. Два разных event id могут описывать одну и ту же версию и конфликтовать. Один event id может прийти повторно, но не должен создать новый переход.</p>\n<p>Если проекция имеет версию 1 и получает событие версии 3, она не должна молча записать <code>cancelled</code>. Событие нужно сохранить как отложенное, зафиксировать ожидаемую версию 2 и выбрать маршрут: дождаться доставки, запросить replay или передать запись на ручную сверку. Автоматически пропускать версию можно только при явно описанном контракте, который доказывает безопасность такого пропуска.</p>\n<h2>Механизм: owner принимает решение, consumer догоняет его</h2>\n<p>Представим короткую последовательность. Owner записал <code>paid v2</code> и выпустил событие. Consumer применил его, поэтому его проекция также имеет версию 2. Резерв вернул подтверждённый отказ. Owner в своей локальной транзакции создал запись компенсации и новое состояние <code>cancelled v3</code>. Затем событие версии 3 пришло в consumer раньше версии 2 из-за задержки доставки.</p>\n<p>Consumer видит gap и оставляет проекцию на версии 1. Он не выдаёт отмену за применённое состояние и не разрешает отгрузку. Когда приходит версия 2, consumer применяет ровно следующий переход. После этого он повторно рассматривает отложенную версию 3. Повтор версии 2 или 3 подавляется по event id и версии. Если другая запись претендует на уже занятую версию, это конфликт, а не обычный retry.</p>\n<pre><code>function applyOrderEvent(projection, event) {\n const eventKey = `${event.source}:${event.id}`;\n\n if (projection.appliedEventIds.has(eventKey)) {\n return { kind: 'duplicate', projection };\n }\n\n if (event.orderVersion <= projection.version) {\n return { kind: 'stale-or-conflict', projection };\n }\n\n if (event.orderVersion !== projection.version + 1) {\n projection.deferred.set(event.orderVersion, event);\n return { kind: 'version-gap', expected: projection.version + 1, projection };\n }\n\n projection.state = event.type === 'training.order.cancelled'\n ? 'cancelled'\n : 'awaiting-reservation';\n projection.version = event.orderVersion;\n projection.appliedEventIds.add(eventKey);\n return { kind: 'applied', projection };\n}</code></pre>\n<p>Код выше — учебный пример в памяти. Он не подключается к broker, базе, HTTP или внешнему резерву. Его задача — показать границу решения: duplicate не меняет состояние, gap не маскируется статусом, последовательное событие применяет ровно один переход. В настоящем consumer-е ledger события и запись проекции должны иметь согласованный способ фиксации. Иначе ledger может сказать «применено», пока обновление проекции потерялось.</p>\n<h2>Симптом → причина → проверка → действие</h2>\n<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>Owner v3, projection v2</td><td>Consumer ещё не применил новую версию</td><td>Сверить order id, версии, source и event id</td><td>Сохранить gap и выполнить согласованный replay</td></tr><tr><td>Пришла v3, ожидалась v2</td><td>События пришли не по порядку</td><td>Проверить deferred-запись и наличие v2</td><td>Не применять v3; после v2 повторить обработку</td></tr><tr><td>Один event id виден дважды</td><td>Повтор доставки</td><td>Найти ключ в consumer ledger</td><td>Подавить повтор и проверить отсутствие второго эффекта</td></tr><tr><td>Две записи претендуют на одну версию</td><td>Конфликт owner-контракта или source</td><td>Сравнить payload, source, id и правило перехода</td><td>Отклонить конфликт и передать его владельцу состояния</td></tr><tr><td>Projection готова к отгрузке, owner cancelled</td><td>Локальный флаг устарел</td><td>Сверить version и reservation evidence</td><td>Заблокировать отгрузку, затем собрать evidence packet</td></tr></tbody></table></div>\n<p>Последняя строка важнее красивого статуса в интерфейсе. Если owner уже отменил заказ, старый флаг <code>readyToShip</code> не должен иметь самостоятельной силы. Сначала блокируется рискованный эффект. Потом проверяются owner version, projection version, event id и причина решения. Ручная коррекция проекции допустима только после сохранения этих фактов и понятного маршрута восстановления.</p>\n<h2>Локальная транзакция не становится распределённой</h2>\n<p>Owner может атомарно изменить заказ и ledger компенсации в одной базе. Это защищает локальную границу. Такая транзакция не доказывает, что consumer получил событие, внешний резерв отменился или письмо ушло. Для каждого внешнего эффекта нужен собственный ключ повторения, владелец и evidence результата.</p>\n<pre><code>BEGIN;\n\nUPDATE orders\nSET status = 'cancelled', version = version + 1\nWHERE id = 'order-417' AND status = 'paid' AND version = 2;\n\nINSERT INTO compensation_ledger (compensation_key, order_id, order_version)\nVALUES ('compensation:order-417:reservation:v2', 'order-417', 2)\nON CONFLICT (compensation_key) DO NOTHING;\n\nCOMMIT;</code></pre>\n<p>Запрос защищает переход одного owner-а и повтор записи в его ledger. Он не делает две базы атомарными. Если внешний вызов вернул timeout, этого недостаточно для автоматической отмены: операция могла завершиться на другой стороне. Неизвестный результат должен вести к сверке, а не к предположению.</p>\n<figure><img src=\"/assets/editorial/2021/data-consistency-state-machine-2021.svg\" alt=\"Схема: owner переводит заказ с paid версии 2 в cancelled версии 3, а fulfillment применяет версии по порядку и блокирует отгрузку до совпадения версии и evidence\" loading=\"lazy\" /><figcaption>Расхождение версий — отдельное состояние ожидания. Оно не даёт проекции права на следующий необратимый шаг.</figcaption></figure>\n<h2>Порядок внедрения</h2>\n<ol><li>Назовите owner каждого доменного состояния. Зафиксируйте сервис, который имеет право менять его.</li><li>Выберите одно рискованное действие: отгрузка, выдача доступа, письмо или счёт. Запишите state, version и evidence, без которых оно запрещено.</li><li>Добавьте к событию стабильные <code>id</code>, <code>source</code>, <code>type</code>, <code>subject</code> и версию объекта. Не смешивайте id доставки с бизнес-ключом эффекта.</li><li>Храните в consumer последнюю применённую версию, ledger event id и отложенные версии. Для gap задайте срок и маршрут восстановления.</li><li>Разделите duplicate, stale и same-version conflict. Для каждого результата задайте отдельный сигнал и владельца.</li><li>Защитите компенсацию ключом именно решения owner-а. Отдельно защитите внешний эффект, если он существует.</li><li>Проверьте отрицательный путь: отсутствует v2, пришла повторная v3, внешний вызов вернул timeout, проекция предлагает отгрузку после отмены.</li><li>Только после этого подключайте конкретные базу, broker и интеграционные тесты. Учебный in-memory пример не заменяет их.</li></ol>\n<h2>Ограничения и критерий готовности</h2>\n<p>Эта модель не обещает exactly-once для бизнеса, нулевую задержку, сохранность каждого сообщения или автоматическую компенсацию внешней операции. Версия защищает порядок одного owner-а, а не всей системы. Event id защищает повтор конверта, а не повтор платежа, письма или резерва. Локальная транзакция защищает одну базу, а не сеть между сервисами.</p>\n<p>Критерий готовности проверяем на одном тестовом заказе. По журналу должны восстанавливаться owner id и version, применённый event id, версия проекции, причина gap и compensation key. При v3 раньше v2 проекция не должна двигаться дальше ожидаемой версии. При повторе v3 бизнес-эффект не должен выполняться второй раз. При <code>owner=cancelled</code> отгрузка должна вернуть отказ даже при старом локальном флаге. Если хотя бы один факт нельзя найти без ручной догадки, контракт ещё не готов.</p>\n<h2>Проверяемые источники</h2>\n<ul><li><a href=\"https://github.com/cloudevents/spec\" target=\"_blank\" rel=\"noopener noreferrer\">CloudEvents Specification</a> — обязательные контекстные атрибуты и правило уникальности сочетания <code>source</code> и <code>id</code>.</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/documentation/\" target=\"_blank\" rel=\"noopener noreferrer\">Apache Kafka Documentation</a> — документация транспорта и его настроек; она не заменяет прикладной контракт версий и эффектов.</li></ul>"
|
||
}
|