{ "index": 243, "slug": "editorial-2021-04-practice-data-consistency", "title": "Согласованность данных между сервисами: версия, владелец и безопасное действие", "excerpt": "Один сервис уже отменил заказ, а другой всё ещё готовит его к отгрузке. Разбираем, как отделить owner-состояние от проекции, пережить gap и не превратить повтор события в новый бизнес-эффект.", "contentHtml": "

Симптом выглядит так: order-service показывает cancelled v3, а fulfillment всё ещё хранит awaiting-reservation v2. Оба сервиса говорят об одном заказе, но видят разные версии. Цена ошибки проявляется в следующей строке кода: второй сервис разрешает отгрузку по старому флагу, повторно просит резерв или отправляет пользователю неверное уведомление. Ручная правка статуса скрывает причину и может создать второй эффект.

\n

Согласованность между сервисами начинается не с одинаковых строк в таблицах. Нужны владелец доменного состояния, монотонная версия, доказательство события и инвариант следующего рискованного действия. Проекция может временно отставать. Она не должна использовать отставание как разрешение на действие.

\n

Сначала отделите владельца от проекции

\n

Владелец, или owner, принимает доменное решение. В нашем примере order-service владеет состоянием заказа. Только он переводит paid v2 в cancelled v3. fulfillment владеет своим локальным состоянием: получил ли он заказ, удалось ли зарезервировать товар, можно ли передавать его на склад. Он не переписывает заказ задним числом.

\n

Это разделение не делает систему синхронной. Оно делает расхождение проверяемым. Если версия owner-а больше версии проекции, consumer должен объяснить gap: событие задержалось, пришло не по порядку, было отфильтровано или не записало локальный эффект. Поле status без источника и версии такого объяснения не даёт.

\n
Минимальный контракт заказа на границе сервисов
ФактВладелецДоказательствоРазрешённое действие
paid v2order-serviceorder id, version, event idПостроить проекцию ожидания резерва
Отказ резерваorder-serviceПричина, исходная версия, compensationKeyПринять новое решение или оставить заказ на проверке
cancelled v3order-serviceНовое событие и версия 3Применить в проекции после версии 2
Готовность к отгрузкеfulfillmentТа же версия и evidence резерваРазрешить локальный шаг
\n

Полезный инвариант звучит конкретно: отгрузка запрещена, если версия проекции не равна версии owner-а или нет явного доказательства резерва. Он запрещает действие в момент, когда ошибка ещё обратима. Формулировка «данные когда-нибудь сойдутся» для обработчика бесполезна.

\n

Версия защищает порядок, id защищает повтор

\n

Событие должно переносить не только новый статус. В учебном конверте ниже поля id, source, specversion и type соответствуют обязательному контексту CloudEvents, а subject уточняет объект. Версия заказа и причина отказа относятся к прикладным данным: их можно положить в data или описать собственным контрактом. В учебном примере используется такой конверт:

\n
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 может прийти повторно, но не должен создать новый переход.

\n

Если проекция имеет версию 1 и получает событие версии 3, она не должна молча записать cancelled. Событие нужно сохранить как отложенное, зафиксировать ожидаемую версию 2 и выбрать маршрут: дождаться доставки, запросить replay или передать запись на ручную сверку. Автоматически пропускать версию можно только при явно описанном контракте, который доказывает безопасность такого пропуска.

\n

Механизм: owner принимает решение, consumer догоняет его

\n

Представим короткую последовательность. Owner записал paid v2 и выпустил событие. Consumer применил его, поэтому его проекция также имеет версию 2. Резерв вернул подтверждённый отказ. Owner в своей локальной транзакции создал запись компенсации и новое состояние cancelled v3. Затем событие версии 3 пришло в consumer раньше версии 2 из-за задержки доставки.

\n

Consumer видит gap и оставляет проекцию на версии 1. Он не выдаёт отмену за применённое состояние и не разрешает отгрузку. Когда приходит версия 2, consumer применяет ровно следующий переход. После этого он повторно рассматривает отложенную версию 3. Повтор версии 2 или 3 подавляется по event id и версии. Если другая запись претендует на уже занятую версию, это конфликт, а не обычный retry.

\n
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  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 может сказать «применено», пока обновление проекции потерялось.

\n

Симптом → причина → проверка → действие

\n
Диагностика расхождения одного объекта
СимптомПричинаПроверкаДействие
Owner v3, projection v2Consumer ещё не применил новую версиюСверить 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
\n

Последняя строка важнее красивого статуса в интерфейсе. Если owner уже отменил заказ, старый флаг readyToShip не должен иметь самостоятельной силы. Сначала блокируется рискованный эффект. Потом проверяются owner version, projection version, event id и причина решения. Ручная коррекция проекции допустима только после сохранения этих фактов и понятного маршрута восстановления.

\n

Локальная транзакция не становится распределённой

\n

Owner может атомарно изменить заказ и ledger компенсации в одной базе. Это защищает локальную границу. Такая транзакция не доказывает, что consumer получил событие, внешний резерв отменился или письмо ушло. Для каждого внешнего эффекта нужен собственный ключ повторения, владелец и evidence результата.

\n
BEGIN;\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, этого недостаточно для автоматической отмены: операция могла завершиться на другой стороне. Неизвестный результат должен вести к сверке, а не к предположению.

\n
\"Схема:
Расхождение версий — отдельное состояние ожидания. Оно не даёт проекции права на следующий необратимый шаг.
\n

Порядок внедрения

\n
  1. Назовите owner каждого доменного состояния. Зафиксируйте сервис, который имеет право менять его.
  2. Выберите одно рискованное действие: отгрузка, выдача доступа, письмо или счёт. Запишите state, version и evidence, без которых оно запрещено.
  3. Добавьте к событию стабильные id, source, type, subject и версию объекта. Не смешивайте id доставки с бизнес-ключом эффекта.
  4. Храните в consumer последнюю применённую версию, ledger event id и отложенные версии. Для gap задайте срок и маршрут восстановления.
  5. Разделите duplicate, stale и same-version conflict. Для каждого результата задайте отдельный сигнал и владельца.
  6. Защитите компенсацию ключом именно решения owner-а. Отдельно защитите внешний эффект, если он существует.
  7. Проверьте отрицательный путь: отсутствует v2, пришла повторная v3, внешний вызов вернул timeout, проекция предлагает отгрузку после отмены.
  8. Только после этого подключайте конкретные базу, broker и интеграционные тесты. Учебный in-memory пример не заменяет их.
\n

Ограничения и критерий готовности

\n

Эта модель не обещает exactly-once для бизнеса, нулевую задержку, сохранность каждого сообщения или автоматическую компенсацию внешней операции. Версия защищает порядок одного owner-а, а не всей системы. Event id защищает повтор конверта, а не повтор платежа, письма или резерва. Локальная транзакция защищает одну базу, а не сеть между сервисами.

\n

Критерий готовности проверяем на одном тестовом заказе. По журналу должны восстанавливаться owner id и version, применённый event id, версия проекции, причина gap и compensation key. При v3 раньше v2 проекция не должна двигаться дальше ожидаемой версии. При повторе v3 бизнес-эффект не должен выполняться второй раз. При owner=cancelled отгрузка должна вернуть отказ даже при старом локальном флаге. Если хотя бы один факт нельзя найти без ручной догадки, контракт ещё не готов.

\n

Проверяемые источники

\n" }