{ "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

Цена ошибки выше, чем лишний запрос. Пользователь принимает решение по неверным данным. Новый код может работать со старой разметкой. Если ответ персональный, общий кэш может показать его другому пользователю. Простое «очистим кэш» убирает симптом, но не объясняет причину.

\n

Тезис статьи простой: HTTP-кэширование — это контракт ресурса, а не одно число в заголовке. Контракт описывает ключ ответа, допустимую давность, правила повторной проверки и границы хранения. Пока эти четыре пункта не проверены, Cache-Control не доказывает, что приложение отдаёт нужную версию.

\n

Сначала определите, что именно кэшируется

\n

Кэш хранит представление ответа. Минимальный ключ включает метод запроса и целевой URI. На представление могут влиять заголовки запроса, например Accept-Language, параметры, cookie или авторизация. Если русский и английский HTML приходят по одному URL, кэш должен различать варианты. Для этого используют Vary или явно настраивают cache key на промежуточном слое.

\n

Стабильный URL и изменяемое содержимое требуют осторожного договора. HTML по адресу /catalog обычно должен быстро перепроверяться, потому что он содержит ссылки на текущие assets. Файл /assets/app.4f91.js может храниться долго: при изменении содержимого сборка создаёт новый URL. Ответ /api/catalog?category=12 можно кэшировать на короткий срок только после проверки его входов и допустимой давности.

\n
РесурсУсловиеПример договораРиск
Общий HTMLТело не зависит от пользователяno-cache и валидаторСтарая ссылка на asset
Файл с хешем в имениНовый байтовый состав получает новый URLpublic, max-age=31536000, immutableСтарый код живёт по постоянному адресу
Общий API-ответКлюч учитывает все вариантыКороткий max-age или s-maxageУстаревшие или смешанные данные
Персональный ответТело зависит от сессииprivate или no-storeУтечка через shared cache
\n

Это не универсальная таблица заголовков. Она задаёт порядок решения. Сначала найдите входы, которые меняют тело. Затем назовите максимальную допустимую давность. Только после этого выбирайте директивы.

\n

Свежесть не равна обновлению

\n

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

\n

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

\n

Длинный TTL безопасен для файла с версией в URL, а не для любого статического файла. Если сервер всегда отдаёт /assets/app.js, годовой max-age закрепит старые байты. Если имя меняется вместе со сборкой, старый URL становится отдельным ресурсом, а новый HTML получает новый ключ.

\n
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.

\n

Проверяйте путь ответа, а не только origin

\n

У ответа может быть несколько границ: приложение, 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, если такой путь доступен. Разница между ответами указывает на слой, где контракт изменился.

\n
\"Путь
Кэширование требует разных договоров для документа, файла сборки и API. Важен весь путь от запроса до origin.
\n

Симптом → причина → проверка → действие

\n
СимптомПричинаПроверкаДействие
После релиза старый 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; разделить публичную и личную части
\n

Порядок действий

\n
  1. Выберите один проблемный URL. Зафиксируйте метод, query-параметры и заголовки, которые могут менять ответ.
  2. Опишите ошибку в терминах пользователя: какая старая версия допустима и сколько времени.
  3. Разделите HTML, assets и API. Не переносите договор одного типа ресурса на другой.
  4. Проверьте cache key. Для вариантов используйте Vary или явное правило shared cache; для персональных ответов исключите общее хранение.
  5. Выберите механизм. Для стабильного URL используйте короткую свежесть и валидатор. Для неизменяемого по URL файла меняйте имя при изменении байтов. Для чувствительных данных запретите хранение, если private недостаточно.
  6. Снимите обычный ответ и выполните условный запрос. Сохраните статус, тело или факт 304 и ключевые заголовки.
  7. Повторите проверку через публичный CDN и после следующего изменения. Очистку кэша используйте только как отдельный способ восстановления, а не как доказательство корректной настройки.
\n

Ограничения и отрицательный путь

\n

HTTP-заголовки не управляют всеми промежуточными правилами. CDN может иметь собственный TTL, cache key и исключения для query-параметров. Браузер может использовать историю навигации иначе, чем обычный reload. Nginx применяет add_header с учётом кода ответа и наследования конфигурации; вложенный location может изменить ожидаемый набор заголовков. Это нужно проверять на фактическом ответе.

\n

Если после изменения конфигурации старый ответ всё ещё приходит, не увеличивайте TTL и не объявляйте инвалидацию успешной. Проверьте, тот ли URL запрашивается, тот ли слой отвечает, не меняется ли cache key, есть ли Age и передаётся ли If-None-Match. Если ответ персональный или входы не известны, остановите общий кэш. Без полного ключа безопасного TTL не существует.

\n

Критерий готовности

\n

Настройка готова, когда для каждого из трёх типов ресурса есть записанный контракт: URL, входы, допустимая давность, директивы и слой хранения. Для неизменённого ответа условный запрос даёт 304 или другой явно согласованный результат. После изменения HTML получает новый asset URL, API не смешивает варианты, а персональный ответ не попадает в shared cache. Эти условия проверяются повторяемыми запросами, а не очисткой браузера и не ощущением, что страница «стала быстрее».

\n

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

\n" }