Files
progcode/editorial/agent-rewrites/243.json
T
huncode 2d914b543f
Build and deploy / deploy (push) Failing after 15s
Publish rewritten technical article archive
2026-08-02 22:19:34 +03:00

8 lines
18 KiB
JSON
Raw 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": 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 &lt;= 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>"
}