ADR-0004 · Кэши на пути чтения ленты#
Статус: принят · 2026-09-16
Метод: D-03 v2.0 · шаблон ADR v1.2
Участники: команды «лента», «посты», «платформа»
Учебная запись: часть прогона E-02, система вымышлена. Форма и рассуждение настоящие.
Контекст#
Вход — 00-context.md. Хранилище лент — ADR-0001, границы — ADR-0002, раздача — ADR-0003.
Материализация убрала фан-аут по подпискам, но путь чтения не стал коротким. После трёх ADR открытие ленты состоит из четырёх шагов, и три из них упираются в источник:
| Шаг | Что делает | Обращений в пике | Куда упирается |
|---|---|---|---|
| 1 | Страница ленты из Redis | 12 000/с | Никуда: одна операция по ключу, ради этого ADR-0001 и писался |
| 2 | Узнать, на каких крупных авторов подписан читатель | 12 000/с | Граф подписок, 48 ГБ в PostgreSQL |
| 3 | Подмешать свежие посты этих авторов — гибрид из ADR-0003 | 1,8 млн/с [расчёт]: 12 000 × 150 |
Хранилище постов |
| 4 | Досбор 40 постов страницы по идентификаторам | 480 тыс./с [расчёт]: 12 000 × 40 |
PostgreSQL постов — уже узкое место [замер] |
Счёт за порог гибрида пришёл сюда. ADR-0003 выбрал порог 100 тыс. подписчиков по нагрузке на запись: отсечение половины рёбер графа снизило пик вставок с ~36 тыс. до 17 400/с. Обратная сторона видна только теперь: если половина рёбер не раскладывается, то половина подписок среднего читателя — крупные авторы, то есть ≈ 150 источников подмешивания на каждое открытие ленты. Решение о записи назначило цену чтению, и цена оказалась крупнейшим числом на всём пути.
Бюджет задержки перестал быть ограничением. Целевые 90 мс на сборку и 60 мс на досбор [продукт] при работающих кэшах расходуются на единицы миллисекунд. Ограничение здесь не задержка, а пропускная способность источников: 1,8 млн и 480 тыс. обращений в секунду не выдержит ни одно из имеющихся хранилищ.
Узких мест два, значит и применений документа два#
D-03 v2.0 требует применяться к одному подтверждённому узкому месту за раз. Шаги 3 и 4 — разные узкие места с разными ответами на все пять вопросов, поэтому ниже два применения и две записи.
Шаг 2 в это пространство не попадает вовсе. Список крупных подписок читателя меняется редко и читается на каждое открытие — но правильный ход здесь не кэш, а материализация: держать рядом с лентой готовый набор feed:large:{user_id} и обновлять его при изменении подписки. Это ровно тот же приём, что и сама лента, и документа под него по-прежнему нет — находка 1 получает второй случай. Решение принято вне метода: ≈ 150 идентификаторов на пользователя, 12 ГБ к имеющимся 64 ГБ — прирост на пятую часть, порог пересмотра ADR-0001 (рост на порядок) не задет.
Ответы на пять вопросов — по каждому узкому месту#
| Вопрос | Шаг 3 · подмешивание | Шаг 4 · досбор постов |
|---|---|---|
| Узкое место подтверждено? | [расчёт] 1,8 млн обращений/с |
[расчёт] 480 тыс./с к базе, которая уже узкое место [замер] |
| Чтений к записям, уникальных ключей | 1,8 млн/с на ≈ 5 000 ключей — по одному на крупного автора [расчёт]: 1,5 млрд рёбер выше порога при средней аудитории ~300 тыс. |
480 тыс./с на 1–2 млн ключей (посты за сутки-двое: 12/с × 86 400 ≈ 1 млн) |
| Допустимая устарелость | 10 с [продукт] |
Ноль по удалению — И2 юридический |
| Что будет, если кэш исчезнет в пик | Источник ляжет | Источник ляжет |
| Цена промаха и попадания | Попадание — обращение к памяти процесса; промах — сетевой запрос | Попадание ≈ 2 мс на батч; промах — запрос к перегруженной базе |
Замера для новой архитектуры нет и быть не может. 900 мс и 780 мс из входа измерены на старой схеме, которой после ADR-0001 не существует. Основание — расчёт по спроектированному пути, и правка E-01 («расчёт — достаточное основание, догадка — нет») впервые сработала в этом виде: не на гринфилде, а на ещё не построенном участке работающей системы.
Дисквалификаторы#
| Дисквалификатор D-03 | Шаг 3 · подмешивание | Шаг 4 · досбор постов |
|---|---|---|
| Система не переживает отказа кэша в пик | Да — в форме, которой документ не предполагал: у локального кэша отказ совпадает с перезапуском процесса, а автовыкат идёт несколько раз в день | Да. Холодный кэш = 480 тыс./с в базу, которая не держит и текущего |
| Устарелость недопустима вовсе | Нет: 10 с разрешено | Да по удалению — и здесь дисквалификатор столкнулся с умолчанием, см. ниже |
| Write-behind для данных, потеря которых недопустима | Нет: кэш читающий | Нет: кэш читающий |
| Уникальных ключей столько же, сколько запросов | Нет: 5 000 ключей на 1,8 млн обращений — идеальный случай | Нет: каждый ключ читается сотни раз |
| Узкое место не подтверждено | Нет: расчёт | Нет: расчёт поверх замера |
Дисквалификатор «устарелость недопустима» предписывает ровно умолчание. Документ говорит: такие данные «кэшируются с явной инвалидацией и коротким TTL, и это уже не умолчание». Но умолчание дословно — «cache-aside + удаление ключа при записи + короткий TTL как страховка». Это те же слова. Либо дисквалификатор лишний, либо умолчание описано неточно; отличить их по тексту нельзя. Записано как находка 13.
Дисквалификатор про отказ кэша сработал во второй раз подряд и во второй раз потребовал документа, которого нет. В E-01 он выдавил наружу решение о деградации — то самое D-06, отложенное по развилке Б2. Здесь он выдавливает его снова, на кейсе, не похожем ни одной осью. Деградация ниже записана как обязательство, а не как решение: решать её нечем.
Применение 1 · Подмешивание постов крупных авторов#
Решение: локальный кэш в процессе, карта «крупный автор → 40 свежих
идентификаторов», обновление подпиской на post.created,
прогрев при старте экземпляра
Основание: 1,8 млн обращений/с на ≈5 000 ключей, ≈5 МБ на экземпляр,
допустимая устарелость 10 с [расчёт]
Отклонения: два, и они гасят цену друг друга —
«локальный кэш в процессе» (задержка до общего кэша
сравнима с работой) и «обновление по событию вместо TTL»
(популярность ключей предсказуема)
Вернуться: доля подмешанных постов старше 10 с; число крупных авторов
выросло так, что карта перестала быть маленькой;
время прогрева экземпляра сравнялось с интервалом выката
Метод: D-03 v2.0
Почему локальный, а не общий. 1,8 млн обращений в секунду к общему кэшу — это сетевой участок на каждое из 150 подмешиваний, то есть кластер, собранный ради операции, которая стоит наносекунду. Карта в 5 МБ помещается в каждый экземпляр целиком. Цена по таблице отклонений — рассогласование между экземплярами — здесь укладывается в уже выданную продуктом уступку 10 секунд: два читателя могут увидеть пост крупного автора с разницей в секунды, и это ровно то, что разрешено.
Почему обновление по событию, а не TTL. Короткий TTL на 5 000 ключей в каждом из десятков экземпляров превращается в тысячи обращений к источнику в секунду — ради данных, которые меняются 12 раз в секунду на всю систему. Экземпляр и так может быть потребителем post.created (ADR-0003): поток крошечный, а карта обновляется точно и сразу.
Два отклонения сложились, и их цены не сложились. Таблица отклонений D-03 устроена как «условие → отклонение → чем платим», по строке на отклонение, и молчит о том, что бывает при наложении. Здесь цена refresh-ahead («тратится на данные, которые могли не понадобиться») исчезла именно потому, что кэш локальный: в карте лежат только крупные авторы, и каждое из 12 событий в секунду попадает в неё по делу. Записано как находка 14.
Применение 2 · Досбор постов по идентификаторам#
Решение: умолчание — cache-aside, ключ post:{id}, инвалидация по
событию post.deleted, TTL 10 мин как страховка
Основание: 480 тыс. чтений/с на 1–2 млн ключей, пост неизменяем после
публикации, устарелость по удалению недопустима (И2)
[расчёт]
Отклонения: нет
Вернуться: hit ratio ниже расчётных 99 %; нагрузка на PostgreSQL при
холодном кэше; доля ответов с удалённым постом (сверка
выборкой) выше нуля
Метод: D-03 v2.0
Умолчание подошло целиком, и это противоположность E-01. Главная развилка читающего кэша, добавленная в D-03 v2.0 по итогам первого прогона, — время жизни ключа против интервала между чтениями. Там 300 записей в секунду в один ключ давали ему 3 мс жизни, и «удаление при записи» оказалось отказом кэша работать. Здесь пост не меняется после публикации вовсе: время жизни ключа ограничено только удалением, то есть сутками, а читается он сотни раз в секунду. Отношение перевёрнуто, и та же строка таблицы уводит ровно в противоположную сторону.
Расчёт попаданий. Промахи дают только три источника: истечение TTL (2 млн ключей ÷ 600 с ≈ 3 300/с), первое чтение нового поста (12/с) и вытеснение холодных ключей. Итого ≈ 99,3 % попаданий, и PostgreSQL видит около 3 300 запросов в секунду вместо 480 тысяч — в сто сорок раз меньше [расчёт].
Зачем TTL, если есть инвалидация. Инвалидация идёт событием post.deleted через ту же шину: доставка at-least-once, повторное удаление ключа безвредно. Но потерянное событие означало бы показ удалённого поста неограниченно долго, а И2 — юридический риск. TTL 10 минут ограничивает окно: после истечения ключ берётся из источника, где поста уже нет. Это не оптимизация, а верхняя граница ущерба от отказа механизма, и менять её как параметр производительности нельзя.
Рассмотрен и отвергнут вариант без инвалидации: держать отдельный набор идентификаторов, удалённых за последнюю неделю (≈ 73 тыс. ключей [расчёт]), и фильтровать страницу по нему. Проще по надёжности — фильтр не зависит от доставки события, — но добавляет обращение на каждое открытие ленты ради события, случающегося раз в десять секунд. Записан в constraints.md с условием пересмотра: если удалений станет сопоставимо с публикациями, фильтр выигрывает.
Самая дорогая будущая граница#
Между порогом гибрида и стоимостью чтения. Порог 100 тыс. — одно число, которое ADR-0003 выбрал по нагрузке на запись, а оплачивает путь чтения: он определяет и число подмешиваний на открытие (≈ 150), и размер материализованного списка крупных подписок (12 ГБ), и размер карты в каждом экземпляре. Опустить порог вдвое — удвоить всё это; поднять — вернуть нагрузку на раздачу.
Опасность не в самом числе, а в том, что оно живёт в двух ADR и меняется как настройка. Если через год кто-то опустит порог, чтобы разгрузить раздачу, счёт придёт сюда, и связь будет неочевидной. Порог обязан иметь одного владельца и единственное место, где он записан вместе с обеими сторонами счёта.
Что придётся построить#
- Деградация при холодном кэше — до включения кэшей, а не после. Оба дисквалификатора требуют её, документа под неё нет. Минимум: ограничение частоты досбора, укороченная страница при просадке, прогрев из ленты. Это продуктовое решение, и оно остаётся открытым.
- Прогрев локальной карты при старте экземпляра. Свежевыкаченный экземпляр с пустой картой отдаёт ленты без постов крупных авторов — то есть без половины содержимого. Экземпляр не должен принимать трафик до прогрева.
- Сверка выборкой на удалённые посты. Единственный способ заметить, что инвалидация перестала работать: hit ratio при этом не меняется, ошибок в логах нет, а И2 нарушается молча.
- Материализованный список крупных подписок
feed:large:{user_id}и его обновление при изменении подписки. Принят вне метода, см. выше. - Единое место для порога гибрида — конфигурация с обеими сторонами счёта в комментарии.
Условия пересмотра#
Сроков нет: оба решения обратимы, их пересматривает метрика.
- Hit ratio досбора устойчиво ниже 99 % — кэшируется не то, или окно чтения глубже, чем сутки.
- Доля подмешанных постов старше 10 секунд вышла за уступку.
- Число крупных авторов выросло на порядок: карта в 5 МБ перестала быть бесплатной, и локальный кэш придётся менять на общий.
- Посты стали изменяемыми — редактирование переворачивает применение 2 целиком: время жизни ключа перестанет быть сутками, и главная развилка
D-03сработает так же, как в E-01. - Удалений стало сопоставимо с публикациями — выигрывает отвергнутый вариант с набором удалённых.
- Порог гибрида изменён — пересчитать всё это применение целиком.
Связанные ADR#
Зависит от: ADR-0001 (лента даёт идентификаторы), ADR-0002 (граница лента → посты и есть шаг досбора), ADR-0003 (порог гибрида порождает шаг подмешивания).
Отвергнутые варианты и условия пересмотра — в constraints.md.