Разработчик видит 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-Language | Vary: 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.
Где искать расхождение между слоями
У одного ответа может быть несколько наблюдаемых точек: приложение, 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 «на всякий случай»: лишний вариант дробит кэш и усложняет проверку.
Последовательность проверки механизма
- Для одного URL выпишите все входы, от которых действительно меняется тело ответа.
- Сравните два безопасных варианта запроса и сохраните заголовки вместе с телом или его хешем.
- Проверьте, что
Varyсовпадает с реальными различиями и не содержит случайных полей. - Определите допустимую давность отдельно для browser cache и shared cache; только затем назначайте
max-ageиs-maxage. - Добавьте валидатор, если постоянный URL должен быстро подтверждать свежесть после истечения срока.
- Повторите сценарий через каждый слой, который реально выдаёт ответ пользователю.
Границы модели
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 как перечисление полей запроса, которые повлияли на представление ресурса