После релиза пользователь открывает ту же карточку товара и видит вчерашнюю цену. Команда меняет число в Cache-Control, но часть браузеров продолжает получать старый HTML, а новый JavaScript уже ждёт другие данные. Цена ошибки — не только лишний запрос: пользователь принимает решение по неверному состоянию, а разработчик не может сказать, какой слой сохранил ответ.
В этой заметке разберём один рабочий вопрос: как назначить кэширование для HTML, версионированных файлов и короткоживущего API-ответа так, чтобы договор можно было проверить заголовками. Это учебный пример HTTP/1.1. Он не заменяет правила конкретного CDN, балансировщика или фреймворка: их настройки нужно сверять отдельно, потому что они могут изменить путь ответа до браузера.
Сначала фиксируем ресурс, а не число секунд
У выражения «поставим час кэша» нет смысла без ресурса. HTML по постоянному URL обычно должен быстро перепроверяться: именно он ссылается на новую версию скрипта и стиля. Файл app.4f91.js можно хранить долго, если изменение содержимого создаёт новый URL. Ответ /api/catalog?category=12 может быть коротко свежим, но только если его тело не зависит от авторизации, языка или другого неучтённого входа.
RFC 7234 разделяет свежесть и повторную проверку. Пока сохранённый ответ свежий, кэш может использовать его без обращения к origin. Когда срок истёк, это ещё не означает обязательную загрузку всего тела: валидатор может привести к условному запросу и ответу 304 Not Modified. Поэтому первый вопрос к заголовку — не «быстро ли он работает», а «какой старый ответ допустим для этого URL и при каком условии».
| Тип ответа | Стабильный URL | Практический договор | Что проверяем |
|---|---|---|---|
| HTML документа | Да | no-cache плюс валидатор, если документ не персонализирован | После изменения сервер получает условный запрос или отдаёт новый HTML |
| Файл с хешем в имени | Нет: URL меняется с содержимым | public, max-age=31536000 | Новый релиз ссылается на новый URL, старый URL может жить отдельно |
| Общий краткий API-ответ | Да | Небольшой max-age; общий кэш только при понятном ключе | Два одинаковых запроса дают ожидаемую свежесть и не смешивают варианты |
| Персональные данные | Да | private или no-store по риску хранения | Ответ одного пользователя не может стать общим ответом для другого |
Таблица не является готовым набором заголовков для любого сайта. У неё другая цель: перед настройкой выписать свойства ответа. Если HTML содержит имя пользователя или корзину, пример для публичного HTML неприменим. Если asset не имеет fingerprint в имени, годовой max-age создаёт ровно ту проблему, которую команда пытается убрать.
Три контракта вместо одного общего правила
Первый контракт — документ. Для общего HTML полезно разрешить хранение, но требовать повторную проверку перед использованием. Директива no-cache не означает «ничего не хранить»: она требует проверять сохранённый ответ перед повторным использованием. Это даёт браузеру шанс получить 304 вместо повторной передачи всего документа. Если документ персональный, к этому контракту добавляется private; для данных, которые нельзя хранить вообще, нужен более строгий no-store.
Второй контракт — asset с версией в URL. Здесь cache-busting делается не очисткой кэша, а сменой адреса: содержимое меняется — сборка создаёт новый хеш — HTML начинает ссылаться на новый путь. Длинный срок живёт безопасно только потому, что новый байтовый состав не маскируется старым ключом. Третий контракт — API: значение TTL должно следовать из допустимой давности данных, а не из желания уменьшить нагрузку любой ценой.
# nginx: общий HTML, который можно хранить, но надо валидировать
location = /catalog {
add_header Cache-Control "no-cache, public";
}
# nginx: имя файла меняется вместе с содержимым сборки
location /assets/ {
add_header Cache-Control "public, max-age=31536000";
}
Этот фрагмент показывает форму контракта, а не полный production-конфиг. В реальном nginx нужно проверить наследование add_header, обработку ошибок, существующие заголовки приложения и путь, в котором CDN читает ответ origin. Документация nginx описывает директиву, но не знает, какие именно URL вашего приложения персонализированы или как сборщик формирует имена файлов.
Проверяем заголовки до изменения конфигурации
Проверка начинается с одного URL и одного ожидаемого контракта. Не очищаем кэш браузера первым действием: это стирает след, который нужно объяснить. Сохраняем статус, Cache-Control, ETag, Last-Modified, Age при наличии и значения Vary. Затем повторяем запрос с условным заголовком. Если сервер всегда отдаёт полное тело, причина может быть в отсутствии валидатора, в неправильном URL или в том, что промежуточный слой не передаёт условный запрос.
# Сначала сохранить заголовки обычного ответа.
curl -sS -D - -o /dev/null https://example.test/catalog
# Затем подставить значение ETag из первого ответа.
curl -sS -D - -o /dev/null -H 'If-None-Match: "catalog-v42"' https://example.test/catalog
Команда выше не доказывает, что ваш CDN использует те же правила, что и браузер. Она делает границу наблюдаемой: на origin или на тестовом домене можно увидеть, поддерживает ли представление условный запрос. Для CDN нужен второй контролируемый путь с той же конфигурацией кэширования. Сравнивать нужно не только код 200 или 304, но и ключевые заголовки на каждом слое.
ETag нужен для повторной проверки, а не как украшение
Когда срок свежести закончился, браузер может отправить If-None-Match со значением предыдущего ETag. Если представление не изменилось, origin отвечает 304, и сохранённое тело остаётся полезным. Если изменилось — отвечает 200 с новым телом и новым валидатором. Такой путь полезен для HTML с постоянным URL: пользователь получает актуальную ссылку на assets, но сеть не передаёт документ повторно, когда он не менялся.
Не стоит подменять эту механику словом «инвалидация». RFC не обещает, что все кэши исчезнут одновременно после деплоя. Версионированный URL делает старое содержимое отдельным ресурсом; короткий TTL ограничивает допустимую давность; валидатор проверяет конкретное сохранённое представление. Это три разных инструмента. Смешать их в один «кэш выключен» — значит потерять возможность объяснить поведение.
Маршрут изменения без слепой очистки
- Выберите один URL и запишите, какую давность данных пользователь может увидеть без ошибки.
- Определите, меняется ли URL вместе с байтовым содержимым. Если нет, не выдавайте долгий
max-ageза безопасный вариант. - Снимите заголовки origin и публичного адреса; отдельно сохраните
Cache-Control, валидаторы,VaryиAge. - Сделайте условный запрос с прежним ETag и зафиксируйте, когда ожидается
304, а когда новый200. - После одного изменения повторите те же запросы. Проверяйте HTML, asset и API раздельно: общий зелёный экран не доказывает их контракт.
Границы решения
Этот рецепт не обещает немедленную видимость релиза во всех промежуточных кэшах. Поставщик CDN может иметь собственный TTL, собственный cache key или правило, которое обходит заголовок origin. Браузер может использовать навигационную историю иначе, чем обычный reload. Поэтому результатом работы должна быть не фраза «кэш настроен», а короткая карточка: URL, вариант запроса, заголовки, допустимая давность и команда повторной проверки.
Для автора 2019 года это естественный следующий шаг после Webpack: сборщик уже умеет менять имя asset, теперь нужно связать это с HTTP-ответом и увидеть границу между браузером, origin и общим кэшем. Если в вашем проекте проблема не в свежести, а в разных языках или пользователях по одному URL, сначала разберите ключ варианта — одной настройкой TTL её не исправить.
Что унести в проект
- Длинный TTL безопасен только для ресурса, чей URL меняется вместе с содержимым.
no-cacheразрешает хранение, но требует повторной проверки; это не синонимno-store.- Заголовок без снимка ответа не является доказательством: храните запрос, статус и ключевые поля ответа.
- HTML, asset и API требуют разных контрактов, даже если проходят через один домен.
Проверяемые источники
- 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 7232, раздел 2.3: ETag — синтаксис entity-tag и связь валидатора с конкретным представлением ресурса
- MDN: HTTP caching — практическое объяснение свежести, повторной проверки, ETag и разницы между no-cache и no-store
- nginx: ngx_http_headers_module — границы директив add_header и expires в конфигурации nginx