{ "index": 219, "slug": "editorial-2021-12-practice-architecture-review", "title": "Граница публикации: как связать запись, событие и публичное чтение", "excerpt": "Рабочая заметка может сохраниться, но не попасть в публичное чтение. Разбираем двойную запись, transactional outbox, повторную доставку и проверки, которые отделяют доказанный факт от предположения.", "contentHtml": "
Симптом выглядит просто: автор сохраняет рабочую заметку, API отвечает успехом, но публичный список не показывает новую версию. Иногда запись появляется через минуту. Иногда её нет после перезапуска relay. Иногда пользователь видит старый текст, хотя в административном экране уже открыт новый. Цена ошибки — потерянное доверие к публикации и дорогая диагностика. Команда может повторить отправку, получить дубль или затереть свежую версию старой.
\nОбычно причина не в одном медленном запросе. Сервис записывает заметку в базу, затем отдельно отправляет событие или обновляет read-модель. Между этими действиями есть окно сбоя. Процесс может завершиться после commit и до отправки. Брокер может принять сообщение, но relay не сохранить результат. Consumer может обработать событие дважды. Если система не различает эти факты, наблюдаемая задержка превращается в спор о технологиях.
\nУ операции публикации должны быть разные владельцы. Каноническая заметка хранит то, что автор сохранил. Publication intent фиксирует решение передать конкретную версию в публичный контур. Relay доставляет intent. Read-модель показывает результат доставки. Эти объекты связаны ключом, но не становятся одной сущностью только потому, что их создал один HTTP-запрос.
\nТакой раздел полезен, когда изменение базы и уведомление другого процесса нельзя выполнить одной локальной операцией. Transactional outbox закрывает первую границу: заметка и intent попадают в одну транзакцию базы. Затем отдельный relay читает подтверждённый intent и отправляет событие. Это не даёт exactly-once. Consumer всё равно должен переживать повтор, а публичное чтение — явно допускать задержку.
\nЕсли публичный список может читать каноническую таблицу напрямую, outbox может быть лишним. Если read-модель обязана существовать отдельно, а факт намерения нельзя потерять, синхронная отправка после commit оставляет опасное окно. Выбор определяется этими условиями, а не названием паттерна.
\nСначала появляется note. Это каноническая версия с идентификатором, номером ревизии и текстом. В той же транзакции создаётся publication_intent с ключом noteId:revision. После commit intent становится доступен relay. Relay публикует его и отмечает попытку. Consumer создаёт или обновляет публичную проекцию по тому же ключу.
Из этого следуют четыре проверяемых состояния. note-only означает, что запись есть, а intent нет. Это ошибка границы транзакции или неполный старый путь. intent-pending означает, что намерение зафиксировано, но эффект чтения ещё не подтверждён. Это задержка или сбой relay. intent-relayed означает, что событие отправляли, но проекция может быть устаревшей или consumer мог отклонить данные. projection-current означает, что публичный контур содержит ту же ревизию.
Не называйте intent-pending потерей данных. Не называйте intent-relayed публикацией, пока не проверена проекция. Не называйте ответ HTTP доказательством последнего состояния. Каждый переход должен оставлять след, по которому можно восстановить ключ, ревизию и владельца следующего действия.
Ниже — ограниченный учебный пример. Он показывает форму транзакции и идентификатора, но не заменяет конкретную СУБД, уровни изоляции, права, retry-политику или контракт брокера. Таблицы условны. В реальной системе обе вставки должны выполняться в одной транзакции того хранилища, которое действительно владеет заметкой.
\nBEGIN;\n\nINSERT INTO notes (id, revision, body)\nVALUES ('n-42', 7, 'Текст рабочей заметки');\n\nINSERT INTO publication_intents (key, note_id, revision, status)\nVALUES ('n-42:7', 'n-42', 7, 'pending');\n\nCOMMIT;\n\n-- Relay читает только committed intents.\n-- Consumer применяет n-42:7 идемпотентно.\nКлюч n-42:7 важнее случайного UUID для эффекта публикации. Он отвечает на вопрос, какую именно ревизию можно повторить. Уникальное ограничение на пару note_id, revision не позволяет незаметно создать две канонические версии с одним номером. Уникальный ключ intent не позволяет одному решению породить два разных задания.
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; не обещать мгновенную видимость |
Для каждой публикации соберите не общий лог, а причинную цепочку. Нужны noteId, revision, operation id, ключ intent, время commit, число попыток relay, результат передачи, результат consumer и revision публичной проекции. Если одно поле отсутствует, это не доказательство успеха. Это пробел наблюдаемости, который нужно назвать отдельно.
Разделяйте факт и предположение. Факт: строка intent существует после commit. Факт: consumer вернул подтверждение для ключа n-42:7. Предположение: после этого пользователь увидит новую версию в любом публичном запросе. Последний вывод требует проверки read scope, кэша, фильтров и версии API. Наличие строки в проекции не доказывает, что endpoint читает именно её.
Срок задержки тоже должен иметь владельца. Если интерфейс допускает eventual consistency, он должен отличать «публикация принята» от «публикация видна в списке». Если интерфейс обязан показывать новую версию до ответа, отдельная проекция может быть неподходящей. В этом случае нужно вернуть чтение к каноническому источнику или изменить контракт. Нельзя получить строгую видимость, просто добавив ещё один retry.
\nnoteId и revision: что ответил write API, что вернул public read и когда появились оба ответа.noteId:revision. Если ключа нет, остановите автоматическое восстановление и разберите write boundary.Не добавляйте outbox к каждой таблице. Он не оправдан, если изменение и чтение находятся в одной локальной транзакции, отдельного события нет, а публичный контракт может читать каноническую запись. Дополнительная таблица в этом случае увеличивает число состояний и операций без нового требования.
\nOutbox также не решает задачу, если запись должна атомарно изменять несколько независимых сервисов. Локальная транзакция не пересекает границу этих сервисов. Нужны другой протокол координации, компенсация или согласованный процесс. Называть outbox распределённой транзакцией опасно: он фиксирует локальное намерение, а не подтверждает каждый внешний эффект.
\nCDC может заменить отдельную outbox-таблицу, если конкретная база и платформа гарантируют нужный порядок, полноту изменений и повторную обработку. Это не бесплатный путь. Нужно проверить, какое событие формируется, как определяется ревизия, где хранится offset и что произойдёт при восстановлении consumer.
\nМатериал не обещает мгновенную консистентность и не доказывает надёжность конкретного брокера. Учебный SQL не является готовой схемой миграции. Он не описывает блокировки, индексы, partitioning, TTL, шифрование, права доступа и нагрузочные пределы. Эти свойства проверяют по документации и конфигурации выбранной системы.
\nИдемпотентность по ключу публикации защищает один эффект, но не отменяет побочные действия. Отправка письма, webhook, инвалидирование внешнего кэша и изменение поискового индекса имеют собственные ключи и правила повторения. Если consumer вызывает несколько эффектов, каждый из них должен иметь подтверждаемую защиту от дубля или компенсирующую операцию.
\nНельзя объявлять результатом исправления отсутствие одного симптома в одном браузере. Проверка должна охватывать новую ревизию, повторную доставку, задержку, падение relay после commit и старую проекцию. Если эти ветки не наблюдаемы, система может выглядеть исправной и снова потерять публикацию при следующем отказе.
\nРешение готово к применению, если для каждой публикации можно ответить на пять вопросов: где лежит каноническая ревизия, где зафиксирован intent, какой ключ повторяет операцию, какая revision подтверждена consumer и что увидит public read при задержке. Проверка должна показать, что note и intent либо фиксируются вместе, либо операция откатывается; повтор одного ключа не создаёт второй публичный эффект; отсутствующий intent не запускает слепой replay; старая проекция остаётся различимой от свежей.
\nЕсли на любой вопрос ответ строится на словах «обычно», «в конце концов» или «очередь сама доставит», граница ещё не доказана. Следующее действие — не добавить ещё одну попытку, а найти отсутствующий факт, назначить его владельца и проверить его тем же ключом публикации.
\n