{ "index": 243, "slug": "editorial-2021-04-practice-data-consistency", "title": "Согласованность данных между сервисами: версия, владелец и безопасное действие", "excerpt": "Один сервис уже отменил заказ, а другой всё ещё готовит его к отгрузке. Разбираем, как отделить owner-состояние от проекции, пережить gap и не превратить повтор события в новый бизнес-эффект.", "contentHtml": "
Симптом выглядит так: order-service показывает cancelled v3, а fulfillment всё ещё хранит awaiting-reservation v2. Оба сервиса говорят об одном заказе, но видят разные версии. Цена ошибки проявляется в следующей строке кода: второй сервис разрешает отгрузку по старому флагу, повторно просит резерв или отправляет пользователю неверное уведомление. Ручная правка статуса скрывает причину и может создать второй эффект.
Согласованность между сервисами начинается не с одинаковых строк в таблицах. Нужны владелец доменного состояния, монотонная версия, доказательство события и инвариант следующего рискованного действия. Проекция может временно отставать. Она не должна использовать отставание как разрешение на действие.
\nВладелец, или owner, принимает доменное решение. В нашем примере order-service владеет состоянием заказа. Только он переводит paid v2 в cancelled v3. fulfillment владеет своим локальным состоянием: получил ли он заказ, удалось ли зарезервировать товар, можно ли передавать его на склад. Он не переписывает заказ задним числом.
Это разделение не делает систему синхронной. Оно делает расхождение проверяемым. Если версия owner-а больше версии проекции, consumer должен объяснить gap: событие задержалось, пришло не по порядку, было отфильтровано или не записало локальный эффект. Поле status без источника и версии такого объяснения не даёт.
| Факт | Владелец | Доказательство | Разрешённое действие |
|---|---|---|---|
paid v2 | order-service | order id, version, event id | Построить проекцию ожидания резерва |
| Отказ резерва | order-service | Причина, исходная версия, compensationKey | Принять новое решение или оставить заказ на проверке |
cancelled v3 | order-service | Новое событие и версия 3 | Применить в проекции после версии 2 |
| Готовность к отгрузке | fulfillment | Та же версия и evidence резерва | Разрешить локальный шаг |
Полезный инвариант звучит конкретно: отгрузка запрещена, если версия проекции не равна версии owner-а или нет явного доказательства резерва. Он запрещает действие в момент, когда ошибка ещё обратима. Формулировка «данные когда-нибудь сойдутся» для обработчика бесполезна.
\nСобытие должно переносить не только новый статус. В учебном конверте ниже поля id, source, specversion и type соответствуют обязательному контексту CloudEvents, а subject уточняет объект. Версия заказа и причина отказа относятся к прикладным данным: их можно положить в data или описать собственным контрактом. В учебном примере используется такой конверт:
const event = {\n specversion: '1.0',\n id: 'evt-order-417-cancelled-v3',\n source: 'https://example.test/order-service',\n type: 'com.example.training.order.cancelled',\n subject: 'order-417',\n data: {\n orderVersion: 3,\n reason: 'reservation-rejected',\n },\n};\nЗдесь source + id отвечает на один вопрос: применялся ли уже этот конверт. data.orderVersion отвечает на другой: допустим ли переход для данной проекции. Нельзя заменить одно другим. Два разных event id могут описывать одну и ту же версию и конфликтовать. Один event id может прийти повторно, но не должен создать новый переход.
Если проекция имеет версию 1 и получает событие версии 3, она не должна молча записать cancelled. Событие нужно сохранить как отложенное, зафиксировать ожидаемую версию 2 и выбрать маршрут: дождаться доставки, запросить replay или передать запись на ручную сверку. Автоматически пропускать версию можно только при явно описанном контракте, который доказывает безопасность такого пропуска.
Представим короткую последовательность. Owner записал paid v2 и выпустил событие. Consumer применил его, поэтому его проекция также имеет версию 2. Резерв вернул подтверждённый отказ. Owner в своей локальной транзакции создал запись компенсации и новое состояние cancelled v3. Затем событие версии 3 пришло в consumer раньше версии 2 из-за задержки доставки.
Consumer видит gap и оставляет проекцию на версии 1. Он не выдаёт отмену за применённое состояние и не разрешает отгрузку. Когда приходит версия 2, consumer применяет ровно следующий переход. После этого он повторно рассматривает отложенную версию 3. Повтор версии 2 или 3 подавляется по event id и версии. Если другая запись претендует на уже занятую версию, это конфликт, а не обычный retry.
\nfunction 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 const orderVersion = event.data.orderVersion;\n\n if (orderVersion < projection.version) {\n return { kind: 'stale', projection };\n }\n\n if (orderVersion === projection.version) {\n return { kind: 'same-version-conflict', projection };\n }\n\n if (orderVersion !== projection.version + 1) {\n const deferred = projection.deferred.get(orderVersion);\n if (deferred && `${deferred.source}:${deferred.id}` !== eventKey) {\n return { kind: 'same-version-conflict', projection };\n }\n projection.deferred.set(orderVersion, event);\n return { kind: 'version-gap', expected: projection.version + 1, projection };\n }\n\n projection.state = event.type === 'com.example.training.order.cancelled'\n ? 'cancelled'\n : 'awaiting-reservation';\n projection.version = orderVersion;\n projection.appliedEventIds.add(eventKey);\n return { kind: 'applied', projection };\n}\nКод выше — учебный пример в памяти. Он не подключается к broker, базе, HTTP или внешнему резерву. Его задача — показать границу решения: duplicate не меняет состояние, stale не двигает проекцию назад, same-version conflict не затирает уже занятую версию, gap не маскируется статусом, а последовательное событие применяет ровно один переход. После успешного применения вызывающий код должен достать следующий элемент из deferred и передать его в эту же функцию. В настоящем consumer-е ledger события и запись проекции должны иметь согласованный способ фиксации. Иначе ledger может сказать «применено», пока обновление проекции потерялось.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Owner v3, projection v2 | Consumer ещё не применил новую версию | Сверить order id, версии, source и event id | Сохранить gap и выполнить согласованный replay |
| Пришла v3, ожидалась v2 | События пришли не по порядку | Проверить deferred-запись и наличие v2 | Не применять v3; после v2 повторить обработку |
| Один event id виден дважды | Повтор доставки | Найти ключ в consumer ledger | Подавить повтор и проверить отсутствие второго эффекта |
| Две записи претендуют на одну версию | Конфликт owner-контракта или source | Сравнить payload, source, id и правило перехода | Отклонить конфликт и передать его владельцу состояния |
| Projection готова к отгрузке, owner cancelled | Локальный флаг устарел | Сверить version и reservation evidence | Заблокировать отгрузку, затем собрать evidence packet |
Последняя строка важнее красивого статуса в интерфейсе. Если owner уже отменил заказ, старый флаг readyToShip не должен иметь самостоятельной силы. Сначала блокируется рискованный эффект. Потом проверяются owner version, projection version, event id и причина решения. Ручная коррекция проекции допустима только после сохранения этих фактов и понятного маршрута восстановления.
Owner может атомарно изменить заказ и ledger компенсации в одной базе. Это защищает локальную границу. Такая транзакция не доказывает, что consumer получил событие, внешний резерв отменился или письмо ушло. Для каждого внешнего эффекта нужен собственный ключ повторения, владелец и evidence результата.
\nBEGIN;\n\nWITH cancelled AS (\n UPDATE orders\n SET status = 'cancelled', version = version + 1\n WHERE id = 'order-417' AND status = 'paid' AND version = 2\n RETURNING id, version\n)\nINSERT INTO compensation_ledger (compensation_key, order_id, order_version)\nSELECT 'compensation:order-417:reservation:v2', id, version - 1\nFROM cancelled\nON CONFLICT (compensation_key) DO NOTHING;\n\nCOMMIT;\nЗапрос защищает переход одного owner-а и повтор записи в его ledger: если условный UPDATE изменил ноль строк, INSERT тоже не создаст компенсацию. Он не делает две базы атомарными. Если внешний вызов вернул timeout, этого недостаточно для автоматической отмены: операция могла завершиться на другой стороне. Неизвестный результат должен вести к сверке, а не к предположению.
id, source, type, subject и версию объекта. Не смешивайте id доставки с бизнес-ключом эффекта.Эта модель не обещает exactly-once для бизнеса, нулевую задержку, сохранность каждого сообщения или автоматическую компенсацию внешней операции. Версия защищает порядок одного owner-а, а не всей системы. Event id защищает повтор конверта, а не повтор платежа, письма или резерва. Локальная транзакция защищает одну базу, а не сеть между сервисами.
\nКритерий готовности проверяем на одном тестовом заказе. По журналу должны восстанавливаться owner id и version, применённый event id, версия проекции, причина gap и compensation key. При v3 раньше v2 проекция не должна двигаться дальше ожидаемой версии. При повторе v3 бизнес-эффект не должен выполняться второй раз. При owner=cancelled отгрузка должна вернуть отказ даже при старом локальном флаге. Если хотя бы один факт нельзя найти без ручной догадки, контракт ещё не готов.
source и id.