DarkRiDDeR12 мин

HTTP-кэш: почему один Cache-Control не управляет всем маршрутом

HTTPАрхитектураРазбор

Разработчик видит Cache-Control: max-age=60 и ожидает, что через минуту пользователь обязательно увидит новое значение. Через две минуты один браузер уже получил обновление, другой — нет, а CDN продолжает отвечать старым вариантом. Ошибка здесь не обязательно в числе 60: ответ мог быть сохранён под неполным ключом, промежуточный кэш мог получить иной контракт, а проверка свежести могла произойти не там, где её ищут. Цена ошибки — показать старую цену, конфигурацию или JavaScript после релиза.

Разберём механизм на одном вопросе: что именно кэш считает «тем же ответом» и почему срок свежести не заменяет ключ и валидатор. Мы не будем назначать поведение конкретному CDN без его конфигурации. Вместо этого соберём модель HTTP: запрос выбирает представление, кэш оценивает его свежесть, а после истечения срока при необходимости валидирует сохранённую версию у origin.

Кэш хранит представление, а не просто URL

URL — начало ключа, но не всегда конец. Если origin отдаёт русский и английский HTML по одному адресу в зависимости от Accept-Language, для кэша это два представления одного ресурса. Заголовок Vary: Accept-Language говорит, что это поле запроса повлияло на содержимое. При выборе сохранённого ответа кэш должен сопоставить значения перечисленных полей с новым запросом.

Из этого следует практическая проверка: прежде чем увеличивать TTL, перечислите входы, которые меняют тело. Язык, формат, мобильная версия, авторизация, эксперимент и cookie — это не общая «динамика», а конкретные ветви генерации. Если один из входов влияет на HTML, а cache key его не различает, кэш может честно отдать свежий, но чужой вариант. TTL не исправляет такую ошибку; он только ограничивает, как долго она видна.

Вход, меняющий телоКакой договор нуженОпасность неверной настройкиНаблюдаемая проверка
URL и queryЕдиный канонический порядок параметров и документированный ключОдин смысл разрастается в много ключей или разные смыслы становятся одним ключомСравнить заголовки и тело для двух URL
Accept-LanguageVary: Accept-Language при реально разном представленииРусский текст попадает в английский запросОтправить два запроса с разным языком и сверить Vary
Cookie/авторизацияprivate либо явное отделение общего ответа от личногоДанные одного пользователя попадают в общий кэшПроверить, не меняется ли тело при двух безопасных тестовых сессиях
Версия asset в имениНовый URL при новом содержимомДлинный TTL держит старый JavaScript по постоянному адресуСравнить HTML релиза и список URL assets

RFC 7234 отдельно описывает Vary, но не превращает его в универсальный флаг для каждого управляемого кэша. У CDN могут быть правила, которые определяют cache key заранее или исключают часть ответов. Поэтому Vary — договор HTTP между origin и совместимым кэшем; проверка реального edge-слоя всё равно входит в выпуск. В статье мы не маскируем этот пробел словом «автоматически».

Свежий, устаревший и проверенный — разные состояния

После сохранения ответа кэш вычисляет его текущий возраст и сравнивает с lifetime. Свежий ответ может быть использован сразу. Устаревший ответ не становится мусором: кэш может отправить условный запрос к origin, передав ETag или дату, и получить подтверждение 304 Not Modified. В этом случае тело не скачивается повторно, но новый ответ подтверждает, что сохранённое представление ещё соответствует origin.

Директивы max-age и s-maxage задают разные границы. Первая влияет на кэши в целом; s-maxage имеет специальное значение для shared cache и может переопределять max-age для него. Это полезно, когда браузер не должен долго хранить ответ, а общий кэш может уменьшить нагрузку origin чуть дольше. Но смысл появляется только после проверки, действительно ли ответ общий и не содержит персональных ветвей.

HTTP/1.1 200 OK
Content-Type: application/json
Cache-Control: public, max-age=30, s-maxage=120
ETag: "catalog-202-17"
Vary: Accept-Language

{"items":[{"id":42,"name":"..."}]}

Этот ответ допустим лишь при конкретных условиях: каталог одинаков для всех пользователей с одним языковым вариантом, а 30 секунд допустимой локальной давности названы бизнесом или продуктом. Если цена зависит от пользователя, промокода или сессии, public в примере становится неправильным. Код не заменяет анализ входов; он фиксирует решение после анализа.

no-cache, no-store и private отвечают на разные риски

Три часто смешиваемые директивы нужны для разных ситуаций. no-cache позволяет сохранить ответ, но требует успешной проверки до повторного использования. no-store запрещает сохранять ответ и его части; он нужен, когда сам факт хранения опасен, а не когда хочется «быстро обновлять страницу». private ограничивает повторное использование shared cache, но не делает страницу автоматически безопасной для всех скриптов, логов и истории браузера.

Выбор должен начинаться с риска. Для обычной публичной статьи полезна повторная проверка: она поддерживает свежесть без полной передачи тела. Для персонального баланса общий кэш недопустим; команда оценивает, достаточно ли private, или ответ вообще нельзя сохранять. Для версионированного bundle риском является не персонализация, а постоянный URL, поэтому решением будет новый ключ ресурса, а не no-store.

Схема выбора HTTP-кэша: URL и Vary формируют ключ варианта, затем кэш проверяет свежесть; при истечении срока условный запрос с ETag приводит к 304 или к новому 200.
Диагностика начинается с ключа. Только после этого TTL и валидатор имеют понятный эффект.

Где искать расхождение между слоями

У одного ответа может быть несколько наблюдаемых точек: приложение, origin-прокси, CDN и браузер. Нельзя склеивать их в один «сервер». В минимальном журнале укажите время, URL, заголовок запроса, статус, Cache-Control, ETag, Vary и Age, если слой его отдал. Затем выполните тот же сценарий напрямую к origin на тестовом адресе и через публичный путь. Разница показывает, где контракт перестал совпадать с ожиданием.

# Запросить один и тот же ресурс в двух вариантах языка.
curl -sS -D /tmp/cache-ru.headers -o /tmp/cache-ru.body -H "Accept-Language: ru" https://example.test/catalog
curl -sS -D /tmp/cache-en.headers -o /tmp/cache-en.body -H "Accept-Language: en" https://example.test/catalog

# Сначала сравнить Vary, Cache-Control и ETag, затем уже тела.

Команды не являются измерением для этой статьи: их нужно запускать на своём тестовом домене, без личных токенов в истории shell. Их польза в другом — они делают явным вход, который раньше был скрыт в браузере. Если два языка дают разное тело и отсутствует ожидаемый Vary, остановитесь здесь. Если тело одинаково, не добавляйте Vary «на всякий случай»: лишний вариант дробит кэш и усложняет проверку.

Последовательность проверки механизма

  1. Для одного URL выпишите все входы, от которых действительно меняется тело ответа.
  2. Сравните два безопасных варианта запроса и сохраните заголовки вместе с телом или его хешем.
  3. Проверьте, что Vary совпадает с реальными различиями и не содержит случайных полей.
  4. Определите допустимую давность отдельно для browser cache и shared cache; только затем назначайте max-age и s-maxage.
  5. Добавьте валидатор, если постоянный URL должен быстро подтверждать свежесть после истечения срока.
  6. Повторите сценарий через каждый слой, который реально выдаёт ответ пользователю.

Границы модели

HTTP-модель не описывает правила очистки конкретного поставщика CDN, режим offline браузера или историю навигации. Она также не говорит, что ETag должен быть криптографическим хешем: важно, чтобы валидатор корректно отличал представления в выбранном договоре. Если у приложения есть персонализация, эксперименты или геозависимые цены, понадобится отдельная карта вариантов и, возможно, отказ от общего кэша для части URL.

Главная привычка автора на этом этапе — перестать считать заголовок красивой строкой конфигурации. Cache-Control отвечает на вопрос о повторном использовании, Vary — о соответствии варианта запросу, ETag — о повторной проверке. Когда каждый ответ получает короткую карту этих трёх ролей, дебаг перестаёт начинаться с глобальной очистки CDN.

Короткий вывод

  • TTL ограничивает давность сохранённого варианта, но не создаёт правильный ключ.
  • Если тело зависит от заголовка запроса, это зависимость нужно проверить как часть cache key, а не описать общим словом «динамика».
  • no-cache, no-store и private выбираются по разным рискам хранения и повторного использования.
  • Проверка должна сравнивать origin и публичный путь, иначе слой с расхождением останется невидимым.

Проверяемые источники

  • RFC 7234, раздел 4.2: freshness — возраст ответа, срок свежести и правило, по которому кэш решает, можно ли использовать сохранённый ответ без повторного запроса
  • RFC 7234, раздел 5.2: Cache-Control — семантика max-age, s-maxage, private, no-cache и no-store для HTTP/1.1-кэшей
  • RFC 7234, раздел 4.3: validation — условные запросы, ETag, If-None-Match и ответ 304 как повторная проверка сохранённого представления
  • RFC 7234, раздел 4.1: Vary — как кэш выбирает подходящую сохранённую вариацию ответа по полям запроса
  • MDN: HTTP caching — практическое объяснение свежести, повторной проверки, ETag и разницы между no-cache и no-store
  • MDN: Vary header — Vary как перечисление полей запроса, которые повлияли на представление ресурса