MAATRIX / Блог / Кеширование на границе: что реально можно отдавать с edge, а что придётся везти с сервера

Кеширование на границе: что реально можно отдавать с edge, а что придётся везти с сервера

MAATRIX

Подключили CDN, включили кеш «на всё» — и через день в саппорт летит скриншот с чужим именем в личном кабинете. Или наоборот: настроили кеш так осторожно, что каждый запрос всё равно долетает до origin, а CDN просто добавил лишний хоп. Граница между «можно кешировать на edge» и «нужно везти к серверу» не проходит по типу файла — она проходит по тому, зависит ли ответ от того, кто его запросил. Разберём это практически: что реально безопасно отдавать с границы сети, какие приёмы позволяют кешировать больше, не ломая персонализацию, и почему одна ошибка в ключе кеша превращается в инцидент с чужими данными.

Что хорошо кешируется на edge

Правило простое: если ответ одинаков для всех, кто его запросит с одними и теми же параметрами запроса — его можно класть в edge-кеш. Три категории попадают сюда почти всегда:

Статика без персонализации. Изображения, шрифты, CSS и JS. Если в имени файла или пути есть версия или хеш содержимого (app.a3f9c1.js, /static/v42/style.css), можно смело ставить Cache-Control: public, max-age=31536000, immutable — файл с таким именем никогда не изменится, а при обновлении сборки поменяется само имя, и старая версия просто перестанет запрашиваться. Это устраняет главную боль классического кеша: не нужна инвалидация вообще, потому что нет мутации существующего объекта.

# nginx отдаёт статику с хешем в имени
location ~* \.[0-9a-f]{8}\.(js|css|woff2)$ {
    add_header Cache-Control "public, max-age=31536000, immutable";
}

Публичные API-ответы с редко меняющимися данными. Каталог товаров, список городов доставки, курс валют, обновляемый раз в час, публичная документация API. Здесь персонализации нет, но данные всё же меняются — значит, нужен TTL, а не immutable.

Полностью статичные страницы. Лендинги, блог без комментариев с разбивкой по пользователю, страницы «о компании». Если HTML собирается один раз на сборке или рендерится сервером одинаково для всех — это идеальный кандидат на edge-кеш с длинным TTL и явной инвалидацией по деплою.

Общий признак всех трёх категорий: ответ не содержит имени пользователя, остатка на счету, содержимого корзины, CSRF-токена или чего угодно, что меняется от посетителя к посетителю. Если сомневаетесь — откройте ответ curl'ом дважды из разных сессий и сравните побайтово.

Что нельзя отдавать с edge напрямую

Обратная сторона того же правила. Три типа контента нужно либо не кешировать вовсе, либо кешировать очень аккуратно, с явной привязкой к идентичности пользователя:

  • Персонализированный контент. Личный кабинет, история заказов, корзина, дашборд с именем и балансом. Кешировать HTML целиком — антипаттерн, который регулярно приводит к утечке чужих данных первому же посетителю после автора страницы.
  • Контент с авторизацией без грамотной вариации по заголовкам. Если ответ зависит от Authorization или сессионной cookie, а кеш-ключ строится только по URL — вы кешируете ответ для одного пользователя и раздаёте его всем остальным. Это не гипотетический риск, а конкретная и частая причина инцидентов.
  • Real-time данные. Котировки, статус заказа «курьер в пути», остаток товара на складе в момент высокого спроса, WebSocket- и SSE-потоки. Кеш здесь либо бессмысленен, либо опасен: пользователь увидит состояние пятиминутной давности и примет решение на основе неактуальных данных.

Практическое правило: если для формирования ответа сервер читает cookie, заголовок Authorization или сессию — по умолчанию ставьте Cache-Control: private, no-store и добавляйте кеширование только осознанно, отдельным решением по каждому эндпоинту, а не «включили CDN — само разберётся».

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Edge Side Includes: собрать страницу из кешируемого и некешируемого

Часто страница не делится чисто на «кешируется» и «не кешируется» — она наполовину статична (шапка, меню, футер, блок «похожие товары»), наполовину персональна (имя пользователя, счётчик корзины). Городить два полных варианта HTML — расточительно. Здесь работает идея edge-side includes (ESI): страница собирается из фрагментов на самом edge-узле, и у каждого фрагмента — свой TTL.

Общая схема (детали синтаксиса отличаются у разных прокси и CDN, суть одна):

<!-- Каркас страницы отдаётся с длинным TTL -->
<header><!--esi <esi:include src="/fragments/menu" /> --></header>

<main>
  <!-- Персональный блок — отдельный запрос с private-кешем или без кеша -->
  <esi:include src="/fragments/user-greeting" />

  <!-- Список товаров — кешируется отдельно, TTL короче каркаса -->
  <esi:include src="/fragments/related-products?category=42" />
</main>

Edge-узел собирает готовую страницу из закешированного меню, закешированного (или нет) блока товаров и живого персонального фрагмента — и всё это одним ответом клиенту. Пользователь получает страницу, собранную за один RTT до edge, хотя фрагменты внутри неё имеют разный жизненный цикл. Минус подхода — сложность отладки: если один фрагмент отдаёт 500, нужно решить, показывать ли деградированную версию страницы или всю целиком; и не каждый CDN или reverse proxy поддерживает ESI «из коробки» — у части провайдеров это отдельная опция или вообще собственный похожий механизм под другим названием, так что перед внедрением стоит свериться с документацией конкретного провайдера, а не полагаться на один и тот же синтаксис везде.

Более простая альтернатива без ESI — рендерить каркас на edge через edge-функцию (Cloudflare Workers, Fastly Compute и аналоги) и подставлять персональный блок через клиентский JS-запрос уже после загрузки статичной части страницы. Дороже по количеству запросов, зато не требует поддержки ESI-синтаксиса конкретным провайдером.

Vary: один URL — не всегда один и тот же ответ

Заголовок Vary говорит кеширующему узлу: «этот URL может отдавать разные ответы в зависимости вот от этих заголовков запроса, храни их как отдельные записи кеша». Классический пример — сжатие:

Vary: Accept-Encoding

Это значит, что кеш должен хранить отдельно gzip-версию и обычную, а не отдать клиенту без поддержки gzip сжатый ответ. То же самое применимо к языку интерфейса (Vary: Accept-Language), к мобильной/десктопной верстке при server-side адаптации (Vary: User-Agent, хотя это довольно грубый инструмент из-за огромного числа значений User-Agent) и — с осторожностью — к авторизации.

Соблазн решить проблему персонализации через Vary: Cookie возникает часто и почти всегда оказывается ловушкой: у каждого пользователя cookie уникальна, значит, кеш-хитов не будет вообще — вы получите ключ кеша практически на каждого посетителя отдельно, что равносильно отключению кеша, но с накладными расходами на хранение мусора. Работающий вариант — не варьировать по всей cookie, а явно нормализовать: на уровне edge-логики (edge-функция или логика origin перед прокси) вычленить из cookie один осмысленный признак — например, is_authenticated: 0/1 или ab_group: A/B — и варьировать кеш по нему через собственный заголовок:

# origin выставляет нормализованный заголовок вместо сырой cookie
X-Cache-Variant: anon
Vary: X-Cache-Variant

Тогда у кеша разумное число вариантов ответа (обычно единицы), а не по одному на пользователя. Это чуть больше работы на старте, зато превращает «нельзя кешировать, там же авторизация» в «кешируется двумя вариантами — для гостя и авторизованного».

Stale-while-revalidate: отдать устаревшее, лишь бы не ждать

stale-while-revalidate — директива Cache-Control, которая разрешает кешу отдать клиенту устаревший (просроченный) ответ немедленно, и параллельно, в фоне, сходить на origin за свежим — так, что следующий посетитель получит уже обновлённые данные, а текущий не ждёт лишний RTT до сервера.

Cache-Control: max-age=60, stale-while-revalidate=600

Здесь запись кеша считается свежей 60 секунд. Следующие 600 секунд после истечения она уже устарела, но edge всё равно отдаёт её мгновенно — и в этот же момент асинхронно обновляет запись, обратившись к origin. Пользователь никогда не видит задержку revalidation-запроса: он либо получает кеш (свежий или чуть устаревший), либо, если и окно stale-while-revalidate истекло, ждёт origin как обычно.

Практическая ценность приёма — сгладить «эффект стада» (thundering herd), когда истечение TTL совпадает по времени у множества запросов и все они одновременно бьют в origin. Об этом эффекте и о том, почему короткий TTL иногда безопаснее длинного, стоит почитать отдельно — это частая причина, когда кеш на минуту ведёт себя хуже кеша на пять секунд. Со stale-while-revalidate фоновое обновление запускает только один запрос, остальные продолжают получать устаревший, но валидный ответ, пока обновление не завершится.

Есть соседняя директива, stale-if-error, которая разрешает отдавать устаревший кеш, если origin ответил ошибкой или недоступен вовсе — полезно как деградация при кратковременных сбоях сервера, но не как постоянная замена мониторинга: если origin лёг надолго, а кеш «протухнет» полностью, посетители всё равно увидят ошибку.

Кеширование API: GET с коротким TTL как компромисс

Для GET-эндпоинтов API редко бывает выбор «кешировать полностью или не кешировать вовсе». Обычно нужен компромисс между свежестью данных и нагрузкой на origin — и короткий TTL (секунды, не минуты) закрывает этот компромисс лучше, чем кажется на первый взгляд.

Идея: если у эндпоинта /api/products?category=42 в пике 500 запросов в секунду от разных пользователей, но данные меняются раз в несколько минут, кеш даже на 3–5 секунд снимает почти всю нагрузку с origin — из 500 запросов в секунду до origin долетит один, остальные 499 получат тот же ответ из edge-кеша. При этом задержка устаревания данных для конечного пользователя практически незаметна.

# короткий TTL для публичного GET-эндпоинта каталога
Cache-Control: public, max-age=5, stale-while-revalidate=30

Важные оговорки для API-кеширования:

  • Кешируйте только идемпотентные GET-запросы. POST, PUT, PATCH, DELETE кешировать нельзя вообще — это меняет состояние, и повторная отдача из кеша либо бессмысленна, либо опасна.
  • Кеш-ключ должен учитывать все значимые query-параметры (category, sort, page) — иначе разным ответам присвоится один и тот же ключ и начнётся путаница, кому что отдаётся.
  • Для эндпоинтов, зависящих от пользователя (/api/me/orders), короткий TTL не спасает — это персонализированный контент, к которому применимо правило из раздела выше, а не общее кеширование по URL.
  • Порядок величины TTL подбирается под конкретный эндпоинт исходя из того, насколько критична свежесть данных и какую нагрузку origin реально выдерживает — единого «правильного» числа для всех случаев нет, это всегда компромисс, который нужно проверять на своих цифрах, а не переносить из чужой статьи.

Как персональный кеш утекает к чужому пользователю

Стоит явно назвать риск, а не обходить его стороной: кеширование персонализированного контента без учёта заголовков или cookie в ключе кеша — одна из самых частых и самых неприятных ошибок конфигурации CDN и reverse-прокси. Механика простая. Кеш строит ключ по URL (иногда — по URL плюс паре заголовков). Если страница /account/dashboard отдаёт разный HTML в зависимости от cookie сессии, а кеш-ключ построен только по пути, происходит следующее: первый посетитель получает страницу, edge её кеширует под ключом /account/dashboard, второй посетитель — с другой сессией — получает из кеша страницу первого. Разбор похожего инцидента с чужой сессией в кеше — отдельная история, и похожая механика случается не только со страницами целиком, но и точечно — например, когда в ключе кеша не было идентификатора пользователя, и вместо своей корзины покупатель видел чужую.

Это техническая ошибка конфигурации, а не какая-то экзотическая уязвимость — и закрывается она системно, а не патчем на скорую руку:

  1. По умолчанию всё, что читает cookie или Authorization, помечайте Cache-Control: private, no-store на уровне origin — кешируемость должна включаться явно, а не быть состоянием по умолчанию.
  2. Для того, что всё же нужно кешировать частично, используйте нормализованный Vary (см. раздел выше), а не сырую cookie целиком.
  3. Перед выкладкой в продакшен проверяйте поведение вручную: два curl-запроса с разными cookie к одному URL должны либо оба обойти кеш, либо получить корректно различающиеся ответы — если оба ответа идентичны, а различаться должны, это сигнал протестировать конфигурацию до релиза, а не после жалобы пользователя.
  4. Логируйте X-Cache: HIT/MISS и периодически сверяйте, какие пути реально попадают в кеш — иногда персональный эндпоинт закешировался случайно из-за общего правила по префиксу пути, которое никто не сузил.

Хорошая новость в том, что это ровно та категория ошибок, которая один раз настраивается правильно и потом не требует внимания — в отличие от постоянной борьбы за свежесть данных.

Нужен сервер под эту задачу?

Разверните VPS MAATRIX за пару минут: NVMe, AMD EPYC, root-доступ, локации UK, США, Франция и РФ. Оплата картой РФ и по СБП.

Арендовать сервер

Нужны сами нейросети для контента?

Генерируйте изображения, видео и озвучку нейросетями на falapi.io — десятки моделей в одном окне. Оплата картой РФ и по СБП.

Частые вопросы

Можно ли кешировать HTML целиком, если на странице нет явной персонализации, но есть CSRF-токен в форме?

Формально нет: если токен уникален на сессию, кешированная страница отдаст один и тот же токен всем посетителям, и либо форма перестанет работать у части пользователей, либо появится общая уязвимость по CSRF. Токен стоит либо вынести в отдельный некешируемый фрагмент (см. ESI), либо генерировать через JS-запрос после загрузки страницы.

Что произойдёт, если поставить и Cache-Control: private, и Cache-Control: public для одного ответа?

Заголовки Cache-Control не должны противоречить друг другу — на практике поведение зависит от конкретного кеширующего узла и не гарантировано документацией стандарта в спорных случаях, поэтому для каждого ответа нужно выставлять один согласованный набор директив, а не полагаться на то, что промежуточный узел «разберётся сам».

Стоит ли кешировать 404 и другие ошибки?

Короткий TTL на 404 (секунды) оправдан, если один и тот же несуществующий путь запрашивают часто (боты, сканеры) — это снимает нагрузку с origin. Долгий кеш ошибок опасен: если страница появится, а старый 404 всё ещё в кеше, пользователи какое-то время не увидят реальный контент.

Как проверить, что edge реально отдаёт из кеша, а не каждый раз ходит на origin?

Смотрите заголовок ответа X-Cache, CF-Cache-Status или аналог конкретного провайдера (называется по-разному у разных CDN) — значение HIT означает попадание в кеш, MISS — обращение к origin. Полезно сравнить время ответа (Server-Timing или просто curl -w "%{time_total}") между первым и повторным запросом одного URL.

Нужно ли включать edge-кеш, если сервер и так быстрый и запросов немного?

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

Обсудить статью, задать вопрос или начать новую тему

Есть вопрос по этой статье, идея для обсуждения или просто хотите поделиться опытом? Сообщество MAATRIX ждёт. Для общения, пожалуйста, зарегистрируйтесь в нашем личном кабинете.

Перейти в сообщество →