Перейти к содержанию

ADR-0003 · Связь в процессе «публикация → раздача по лентам»#

Статус:    принят · 2026-09-16
Метод:     D-04 v2.0 · шаблон ADR v1.2
Участники: команды «лента», «посты», «платформа»

Учебная запись: часть прогона E-02, система вымышлена. Форма и рассуждение настоящие.

Контекст#

Вход — 00-context.md. Хранилище лент — ADR-0001, границы — ADR-0002: три сервиса, граф подписок уехал к ленте.

Материализация решена в ADR-0001 (вне метода, находка 1). Здесь решается, как именно публикация доходит до лент подписчиков.

Процесс распадается на четыре куска, и классифицируются не все#

D-04 v2.0 требует сначала разрезать процесс по границам, за которыми нет общей транзакции, и отбросить куски без таких границ.

Кусок Участники и хранилища Попадает ли в пространство D-04
Валидация и сохранение поста посты, один PostgreSQL, одна транзакция Нет. Локальная транзакция — ровно случай, который документ велит отбросить
A. посты → лента: уведомление о публикации Два сервиса, общей транзакции нет Да
B. Раздача внутри лента Один сервис, два хранилища: граф подписок в PostgreSQL, ленты в Redis Не определено документом — см. ниже
Удаление поста — Процесса нет. И2 закрыт фильтрацией при чтении (ADR-0001); раздача удалений не существует, и решать нечего

Кусок B провалился между двумя классами. D-04 делит куски на «граница, за которой нет общей транзакции» и «локальная транзакция в одном модуле». Раздача — ни то ни другое: общей транзакции между PostgreSQL и Redis нет, но и границы по списку документа («другой сервис, другая организация, внешний провайдер») тоже нет. Записано как находка 8.

Последняя строка таблицы стоит отдельного внимания: самый дорогой на вид процесс — вычистка удалённого поста из миллионов лент — не существует, потому что решение уровнем выше убрало его целиком. Документ связи не может отменить процесс; он может только не найти его в списке.

Три координатных вопроса — для участка A#

Вопрос Ответ Почему
Нужен ли инициатору ответ до завершения? Асинхронное Публикация обязана уложиться в p99 500 мс [продукт], раздача у автора с большой аудиторией идёт десятки секунд [расчёт]. Ждать нечего и незачем
Обязана ли согласованность быть атомарной? Итоговая Ни один инвариант не требует атомарности. Отставание до 10 с разрешено продуктом [продукт] — не недоработка, а сознательная уступка
Кто отвечает за завершение процесса? Хореография посты публикует событие post.created и не знает, кто его возьмёт. Дирижёра, знающего процесс целиком, нет и не нужно: шаг ровно один

Инвариант И1 выглядел требованием синхронности и им не оказался#

«Пользователь видит собственный пост немедленно» читается как «вставить в собственную ленту до ответа», то есть как синхронный вызов посты → лента на горячем пути публикации. Это неверно: И1 — требование к пути чтения, а не к раздаче.

Гибрид (ADR-0001) и так обязывает путь чтения подмешивать посты авторов, которые не раскладываются. Автор — такой же источник подмешивания для собственной ленты, как аккаунт с большой аудиторией: механизм уже есть, добавляется одна строка в список источников. Синхронный участок исчез не потому, что мы отказались от синхронности, а потому, что инвариант оказался не про этот процесс.

D-04 предлагает на этот случай два ответа: «ответ нужен» и «достаточно идентификатора, уведомим потом». Здесь сработал третий — «инвариант закрывается на другом пути». Записано как находка 12.

Числа процесса#

Показатель Значение Откуда
Публикаций 12/с средн., 120/с в пике [замер], пик ×10 как у чтений
Порог гибрида аудитория свыше 100 тыс. подписчиков решение этого ADR, см. ниже
Доля рёбер графа выше порога ≈ половина из 3 млрд [замер]
Средняя раскладываемая аудитория 145 вместо 300 — хвост отсечён порогом [расчёт]
Вставок в пике 120 × 145 = 17 400/с [расчёт], сходится с ADR-0001
Длительность одной раздачи от долей секунды (медиана 120 подписчиков) до ≈ минуты у порога [расчёт]: 100 тыс. вставок при доле конвейера ≈ 2 000 вставок/с
Одновременных раздач в пике ≈ 40 [расчёт]: правило Литтла, 120/с × 0,3 с средней длительности

Дисквалификаторы#

Условие Сработало Что из этого следует
Атомарность при хореографии Нет Атомарность не требуется вовсе
Атомарность без явного инварианта Нет Её никто и не просил. Требование появилось бы само, если бы «опубликовано» означало «во всех лентах» — мы этого не приняли
Хореография при пяти и более участниках Нет Участников два. Но см. условия пересмотра: событие в общей шине собирает потребителей само
Процесс дольше часа, состояние в коде По букве нет, по существу да Самая длинная раздача — около минуты, то есть порог из документа в шестьдесят раз дальше. При этом одновременных раздач в любой момент около сорока, а автовыкат идёт несколько раз в день: перезапуск всегда попадает в незавершённые. Находка 9
Асинхронность без идемпотентного потребителя Да И3 запрещает пост в ленте дважды, Kafka даёт at-least-once, ретраи раздачи обязательны. См. ниже

Идемпотентность закрылась структурой данных, а не механизмом. Лента — упорядоченный набор (ADR-0001); повторная вставка того же идентификатора с тем же ключом сортировки не создаёт второго элемента. Отдельная дедупликация не нужна. Это не подарок, а зависимость: смена структуры хранения ленты ломает И3 молча, и поэтому записана в последствия.

Два дисквалификатора из пяти дали не «нет», а «да, но». В E-01 два дисквалификатора тоже сработали и тоже не отвергли решение, а превратились в обязательные работы. Похоже, это их обычный режим, а не исключение.

Рассмотренные варианты#

Перечень восьми ячеек не копируется — он в D-04 и не зависит от нашего проекта. Снимком копируются три ответа, приведшие в ячейку: они выше.

Участок A — ячейка aec, Anthology Saga: асинхронное взаимодействие, итоговая согласованность, хореография. Самая слабая связность и самая дешёвая по зависимостям из восьми.

Участок B — координата определяется и ничего не даёт. Формально: асинхронно, итогово, с дирижёром внутри ленты, то есть aeo. Практически из этой координаты не следует ничего:

Что ячейка обычно приносит Что здесь
Компенсации и их порядок Компенсировать нечего: незавершённая раздача — не отказ, а «ещё не всё»
Разные шаги у разных участников Шаг один, повторённый до 100 тыс. раз
Выбор между дирижёром и его отсутствием Не выбор: «дирижёр» здесь — это курсор по списку подписчиков
Смысл ухода в соседнюю ячейку Уходить некуда: соседние ячейки описывают саги, а это размножение

Настоящие вопросы участка B — размер чанка, где живёт курсор, приоритет длинных раздач, порог гибрида — не задаёт ни один из трёх координатных вопросов. Записано как находка 10.

Решение#

Участок A: aec через post.created в Kafka. Ключ партиционирования — author_id: посты одного автора обрабатываются по порядку и не раскладываются двумя воркерами одновременно. Kafka уже в эксплуатации [орг].

Участок B: раздача чанками с курсором в таблице.

Что Как Почему так
Порог гибрида Аудитория свыше 100 тыс. не раскладывается Половина рёбер графа уходит из раздачи; пик падает с ~36 тыс. до 17 400 вставок/с
Чанк 1 000 подписчиков Раздача у порога — сто шагов, а не один; между шагами задача уступает конвейер
Курсор Строка в PostgreSQL ленты, фиксируется после каждого чанка Перезапуск продолжает с курсора; см. дисквалификатор о состоянии в коде
Повтор Ретрай чанка целиком Безопасен: вставка идемпотентна по структуре
Собственная лента автора Не раскладывается вовсе, подмешивается при чтении И1 закрывается путём чтения, механизм подмешивания уже нужен для гибрида

Порог 100 тыс. — параметр, а не константа. Как и длина ленты в ADR-0001, он двигает работу между записью и чтением и определяет стоимость линейно. Менять его — изменение бюджета, а не настройка.

Обоснование#

Из трёх координат ни одна не была спорной, и это само по себе результат. Атомарность не требуется ни одним инвариантом; ответа инициатору не нужно; участников двое, и дирижёр не из чего строить. Эвристика D-04 «начинать с согласованности» отработала за минуту: половина пространства с самыми дорогими ячейками отпала сразу, а из оставшихся четырёх три отсеклись так же быстро.

Дорогой оказалась не координата, а то, чего в координатах нет. Порог гибрида, размер чанка, место курсора — решения с реальной ценой, и ни одно из них не следует из ячейки aec. Документ довёл до правильной и дешёвой ячейки, после чего работа только началась.

Хореография выбрана не по эвристике, а по отсутствию альтернативы. D-04 советует при ничьей брать ту ячейку, где есть дирижёр: наблюдаемость дороже связности. Ничьей не было — дирижировать нечем, шаг один. Но совет всё равно сработал в другую сторону: раз дирижёра нет, наблюдаемость придётся строить отдельно, и это ушло в последствия отдельной строкой, а не осталось предположением.

Самая дорогая будущая граница#

Между раздачей и порядком в ленте. Раздача на запись предполагает, что порядок ленты одинаков для всех читателей — сейчас это хронология, ключ сортировки равен времени поста. Ранжирование, стоящее в роадмапе, эту границу пересекает по-разному:

  • ранжирование от поста (свежесть, тип, автор) совместимо: ключ сортировки считается один раз при раздаче;
  • ранжирование от читателя (интересы, история) несовместимо в принципе: материализовать порядок, который у каждого свой, нельзя — раздача превращается в вычисление на чтении, и вся схема ADR-0001 теряет смысл.

Это та линия, по которой изменение обойдётся дороже всего: продуктовое требование, звучащее как «улучшить ленту», отменяет три ADR сразу. Записано здесь, потому что через год связь между словом «ранжирование» и стоимостью переделки будет неочевидна.

Последствия#

  • Отставание становится наблюдаемой величиной и обязано попасть в SLO. 10 секунд — уступка продукта, а не свойство системы; без метрики «время от публикации до последней вставки, p99» она незаметно превратится в минуты.
  • Идемпотентность держится на структуре хранения ленты. Это надо зафиксировать явно: смена структуры ломает И3 молча, без единой ошибки в логах.
  • Наблюдаемость строится отдельно. aec — самая слабая ячейка по этому признаку; процесс невидим без сквозной трассировки. Трассировка есть три месяца [орг], но покрывает ли она путь через Kafka — не проверено.
  • Состояние «раздача не завершена» существует явно и нормально. Это не отказ и не должно алертить; алертить должно её отсутствие в течение времени, превышающего уступку.
  • Гибрид перекладывает работу на путь чтения. Подмешивание постов крупных авторов при каждом открытии ленты — новая нагрузка, и её стоимость решается в ADR-0004 (кэш и чтение, этап 5). Порог 100 тыс. выбран здесь, а оплачивается там.
  • Событие post.created в общей шине притянет потребителей — уведомления, поиск, модерация, аналитика. Каждый из них не участник нашего процесса, и дисквалификатор «пять и более участников» на них не сработает. Следить придётся руками, см. условия пересмотра.

Условия пересмотра#

  • Ранжирование потребовало порядка, зависящего от читателя — см. самую дорогую будущую границу.
  • Отставание перестало укладываться в 10 секунд при пиковой публикации.
  • Появился инвариант, требующий атомарности: например, «пост виден всем подписчикам одновременно» — эмбарго, платные подписки.
  • На post.created подписалось больше трёх независимых потребителей. Наш процесс останется двухучастниковым, но вопрос «что вообще происходит после публикации» станет неотвечаемым без каталога подписчиков события.
  • Распределение подписок изменилось так, что порог 100 тыс. перестал отсекать половину рёбер.
  • Раздача перестала быть одношаговой — например, между чтением графа и вставкой появилась фильтрация по правам или блокировкам. Тогда участок B станет настоящей сагой, и координата начнёт что-то значить.

Связанные ADR#

Зависит от: ADR-0001 (структура ленты даёт идемпотентность), ADR-0002 (граница посты → лента и есть участок A). Зависимые: ADR-0004 (подмешивание при чтении — цена порога гибрида). Отвергнутые варианты и условия пересмотра — в constraints.md.