MAATRIX / Блог / Антипаттерн: кешировать персональные страницы целиком

Антипаттерн: кешировать персональные страницы целиком

MAATRIX

Кеш на уровне CDN или reverse proxy — обычно чистый выигрыш: страница отдаётся мгновенно, бэкенд не трогается, сервер выдерживает в разы больше нагрузки. Но у этого выигрыша есть цена, если применить его не разбирая: если в общий кеш попадает целиком страница с персональными данными — история заказов, имя, email, последние цифры карты, — рано или поздно её получит не тот пользователь, который её сформировал. Разбираем, почему это происходит технически, и как кешировать агрессивно, не открывая чужие данные всем подряд.

Почему это вообще возможно

Общий (shared) кеш — будь то CDN на границе сети или proxy_cache в Nginx перед бэкендом — по умолчанию не знает, что такое «личное». Для него запрос — это метод, путь и, если явно не настроено иначе, набор заголовков запроса, которые входят в ключ кеша. Ответ он тоже видит просто как байты: тело плюс заголовки. Кеш физически не различает страницу каталога, одинаковую для всех, и страницу /account/dashboard, где HTML для каждого пользователя разный.

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

Отсюда механика утечки: пользователь A открывает /account/dashboard, авторизованный своей сессией. Бэкенд рендерит страницу с именем A, его заказами, последними цифрами его карты — и отправляет ответ без заголовков, запрещающих кеширование. Reverse proxy или CDN видит: URL /account/dashboard, статус 200, тело ответа — и кладёт его в кеш по этому URL. Пользователь B открывает тот же /account/dashboard под своей сессией. Если ключ кеша не учитывает куку B, кеш находит совпадение по URL и отдаёт сохранённый ответ — с именем и заказами A. Кука B в браузере его, а вот HTML — чужой.

Это не гипотетический сценарий «а вдруг». Это стандартное поведение любого reverse-proxy-кеша и любого CDN при дефолтных настройках плюс персонализированный бэкенд, который не проставил правильные заголовки. Отдельный разбор одного такого случая — CDN закешировал страницу вместе с чужой сессией: там видно, как это выглядит со стороны саппорта и логов, а здесь — что делать, чтобы так не вышло.

Откуда это берётся на практике

Чаще всего причина не в злом умысле, а в том, что кеширование включали ради производительности и не подумали про персонализацию отдельно. Несколько типичных путей к этой проблеме:

  • Кеш включили «для скорости» на уровне, который ничего не знает о персонализации. CDN или Varnish поставили перед всем сайтом целиком, потому что часть страниц (каталог, статьи, лендинги) действительно выигрывает от кеша. Личный кабинет попал под общее правило автоматически, потому что никто не выделил его отдельно.
  • Бэкенд не проставляет Cache-Control вообще, полагаясь на то, что «кеш сам разберётся». Он не разберётся — при отсутствии явного заголовка многие прокси и CDN считают ответ кешируемым по собственной политике, которая может не совпадать с ожиданиями разработчика.
  • Set-Cookie есть, но только при первом логине. Многие CDN не кешируют ответ с заголовком Set-Cookie — но это срабатывает лишь на запросе логина. На последующих запросах /account/dashboard кука уже читается из сохранённой, Set-Cookie в ответе нет — и страница проходит критерий кешируемости.
  • Ключ кеша не учитывает идентификатор пользователя. Даже если Cache-Control настроен на кеширование «по-разному для разных пользователей», кеш смешает ответы, если в ключ не добавлена кука сессии или заголовок авторизации — тогда все бьют по одной ячейке.
  • CDN добавили поверх уже работающего приложения, изначально не проектировавшегося с расчётом на общий кеш перед собой — заголовки кеширования просто никогда не были частью архитектуры.
  • Смешали уровни кеширования в голове. Браузерный кеш (private) реально приватен — он живёт в браузере конкретного человека. Разработчик переносит эту логику на CDN, не заметив, что CDN — общий кеш на границе сети, один на всех, а не персональное хранилище одного клиента.

Общий знаменатель — кеш настраивали как инфраструктурную оптимизацию, не спрашивая на уровне каждого маршрута: «а этот ответ одинаков для всех, кто его запросит, или у него есть аудитория ровно из одного человека?»

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

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

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

Правильный подход: явные заголовки Cache-Control

Базовое правило простое: если ответ различается в зависимости от того, кто его запросил, он либо не кешируется вовсе, либо кешируется только в приватном (браузерном) кеше конкретного пользователя, но никогда — в общем кеше. Это регулируется заголовком Cache-Control, и для персонализированного контента правильные значения такие:

Cache-Control: private, no-store

no-store — самый жёсткий вариант: ответ вообще нигде не сохраняется, ни в общем кеше, ни в браузере. Уместен для страниц с действительно чувствительными данными — платёжные формы, страницы с полным номером карты, история операций с деньгами.

Cache-Control: private, no-cache

private запрещает общим (shared) кешам — CDN, reverse proxy — сохранять ответ вообще. Браузеру сохранять разрешено, но no-cache обязывает его каждый раз идти на сервер и проверять актуальность (через ETag/If-None-Match или Last-Modified/If-Modified-Since), прежде чем показать сохранённую копию. Разумный вариант для личного кабинета: браузер может закешировать статичную обвязку страницы, но обязан перепроверить актуальность данных.

Частая путаница — считать private самодостаточным. Технически он запрещает CDN и reverse-proxy-кешам сохранять ответ, но полагаться на то, что абсолютно все прокси на пути корректно разбирают Cache-Control, рискованно: часть старых или нестандартно настроенных узлов всё равно может закешировать GET-ответ без явного запрета. Для по-настоящему приватных данных no-store надёжнее.

Практический набор заголовков для персонализированной страницы на бэкенде:

Cache-Control: private, no-store, must-revalidate
Pragma: no-cache
Vary: Cookie, Authorization

Vary: Cookie и Vary: Authorization — подстраховка на случай, если где-то на пути всё же стоит кеш, который решит сохранить ответ несмотря на private: Vary заставляет его учитывать куку или заголовок авторизации при поиске совпадения, так что ответы разных пользователей не попадут в одну ячейку. Это защита второго эшелона, а не замена Cache-Control — при большом разбросе значений куки такой кеш просто перестаёт быть эффективным.

На уровне Nginx, если персонализированные маршруты идут через тот же сервер, что и кешируемые статичные, стоит явно исключить их из зоны кеша:

location /account/ {
    proxy_cache off;
    proxy_pass http://backend;
    add_header Cache-Control "private, no-store" always;
}

location / {
    proxy_cache my_cache;
    proxy_cache_valid 200 10m;
    proxy_pass http://backend;
}

Разбор частых промахов именно в конфиге Nginx — какие директивы перебивают друг друга, где proxy_cache_bypass спасает, а где нет — в статье Кеширование Nginx на сервере: частые ошибки и решения.

Кешировать только неперсонализированные фрагменты

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

Здесь помогает разделение на уровне рендеринга — fragment caching, кеширование отдельных фрагментов страницы, а не ответа целиком:

  • Собирать страницу на клиенте. Сервер отдаёт статичный HTML-каркас (кешируемый, общий для всех) и подгружает персональные данные отдельным запросом через JavaScript — к API, ответ которого явно помечен private, no-store. Кешируется только каркас, персональные данные вообще никогда не проходят через общий кеш.
  • Edge Side Includes (ESI) или аналог на уровне CDN. Часть CDN и Varnish поддерживают склейку страницы из нескольких фрагментов на границе сети: общие блоки кешируются с длинным TTL, персональный блок помечается как некешируемый и всегда идёт до бэкенда. Пользователь получает единый HTML-ответ, но фактически это сборка из разных источников с разной политикой кеширования.
  • Кеш на уровне приложения, а не HTTP-ответа. Персональные данные (список последних заказов, баланс) кешировать в Redis с ключом, включающим ID пользователя, — так кешируется дорогой запрос к базе, но результат отдаётся только владельцу этого ID, потому что ключ кеша сам по себе приватен.
  • Разделить маршруты по признаку персонализации явно. Каталог, статьи, лендинги — на один location с агрессивным proxy_cache. Личный кабинет, корзина, чекаут — на другой, с proxy_cache off. Смешение этих маршрутов под одним правилом — самый частый источник проблемы, разобранной в начале статьи.

Здесь стоит держать в голове миф-соседа этой темы — идею, что кеш сам по себе решает любую проблему производительности без учёта того, что именно кешируется. Он разобран отдельно в Миф: кеш решит проблему производительности. Кеш ускоряет то, что можно безопасно переиспользовать между разными запросами — если это условие не выполняется, кеш не оптимизация, а канал утечки.

Тестирование поведения кеша с разными пользователями

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

Минимальный протокол проверки:

  1. Проверить заголовки ответа напрямую.
curl -sI https://example.com/account/dashboard -H "Cookie: session=USER_A_TOKEN"

Убедиться, что в ответе есть Cache-Control: private, no-store (или no-cache), а не что-то более мягкое, что мог подставить CDN по умолчанию.

  1. Проверить заголовок статуса кеша, если он включён. Многие CDN и Nginx с proxy_cache отдают заголовок вроде X-Cache-Status или CF-Cache-Status. На персонализированном маршруте он должен стабильно показывать BYPASS или MISS, никогда HIT:
curl -sI https://example.com/account/dashboard -H "Cookie: session=USER_A_TOKEN" | grep -i cache-status
  1. Симулировать двух разных пользователей подряд. Сделать запрос под сессией A, затем сразу под сессией B на тот же маршрут, сравнить тела ответов — они обязаны отличаться персональными данными:
curl -s https://example.com/account/dashboard -H "Cookie: session=USER_A_TOKEN" -o resp_a.html
curl -s https://example.com/account/dashboard -H "Cookie: session=USER_B_TOKEN" -o resp_b.html
diff resp_a.html resp_b.html

Если diff показывает отсутствие различий там, где они обязаны быть (разные имена, разные заказы), а не просто совпадение общей вёрстки — это сигнал тревоги, требующий немедленной проверки конфигурации кеша.

  1. Проверить поведение под нагрузкой, не только вручную. Утечка через кеш часто проявляется не на первом запросе, а когда одновременно идёт много пользователей и кеш реально начинает срабатывать (HIT) на маршрутах, которые считались защищёнными. Нагрузочный тест с разными сессиями — не роскошь для персонализированных API, а часть проверки безопасности.
  2. Включить это в регрессионный чеклист, а не проверять один раз. Заголовки кеширования легко случайно сломать при рефакторинге бэкенда, смене CDN-провайдера или правке location-блоков в Nginx. Автоматический тест, который делает шаг 3 при каждом деплое, стоит на порядок дешевле, чем разбор инцидента постфактум.

Если утечка уже произошла

Порядок действий: сначала остановить утечку — отключить кеш на затронутом маршруте целиком (proxy_cache off или аналог на стороне CDN), а не точечно чинить заголовки под нагрузкой. Затем принудительно очистить (purge) уже накопленный кеш на всех edge-серверах CDN — иначе утечка продолжится с уже сохранёнными данными. После этого разобраться, кто реально мог получить чужие данные, — по логам CDN или reverse proxy, сопоставляя запросы к затронутому URL по времени с моментами HIT.

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

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

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

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

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

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

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

Достаточно ли одного Cache-Control: private, чтобы исключить утечку?

Формально да, если весь путь состоит из прокси, корректно следующих спецификации HTTP. На практике часть промежуточных кешей может проигнорировать private для GET без явного no-store. Для чувствительных данных безопаснее сочетание private, no-store.

Можно ли кешировать персональную страницу хотя бы на пару секунд, чтобы снизить нагрузку при всплеске запросов от одного пользователя?

Технически можно, если ключ кеша включает идентификатор пользователя, а не только URL. Но проще и надёжнее для этой задачи — кеш на уровне приложения (Redis) с коротким TTL, а не HTTP-кеш на уровне прокси.

CDN сам не кеширует ответы с Set-Cookie — разве этого не достаточно?

Только для первого ответа, где кука устанавливается (после логина). На последующих запросах к личному кабинету Set-Cookie в ответе уже нет — и этот критерий перестаёт срабатывать. Нужен явный Cache-Control, а не расчёт на побочный эффект другого заголовка.

Как быстро проверить, не утекает ли уже что-то, на существующем проекте?

Открыть личный кабинет в двух браузерах под двумя разными учётными записями, обновить страницу несколько раз подряд в обеих вкладках и свериться, что данные соответствуют своему аккаунту. Если хоть раз промелькнули чужие данные — смотреть Cache-Control и X-Cache-Status/CF-Cache-Status.

ESI или сборка на клиенте — что выбрать для нового проекта?

Если фронтенд и так SPA с отдельными API-запросами — персонализированный блок логично тянуть отдельным запросом с private, no-store. Если страница рендерится на сервере целиком (SSR) — ESI или аналогичная функция CDN решает задачу без переписывания фронтенда.

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

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

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