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

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.