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.