{ "index": 312, "slug": "editorial-2019-05-practice-http-caching", "title": "HTTP-кэширование без устаревших ответов: контракт для HTML, assets и API", "excerpt": "После релиза браузер показывает старый HTML, JavaScript или данные API. Разбираем, как связать cache key, свежесть, валидаторы и проверку ответа, чтобы кэш ускорял сайт, а не скрывал ошибку.", "contentHtml": "
После релиза пользователь открывает карточку товара и видит вчерашнюю цену. Страница загрузилась быстро. В DevTools почти нет ошибок. Но HTML ссылается на старый JavaScript, а CDN отдаёт прежний JSON. Команда меняет max-age и не понимает, какой слой сохранил ответ.
Цена ошибки выше, чем лишний запрос. Пользователь принимает решение по неверным данным. Новый код может работать со старой разметкой. Если ответ персональный, общий кэш может показать его другому пользователю. Простое «очистим кэш» убирает симптом, но не объясняет причину.
\nТезис статьи простой: HTTP-кэширование — это контракт ресурса, а не одно число в заголовке. Контракт описывает ключ ответа, допустимую давность, правила повторной проверки и границы хранения. Пока эти четыре пункта не проверены, Cache-Control не доказывает, что приложение отдаёт нужную версию.
Кэш хранит представление ответа. Минимальный ключ включает метод запроса и целевой URI. На представление могут влиять заголовки запроса, например Accept-Language, параметры, cookie или авторизация. Если русский и английский HTML приходят по одному URL, кэш должен различать варианты. Для этого используют Vary или явно настраивают cache key на промежуточном слое.
Стабильный URL и изменяемое содержимое требуют осторожного договора. HTML по адресу /catalog обычно должен быстро перепроверяться, потому что он содержит ссылки на текущие assets. Файл /assets/app.4f91.js может храниться долго: при изменении содержимого сборка создаёт новый URL. Ответ /api/catalog?category=12 можно кэшировать на короткий срок только после проверки его входов и допустимой давности.
| Ресурс | Условие | Пример договора | Риск |
|---|---|---|---|
| Общий HTML | Тело не зависит от пользователя | no-cache и валидатор | Старая ссылка на asset |
| Файл с хешем в имени | Новый байтовый состав получает новый URL | public, max-age=31536000, immutable | Старый код живёт по постоянному адресу |
| Общий API-ответ | Ключ учитывает все варианты | Короткий max-age или s-maxage | Устаревшие или смешанные данные |
| Персональный ответ | Тело зависит от сессии | private или no-store | Утечка через shared cache |
Это не универсальная таблица заголовков. Она задаёт порядок решения. Сначала найдите входы, которые меняют тело. Затем назовите максимальную допустимую давность. Только после этого выбирайте директивы.
\nСвежий ответ кэш может использовать без обращения к origin. Когда срок истёк, ответ становится устаревшим, но это не обязательно означает повторную передачу всего тела. Кэш или браузер отправляет условный запрос с валидатором. При совпадении ETag origin отвечает 304 Not Modified. Тело остаётся в кэше, а представление считается подтверждённым. При изменении origin отдаёт 200 с новым телом и новым валидатором.
no-cache разрешает хранить ответ, но требует проверки перед повторным использованием. no-store запрещает сохранять ответ и применяется, когда само хранение создаёт риск. private запрещает использовать ответ shared cache, но не отменяет хранение в браузере. Эти директивы нельзя заменять друг другом ради «самой свежей страницы».
Длинный TTL безопасен для файла с версией в URL, а не для любого статического файла. Если сервер всегда отдаёт /assets/app.js, годовой max-age закрепит старые байты. Если имя меняется вместе со сборкой, старый URL становится отдельным ресурсом, а новый HTML получает новый ключ.
HTTP/1.1 200 OK\nContent-Type: application/json\nCache-Control: public, max-age=30, s-maxage=120\nETag: \"catalog-202-17\"\nVary: Accept-Language\n\n{\"items\":[{\"id\":42,\"name\":\"Example\"}]}\nПример учебный. Он применим только к публичному каталогу, если язык действительно меняет представление, а 30 секунд — согласованная допустимая давность. Если цена зависит от пользователя, промокода или cookie, public здесь неверен. Пример не сообщает production-результат и не заменяет проверку CDN.
У ответа может быть несколько границ: приложение, reverse proxy, CDN и браузер. Origin может прислать правильный заголовок, а промежуточный слой — заменить TTL, изменить ключ или не передать условный запрос. Поэтому один запрос к localhost не подтверждает поведение публичного адреса.
\n# Снимите обычный ответ и сохраните заголовки.\ncurl -sS -D /tmp/catalog.headers -o /tmp/catalog.body \\\n https://example.test/api/catalog?category=12\n\n# Подставьте ETag из первого ответа.\ncurl -sS -D - -o /dev/null \\\n -H 'If-None-Match: \"catalog-202-17\"' \\\n https://example.test/api/catalog?category=12\nВ реальном проекте сравните Cache-Control, ETag, Last-Modified, Vary, Age, статус и тело. Сначала выполните запрос через публичный путь. Затем повторите его на тестовом origin, если такой путь доступен. Разница между ответами указывает на слой, где контракт изменился.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После релиза старый JavaScript | Постоянный URL и долгий TTL | Сравнить URL asset в новом HTML и Age ответа | Добавить fingerprint и публиковать новый URL |
| Всегда приходит полный ответ | Нет валидатора или не проходит условный запрос | Повторить запрос с If-None-Match и проверить статус | Настроить ETag либо Last-Modified; найти слой, который удаляет заголовок |
| Английский запрос получает русский текст | Ключ не учитывает язык | Сделать два запроса с разным Accept-Language и сравнить Vary | Добавить вариант в ключ или отключить общий кэш для ответа |
| Устаревшая цена после TTL | Кэш настроен дольше допустимого или edge не применил заголовок origin | Сравнить Age, Cache-Control и настройки CDN | Сократить TTL на нужном слое и оставить измеряемый путь обновления |
| Данные одной сессии видны другой | Персональный ответ разрешён shared cache | Проверить тело и заголовки на двух тестовых сессиях | Использовать private или no-store; разделить публичную и личную части |
Vary или явное правило shared cache; для персональных ответов исключите общее хранение.private недостаточно.304 и ключевые заголовки.HTTP-заголовки не управляют всеми промежуточными правилами. CDN может иметь собственный TTL, cache key и исключения для query-параметров. Браузер может использовать историю навигации иначе, чем обычный reload. Nginx применяет add_header с учётом кода ответа и наследования конфигурации; вложенный location может изменить ожидаемый набор заголовков. Это нужно проверять на фактическом ответе.
Если после изменения конфигурации старый ответ всё ещё приходит, не увеличивайте TTL и не объявляйте инвалидацию успешной. Проверьте, тот ли URL запрашивается, тот ли слой отвечает, не меняется ли cache key, есть ли Age и передаётся ли If-None-Match. Если ответ персональный или входы не известны, остановите общий кэш. Без полного ключа безопасного TTL не существует.
Настройка готова, когда для каждого из трёх типов ресурса есть записанный контракт: URL, входы, допустимая давность, директивы и слой хранения. Для неизменённого ответа условный запрос даёт 304 или другой явно согласованный результат. После изменения HTML получает новый asset URL, API не смешивает варианты, а персональный ответ не попадает в shared cache. Эти условия проверяются повторяемыми запросами, а не очисткой браузера и не ощущением, что страница «стала быстрее».
Vary и директивах; он заменяет RFC 7234.add_header, expires и правил наследования.