Read in:
Русский

Кеш страниц и неожиданное узкое место

Профилировщик показал: обычная страница документации в 30 раз дороже кастомного лендинга. Мы думали наоборот.

Под нагрузкой стояли два варианта. Jet-главная: кастомный лейаут с mesh-сеткой, фирменным дизайном, ручной вёрсткой. И страница документации в default-темплейте, которую получаешь без всякой настройки. Казалось бы: дорогой рендер у того, что сложнее. Профилировщик сказал: docs-страница — ~28 мс/запрос, Jet-главная — ~0,9 мс/запрос. В тридцать раз медленнее. И всё это в одном месте: синхронный поиск по косинусному сходству по всем эмбеддингам заметок на каждый запрос, подписанный «похожие заметки».


Дорогое место было невидимым

На каждый запрос trip2g загружает страницу, собирает лейаут и до отправки ответа перебирает все векторы заметок, считает косинусное сходство для каждого и отбирает топ-совпадения как «похожие материалы». Без кеша, на каждый запрос, для каждого читателя. На небольшом хранилище (сотни заметок) — ~28 мс чистых вычислений на пути чтения, около 94% CPU-бюджета одного запроса.

У Jet-главной ничего этого не было. Парсинг шаблона уже кеширован, страница собирается быстро. Визуально сложнее — вычислительно дешевле больше чем на порядок.

Вывод: мерить, а не предполагать. Узкое место редко там, где кажется.


Что делает кеш анонимных страниц

Можно было ускорить поиск. Эффективнее оказалось не запускать его на каждый запрос. Кеш анонимных страниц хранит готовый gzip-ованный HTML-ответ по ключу (путь, хост, версия заметки, конфиг, язык). При попадании в кеш сервер копирует готовые байты из памяти: ни косинусный поиск, ни шаблонизатор, ни gzip не запускаются.

Версионирование держит всё корректным: при обновлении заметки её запись в кеше инвалидируется. Первый запрос после изменения контента платит 28 мс и кеширует результат. Все последующие читатели не тратят на это ни миллисекунды.

Измерено на 12-ядерной dev-машине, одна страница под непрерывной нагрузкой: docs-страница выдавала 421 зап/с до кеша и 8 504 зап/с после прогрева — в 20 раз. CPU на попаданиях в кеш: около 0%.

Jet-главная и так была быстрой. После кеша — тоже около 9 000 зап/с. Кеш уравнивает дорогие и дешёвые страницы на одном потолке пропускной способности. Этот потолок оказался сетью и системными вызовами, а не прикладной логикой.

Почему кеш только для анонимных читателей. Авторизованный пользователь может быть администратором (видит элементы управления редактирования), подписчиком (у него другой доступ к материалам) или иметь персональный контекст, который отличает его страницу от анонимной. Отдать такому читателю кешированную анонимную версию — значит рискнуть показать не то.

Здесь есть нюанс, специфичный для default-темплейта: разница между видом анонима и авторизованного (кнопка входа, значок редактирования) рендерится клиентским скриптом в браузере. HTML с сервера для обоих одинаков. Технически кеш можно было бы распространить на авторизованных пользователей в default-темплейте без риска. Но не для кастомных лейаутов и не для заметок за пейволлом. Текущий дизайн выбирает простое и надёжное решение: любой авторизованный запрос обходит кеш.


Как мерили: три ловушки

Бенчмарк запущен 2026-06-29 на двух реальных Hetzner shared-vCPU VM:

  • cx23 (2 vCPU / 3,8 ГБ, €6,49/мес) — тестируемый сервер
  • cx33 (4 vCPU / 7,7 ГБ, €8,99/мес) — генератор нагрузки

Атака с cx33 по сети, разные машины. vegeta на неограниченном темпе: 12 секунд (всплеск), потом 10 минут (устойчивая нагрузка).

Результат: cx23 выдал около 9 000 зап/с во всплеске и 8 557 зап/с в среднем за 10 минут. Ноль ошибок. CPU steal — 0% весь прогон.

Три альтернативных стенда, каждый со своей ловушкой.

Ловушка 1: атака с той же машины. Запустили vegeta на том же cx23. Получили около 5 400 зап/с. Генератор делил два ядра с сервером. CPU сервера не упирался в 100%; упирался атакующий. Мы мерили потолок генератора, а не сервера. Межмашинная цифра — реальный потолок.

Ловушка 2: слабый атакующий, сильный сервер. Trip2g на cx33, атака с cx23. CPU cx33 — 43–45%. Cx23 не смог насытить cx33 нагрузкой. Ограничение атакующего, не измерение сервера.

Ловушка 3: steal как индикатор дросселирования. Shared vCPU подвержен fair-use-ограничениям: провайдер может забрать процессорное время при перегрузке физического хоста. Сигнал — метрика steal. За весь 10-минутный прогон steal держался на 0%. Throttling не было. Полученные 8 557 зап/с — то, что сервер реально выдал.

Почему важен именно 10-минутный прогон: провайдер может пропускать короткие всплески без ограничений и дросселировать длительную нагрузку. Двенадцать секунд с нулевым steal — недостаточная проверка. Прошло. Но оговорка остаётся: максимальная нагрузка 24/7 на shared vCPU в конечном счёте может задействовать fair-use-ограничения. Для такого режима подходит dedicated-vCPU.


Что дальше

Кеш анонимных страниц скрывает косинусный поиск для попаданий в кеш, но не устраняет его. Два случая по-прежнему платят полную цену:

  • Cache miss: первый запрос после изменения контента.
  • Авторизованные пользователи: обходят кеш.

Следующий шаг — структурный: вычислять «похожие заметки» один раз при перезагрузке заметки, хранить рядом с отрендеренным HTML и отдавать из памяти при каждом чтении. O(N)-поиск уходит с пути чтения и заменяется одним обращением к памяти. Когда хранилища вырастут до размеров, при которых и разовое сканирование станет медленным, следующий шаг — ANN-индекс.

Бенчмарк показал, где искать. До измерения мы считали, что дорогой кейс — кастомный Jet-лейаут. Оказалось наоборот. Это открытие указало на нужную оптимизацию, после которой кеш для большей части скрытых им вычислений станет ненужным.