{ "index": 310, "slug": "editorial-2019-05-field-http-caching", "title": "Один URL, два ответа: как не склеить варианты в HTTP-кэше", "excerpt": "После включения CDN пользователь получает не тот язык или старую версию страницы. Разбираем cache key, Vary и ETag на контролируемом примере и проверяем результат через origin и edge.", "contentHtml": "
После деплоя /catalog должен отдавать английский HTML по запросу с Accept-Language: en. Но часть пользователей видит русский текст. Иногда проблема выглядит мягче: новая подпись уже есть на origin, а публичный адрес ещё отдаёт старую. Цена ошибки — неверный интерфейс, потерянное доверие и часы на спор между frontend, backend и CDN. Если в ответе есть персональные данные, ошибка кэша становится утечкой.
Тезис простой: кэш хранит не «URL вообще», а представление ответа для конкретного запроса. Чтобы использовать сохранённый ответ безопасно, нужно согласовать четыре вещи: какие входы меняют тело, как они попадают в cache key, сколько ответ считается свежим и как сервер подтверждает его версию. Один правильный заголовок не исправляет расхождение между приложением и edge.
\nВ учебном примере тело /catalog зависит только от Accept-Language. Русский и английский ответы имеют один URL, но являются разными представлениями. Origin должен сообщить это через Vary: Accept-Language. Кэш использует указанные поля запроса, чтобы отличить сохранённые варианты. Если поле не указано, edge может считать русский и английский ответы одним объектом.
Cache-Control задаёт правила хранения и повторного использования. max-age ограничивает свежесть ответа для обычного кэша. s-maxage позволяет отдельно задать срок для shared cache, если это нужно архитектуре. private запрещает общий кэш для ответа, который нельзя отдавать другой сессии. no-store запрещает хранение, но не превращает любой публичный ответ в персональный.
После истечения свежести кэш может не получать всё тело заново. Он отправляет условный запрос с валидатором. Для этого сервер возвращает ETag, а клиент или кэш повторяет его в If-None-Match. Если представление не изменилось, сервер отвечает 304 Not Modified. Если изменилось, он возвращает новое тело и новый валидатор. Проверяйте ETag внутри одного варианта: русский ETag нельзя интерпретировать запросом без того же языка.
GET /catalog HTTP/1.1\nHost: www.example.test\nAccept-Language: en\n\nHTTP/1.1 200 OK\nCache-Control: public, max-age=60\nVary: Accept-Language\nETag: \"catalog-en-v18\"\n\n<h1>Catalog</h1>\nЭто учебный обмен. Значение ETag придумано для примера и не является результатом измерения. В реальном сервисе валидатор должен меняться вместе с выбранным представлением, а не с каждым запросом из-за случайного идентификатора.
Начните с безопасного тестового домена. Снимите два ответа, изменив только язык. Сохраните заголовки и тела отдельно. Не добавляйте cookies, User-Agent и экспериментальные параметры одновременно: иначе нельзя понять, какой вход изменил результат.
\ncurl -sS -D /tmp/catalog-ru.headers \\\n -o /tmp/catalog-ru.html \\\n -H \"Accept-Language: ru\" \\\n https://origin.example.test/catalog\n\ncurl -sS -D /tmp/catalog-en.headers \\\n -o /tmp/catalog-en.html \\\n -H \"Accept-Language: en\" \\\n https://origin.example.test/catalog\n\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|last-modified):\" /tmp/catalog-en.headers\nКоманды предназначены для стенда и не выполнены в рамках этой статьи. На origin ожидайте разные тела, если язык действительно влияет на HTML. При этом Vary должен назвать Accept-Language. Если тела одинаковые, не добавляйте Vary по догадке: лишний вариант уменьшит эффективность кэша. Если origin уже отдаёт неверный язык, не начинайте с purge CDN. Исправьте выбор представления или передачу заголовка.
Повторите те же запросы через CDN. Сравните статус, тело и ключевые заголовки. Edge может добавлять служебные поля, например Age, но не должен терять смысл Vary, срока свежести и валидатора. Отсутствие Age само по себе не доказывает, что origin был вызван.
for lang in ru en; do\n curl -sS -D \"/tmp/edge-$lang.headers\" \\\n -o \"/tmp/edge-$lang.html\" \\\n -H \"Accept-Language: $lang\" \\\n https://www.example.test/catalog\ndone\n\nsha256sum /tmp/edge-ru.html /tmp/edge-en.html\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-ru.headers\ngrep -Ei \"^(cache-control|vary|etag|age):\" /tmp/edge-en.headers\nЕсли origin различает ответы, а edge отдаёт одинаковое тело, ищите расхождение в настройке cache key или в том, как провайдер обрабатывает Vary. Не каждый CDN одинаково строит ключ по заголовкам. Документация провайдера важна, но её нужно подтвердить двумя реальными запросами на тестовом URL.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| Origin отдаёт один язык | Приложение не использует вход или прокси его теряет | Два запроса к origin, тела и входящие заголовки | Исправить маршрутизацию или генерацию; CDN не менять |
Origin различает, но Vary отсутствует | HTTP-договор не описывает зависимость | Сравнить тела и response headers | Добавить реальный вход в Vary; повторить тест |
| Origin корректен, edge склеивает варианты | Cache key не учитывает язык или правило провайдера не применилось | Одинаковые запросы через публичный URL, digest тел | Исправить правило CDN и проверить два холодных варианта |
| Новая версия приходит только после purge | Слишком длинный TTL или неработает revalidation | Тот же язык, ETag и If-None-Match после выбранного срока | Настроить TTL и валидатор; purge оставить аварийным инструментом |
| Личная страница попадает в общий кэш | Ответ помечен как публичный или ключ неполон | Проверить cookies, Authorization и Cache-Control | Разделить публичный shell и данные либо использовать private/no-store |
Выберите один язык и короткий срок на тестовом стенде. Сначала получите ETag. Затем отправьте условный запрос с тем же Accept-Language и этим ETag.
curl -sS -D - -o /dev/null \\\n -H \"Accept-Language: ru\" \\\n -H 'If-None-Match: \"catalog-ru-v18\"' \\\n https://www.example.test/catalog\nЕсли представление не менялось и валидатор совпадает, ожидайте 304. После контролируемого изменения текста ожидайте 200 с новым телом и новым ETag. Если сервер всегда возвращает 200, проверьте, не меняется ли ETag из-за времени или случайного поля. Если сервер возвращает 304 после изменения, валидатор слишком грубый. Это учебные критерии; конкретные статусы нужно подтвердить на вашем стенде.
Vary и не расширяйте ключ случайными полями.Cache-Control, Vary, ETag и доступные служебные поля.If-None-Match после выбранного срока свежести.Один заголовок не описывает всю конфигурацию CDN. Провайдер может иметь собственные правила нормализации, дополнительные части ключа и отдельные механизмы purge. Их нужно проверять по документации и на вашем тестовом URL.
\nЕсли тело зависит от cookie, Authorization, географии, устройства или эксперимента, добавьте каждый реальный вариант в модель. Не превращайте Vary: Cookie в универсальный ремонт: он может раздробить кэш и не решить задачу персональных данных. Для личного HTML безопаснее отказаться от shared cache.
Сценарий не доказывает production-результаты и не заменяет нагрузочные тесты. Учебные домены, ETag и digest выше нужны только для воспроизводимой проверки механизма. Переход к production требует согласованного TTL, политики очистки, наблюдаемости и владельца каждого правила.
\nРабота готова, когда два запроса к origin дают ожидаемые представления, response headers объявляют реальные входы через Vary, а два запроса через CDN возвращают соответствующие тела, а не первый сохранённый вариант. Для одного языка условный запрос возвращает 304 без изменения представления и 200 с новым ETag после контролируемого изменения. Персональный ответ не доступен через общий кэш. Все четыре результата записаны в повторяемую проверку.
Vary.ETag, условных запросов и Vary.