{ "index": 219, "slug": "editorial-2021-12-practice-architecture-review", "title": "Граница публикации: как связать запись, событие и публичное чтение", "excerpt": "Рабочая заметка может сохраниться, но не попасть в публичное чтение. Разбираем двойную запись, transactional outbox, повторную доставку и проверки, которые отделяют доказанный факт от предположения.", "contentHtml": "

Симптом выглядит просто: автор сохраняет рабочую заметку, API отвечает успехом, но публичный список не показывает новую версию. Иногда запись появляется через минуту. Иногда её нет после перезапуска relay. Иногда пользователь видит старый текст, хотя в административном экране уже открыт новый. Цена ошибки — потерянное доверие к публикации и дорогая диагностика. Команда может повторить отправку, получить дубль или затереть свежую версию старой.

\n

Причина часто находится не в одном медленном запросе, а на границе двух записей. Сервис сохраняет заметку в базе, затем отдельно отправляет событие или обновляет read-модель. Процесс может завершиться после commit и до отправки. Брокер может принять сообщение, но relay не сохранить результат. Consumer может обработать событие дважды. Пока система не различает эти факты, наблюдаемая задержка превращается в спор о технологиях.

\n

Тезис: разделите каноническую запись и эффект чтения

\n

У операции публикации должны быть разные владельцы. Каноническая заметка хранит то, что автор сохранил. publication_intent фиксирует решение передать конкретную версию в публичный контур. Relay доставляет это намерение. Read-модель показывает результат доставки. Объекты связаны ключом, но не становятся одной сущностью только потому, что их создал один HTTP-запрос.

\n

Такое разделение полезно, когда изменение базы и уведомление другого процесса нельзя выполнить одной локальной операцией. Transactional outbox закрывает первую границу: заметка и intent попадают в одну транзакцию базы. Затем отдельный relay читает только подтверждённый intent и отправляет событие. Это не даёт гарантии exactly-once для всей цепочки. Consumer всё равно должен переживать повтор, а публичное чтение — явно допускать задержку.

\n

Если публичный список читает каноническую таблицу напрямую и отдельного события нет, outbox может быть лишним. Если read-модель обязана существовать отдельно, а факт намерения нельзя потерять, синхронная отправка после commit оставляет опасное окно. Выбор определяется этими условиями, а не названием паттерна.

\n

Механизм: четыре состояния одной публикации

\n

Ниже — диагностическая модель, а не универсальный статусный справочник. Она помогает отделить четыре наблюдаемых факта. Сначала появляется note: каноническая версия с идентификатором, номером ревизии и текстом. В той же транзакции создаётся publication_intent с ключом noteId:revision. После commit intent становится доступен relay. Relay передаёт его и фиксирует попытку. Consumer создаёт или обновляет публичную проекцию.

\n

note-only означает, что запись есть, а intent нет. Это дефект границы транзакции или неполный старый write path. intent-pending означает, что намерение зафиксировано, но эффект чтения ещё не подтверждён. Это задержка или сбой relay. intent-relayed означает, что событие передавали, но проекция может быть устаревшей, а consumer мог отклонить данные. projection-current означает, что публичный контур содержит ту же ревизию.

\n

Не называйте intent-pending потерей данных. Не называйте intent-relayed публикацией, пока не проверена проекция. Не называйте ответ HTTP доказательством последнего состояния. Каждый переход должен оставлять след, по которому можно восстановить ключ, ревизию и владельца следующего действия.

\n

Учебный пример границы записи

\n

Ниже — ограниченный учебный пример для PostgreSQL. Он показывает, какие ключи и ограничения нужны для рассуждения о повторе, но не является готовой миграцией. В реальной системе обе вставки должны выполняться в одной транзакции того хранилища, которое действительно владеет заметкой. Уровень изоляции, права, retry-политику и контракт брокера задают отдельно.

\n
-- Минимальная форма; типы и поля сокращены. CREATE TABLE notes ( id text NOT NULL, revision integer NOT NULL, body text NOT NULL, PRIMARY KEY (id, revision) ); CREATE TABLE publication_intents ( intent_key text PRIMARY KEY, note_id text NOT NULL, revision integer NOT NULL, status text NOT NULL CHECK (status IN ('pending', 'relayed')), UNIQUE (note_id, revision) ); BEGIN; INSERT INTO notes (id, revision, body) VALUES ('n-42', 7, 'Текст рабочей заметки'); INSERT INTO publication_intents (intent_key, note_id, revision, status) VALUES ('n-42:7', 'n-42', 7, 'pending'); COMMIT; -- Relay читает только committed intents. -- Consumer применяет n-42:7 идемпотентно.
\n

В примере транзакция группирует две записи. Если одна вставка или сам commit завершается ошибкой, база не должна оставить только половину операции. Это свойство локальной транзакции, а не гарантия доставки внешнего события. После commit relay всё ещё может упасть, поэтому intent остаётся материальным следом незавершённой работы.

\n

Составной ключ notes(id, revision) не даёт создать две строки с одним идентификатором и номером ревизии. publication_intents(intent_key) делает повторяемым конкретное намерение, а UNIQUE(note_id, revision) связывает одну ревизию с одним intent. Это проектные ограничения примера: если бизнес допускает несколько независимых публикаций одной ревизии, ключ и уникальность должны быть другими.

\n

Ключ n-42:7 отвечает на вопрос, какую именно ревизию можно повторить. Случайный UUID может быть полезен как технический идентификатор сообщения, но сам по себе не объясняет, относится ли повтор к той же версии. Поэтому в envelope обычно передают и уникальный id события, и идентификатор агрегата с ревизией.

\n

Relay должен сначала прочитать committed intent, затем передать событие и безопасно повторить попытку после временного отказа. Если отметку «отправлено» записать до передачи, падение между отметкой и отправкой может скрыть событие. Если отметить после передачи, возможен дубль при падении после отправки и до фиксации статуса. Второй сценарий требует идемпотентного consumer, но делает потерю обнаруживаемой и устранимой.

\n

Отрицательный путь начинается с отсутствующего решения. Если в записи нет intent, нельзя задним числом считать публикацию разрешённой только потому, что заметка видна оператору. Нужно остановить автоматический replay, найти границу старого write path и отдельно решить, можно ли безопасно создать intent для конкретной ревизии. Иначе восстановление превратится в новую запись поверх неизвестного состояния.

\n
Диагностика публикации заметки
СимптомПричинаПроверкаДействие
Каноническая заметка есть, intent нетДвойная запись выполнена разными операциями или сработал старый путьСопоставить note id, revision, commit и код write pathОстановить replay; принять отдельное решение о создании intent
Intent pending дольше допустимого окнаRelay не читает committed строки или повторная доставка завершается отказомПроверить статус, попытки, время последнего чтения и текст ошибки по ключуПовторить только этот ключ после устранения причины; не создавать новую ревизию
Событие отправлено, проекция стараяConsumer отклонил событие, применил старую версию или не обновил read-модельСопоставить envelope, revision, consumer result и текущую revision проекцииИсправить порядок или контракт consumer; replay делать идемпотентно
Одна ревизия видна дваждыConsumer не защищён от повторной доставкиНайти deduplication key и две операции примененияСделать применение условным по ключу; удалить дубль только по подтверждённому правилу
Публичное чтение показывает старый текст после новогоЗапрос читает stale projection или получает кэшированную версиюСравнить revision источника, projection, cache headers и scope запросаЯвно показать задержку или перестроить projection; не обещать мгновенную видимость
\n
Граница публикации заметки: транзакция сохраняет каноническую запись и intent, relay передаёт ключ, consumer обновляет публичную проекцию.
Каноническая запись и intent фиксируются вместе. Relay и consumer могут повторяться, поэтому ключ ревизии проходит через весь путь.
\n

Как читать доказательства

\n

Для каждой публикации соберите не общий лог, а причинную цепочку. Нужны noteId, revision, operation id, ключ intent, время commit, число попыток relay, результат передачи, результат consumer и revision публичной проекции. Если одно поле отсутствует, это не доказательство успеха. Это пробел наблюдаемости, который нужно назвать отдельно.

\n

Разделяйте факт и предположение. Факт: строка intent существует после commit. Факт: consumer вернул подтверждение для ключа n-42:7. Предположение: после этого пользователь увидит новую версию в любом публичном запросе. Последний вывод требует проверки read scope, кэша, фильтров и версии API. Наличие строки в проекции не доказывает, что endpoint читает именно её.

\n

Срок задержки тоже должен иметь владельца. Если интерфейс допускает eventual consistency, он должен отличать «публикация принята» от «публикация видна в списке». Если интерфейс обязан показывать новую версию до ответа, отдельная проекция может быть неподходящей. В этом случае нужно вернуть чтение к каноническому источнику или изменить контракт. Нельзя получить строгую видимость, просто добавив ещё один retry.

\n

Порядок действий

\n
  1. Зафиксируйте симптом с конкретными noteId и revision: что ответил write API, что вернул public read и когда появились оба ответа.
  2. Найдите каноническую запись и проверьте её revision, владельца и время commit. Не меняйте данные во время первичной проверки.
  3. Проверьте наличие intent с ключом noteId:revision. Если ключа нет, остановите автоматическое восстановление и разберите write boundary.
  4. Если intent есть, проверьте, что он вошёл в ту же транзакцию, что и note. Смотрите на реальный commit, а не на локальный объект в памяти.
  5. Проверьте relay: видит ли он только committed строки, сохраняет ли попытки и может ли повторить один ключ без создания нового intent.
  6. Проверьте consumer: принимает ли он revision, отклоняет ли старую версию и повторяет ли эффект без дубля.
  7. Сравните revision в public projection и в ответе endpoint. Отдельно проверьте scope, фильтр, кэш и авторизацию.
  8. Разберите медленный и отрицательный путь: сбой после commit, отказ брокера, повторное событие, старый consumer и отсутствующий intent.
  9. После исправления повторите тот же ключ и тот же сценарий. Зафиксируйте, какой переход изменился и какое наблюдение подтверждает результат.
\n

Когда outbox не нужен

\n

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

\n

Outbox также не решает задачу, если запись должна атомарно изменять несколько независимых сервисов. Локальная транзакция не пересекает границу этих сервисов. Нужны другой протокол координации, компенсация или согласованный процесс. Называть outbox распределённой транзакцией опасно: он фиксирует локальное намерение, а не подтверждает каждый внешний эффект.

\n

CDC может заменить отдельную outbox-таблицу, если конкретная база и платформа гарантируют нужный порядок, полноту изменений и повторную обработку. Но CDC не превращает внешний эффект в часть транзакции автоматически. Например, у Debezium Outbox Event Router есть ожидаемая форма outbox-таблицы и настройки маршрутизации; их нужно сопоставить с фактической схемой, ключом события и политикой повторов. При восстановлении consumer отдельно проверяют offset, порядок и повторную обработку.

\n

Ограничения

\n

Материал не обещает мгновенную консистентность и не доказывает надёжность конкретного брокера. Учебный SQL не описывает блокировки, индексы, partitioning, TTL, шифрование, права доступа и нагрузочные пределы. Эти свойства проверяют по документации и конфигурации выбранной системы.

\n

Идемпотентность по ключу публикации защищает один эффект, но не отменяет побочные действия. Отправка письма, webhook, инвалидирование внешнего кэша и изменение поискового индекса имеют собственные ключи и правила повторения. Если consumer вызывает несколько эффектов, каждый из них должен иметь подтверждаемую защиту от дубля или компенсирующую операцию.

\n

Нельзя объявлять результатом исправления отсутствие одного симптома в одном браузере. Проверка должна охватывать новую ревизию, повторную доставку, задержку, падение relay после commit и старую проекцию. Если эти ветки не наблюдаемы, система может выглядеть исправной и снова потерять публикацию при следующем отказе.

\n

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

\n

Решение готово к применению, если для каждой публикации можно ответить на пять вопросов: где лежит каноническая ревизия, где зафиксирован intent, какой ключ повторяет операцию, какая revision подтверждена consumer и что увидит public read при задержке. Проверка должна показать, что note и intent либо фиксируются вместе, либо операция откатывается; повтор одного ключа не создаёт второй публичный эффект; отсутствующий intent не запускает слепой replay; старая проекция остаётся различимой от свежей.

\n

Если на любой вопрос ответ строится на словах «обычно», «в конце концов» или «очередь сама доставит», граница ещё не доказана. Следующее действие — не добавить ещё одну попытку, а найти отсутствующий факт, назначить его владельца и проверить его тем же ключом публикации.

\n

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

" }