diff --git a/editorial/agent-rewrites/311.json b/editorial/agent-rewrites/311.json index 0e161f0..7f11d9e 100644 --- a/editorial/agent-rewrites/311.json +++ b/editorial/agent-rewrites/311.json @@ -1,7 +1,7 @@ { "index": 311, "slug": "editorial-2019-05-mechanism-http-caching", - "title": "HTTP-кэш: почему правильный TTL всё равно отдаёт неправильный ответ", - "excerpt": "Кэш проверяет не только срок хранения. Разбираем ключ варианта, Vary, ETag и границы browser cache, proxy и CDN на наблюдаемом сценарии.", - "contentHtml": "
После релиза пользователь открывает знакомый адрес и видит старый JavaScript или вчерашний язык страницы. В ответе есть Cache-Control: max-age=60, поэтому команда ждёт обновления через минуту. Но один клиент получает новый файл, другой — старый, а CDN продолжает отвечать из своего кэша. Ошибка стоит дороже, чем лишний запрос: пользователь видит неверное состояние, релиз выглядит сломанным, а команда начинает очищать кэш вслепую.
Причина часто не в числе 60. Кэш хранит представление ответа для конкретного запроса. На это представление влияют URL, query-параметры, заголовки, cookies и правила посредника. Cache-Control описывает свежесть и допустимое хранение. Он не исправляет неполный ключ, не различает личные данные и не заставляет каждый слой сети забыть старую запись.
У каждого кэшируемого ответа должны быть три понятных свойства: какой запрос выбирает ответ, как долго его можно использовать без проверки и как подтвердить, что сохранённое представление ещё подходит. В HTTP этим ролям обычно соответствуют ключ варианта, директивы Cache-Control и валидатор вроде ETag. Заголовок Vary сообщает, какие поля запроса участвовали в выборе представления.
Представьте публичную страницу /catalog. Origin формирует русский или английский HTML по Accept-Language. Для русского запроса кэш сохранил ответ. Следующий английский запрос получит тот же ответ, если посредник использует только URL и не учитывает язык. Этот ответ может быть свежим по max-age, но он всё равно неверен для запроса. Увеличение или уменьшение TTL не меняет ошибочный ключ.
Другая ситуация возникает у файла /assets/app.js. Если содержимое меняется, а имя остаётся тем же, длинный TTL закрепляет старый файл. Безопасный долгий срок требует нового URL при каждом изменении, например app.4f91.js. Здесь кэш не обязан угадывать выпуск: новый адрес отделяет новое представление от старого.
Клиент отправляет запрос. Кэш ищет сохранённый ответ, который подходит этому запросу. Сначала он проверяет соответствие варианта. Затем оценивает свежесть. Свежий ответ можно вернуть без origin. Несвежий ответ не обязательно нужно загружать заново: кэш может отправить условный запрос с If-None-Match. Origin ответит 304 Not Modified, если представление не изменилось, или 200 с новым телом, если изменилось.
ETag не очищает запись и не делает ответ свежим. Он позволяет сравнить сохранённое представление с текущим. no-cache тоже не означает «не хранить»: директива требует повторной проверки перед использованием. no-store запрещает хранение ответа в соответствии с правилами кэша. private ограничивает использование частным кэшем и подходит для ответа, который нельзя отдавать общему кэшу.
У ответа может быть несколько точек наблюдения: приложение, reverse proxy, CDN и браузер. Заголовок, снятый на origin, не доказывает, что тот же заголовок дошёл до браузера. Посредник может изменить TTL, ключ или правило обхода. Поэтому проверка должна сравнивать один сценарий на каждой доступной границе.
\nНиже приведены команды для безопасного тестового домена. Это учебный сценарий, а не результат измерения production. Он меняет только один вход — язык — и сохраняет тело и заголовки отдельно. В реальной команде замените адрес на тестовый URL без личных данных.
\n# Запросы к origin: меняется только Accept-Language.\ncurl -sS -D /tmp/catalog-ru.headers -o /tmp/catalog-ru.html \\\n -H 'Accept-Language: ru' https://origin.example.test/catalog\n\ncurl -sS -D /tmp/catalog-en.headers -o /tmp/catalog-en.html \\\n -H 'Accept-Language: en' https://origin.example.test/catalog\n\n# Сначала сравниваем договор, затем тело.\ngrep -Ei '^(cache-control|vary|etag|age):' /tmp/catalog-ru.headers\ngrep -Ei '^(cache-control|vary|etag|age):' /tmp/catalog-en.headers\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\n\n# Учебная проверка сохранённого варианта.\ncurl -sS -D - -o /dev/null \\\n -H 'Accept-Language: ru' \\\n -H 'If-None-Match: "catalog-ru-v18"' \\\n https://origin.example.test/catalog\nЕсли тела различаются по языку, в ответе ожидается Vary: Accept-Language либо эквивалентное явно настроенное правило cache key. Если origin возвращает один язык для обоих запросов, CDN пока не проверяем: источник уже нарушает ожидаемый контракт. Если origin корректен, а публичный адрес склеивает ответы, ищем расхождение в CDN или reverse proxy.
Значение ETag в команде условное. Его нужно взять из предыдущего ответа для того же языка. Нельзя использовать русский валидатор для английского варианта. При неизменном русском представлении ожидаем 304 после повторной проверки. После изменения русского HTML ожидаем новый 200 и новый валидатор. Конкретный статус и заголовки нужно подтвердить на вашем стенде.
| Симптом | Причина | Проверка | Действие |
|---|---|---|---|
| После релиза старый JavaScript | Постоянный URL и длинный TTL | Сравнить URL из нового HTML и digest файла | Добавить fingerprint или сократить контракт свежести |
| Английский запрос получает русский HTML | Язык не входит в вариант кэша | Сделать два запроса с разным Accept-Language и сравнить Vary | Исправить ключ варианта или отказаться от общего кэша |
| Всегда приходит полный ответ после истечения TTL | Нет валидатора или условный запрос не доходит до origin | Повторить запрос с прежним ETag и проверить путь через proxy | Настроить устойчивый валидатор и проверить каждый слой |
| Публичный ответ отличается от origin | CDN переписывает заголовки или использует другой ключ | Снять одинаковые заголовки через origin и публичный адрес | Сверить правило CDN с HTTP-контрактом origin |
| Данные одного пользователя видит другой | Персональный ответ попал в общий кэш | Проверить Cookie, авторизацию и Cache-Control на тестовых сессиях | Выбрать private, no-store или разделить публичную и личную части |
Cache-Control, ETag, Vary и Age, если поле есть.Vary по предположению.ETag и зафиксируйте ожидаемый 304 либо новый 200.Эта модель объясняет HTTP-контракт, но не знает настройки конкретного CDN. Поставщик может иметь собственный cache key, отдельный TTL и правила обхода. Браузер может показать результат навигационной истории или service worker, а не обычного HTTP-кэша. Заголовок Age может отсутствовать и не доказывает отсутствие хранения. Поэтому один ответ curl не описывает весь маршрут пользователя.
Vary не заменяет явную проверку персонализации. Если тело зависит от cookie, эксперимента или пользователя, перечисление большого набора полей дробит кэш и может оставить риск утечки. Для личного HTML чаще безопаснее не использовать общий кэш. Для публичной локализации нужен ограниченный список вариантов и тест на каждый поддерживаемый язык.
Учебный пример не обещает конкретное ускорение и не доказывает работу вашей инфраструктуры. Проверяемый результат должен появиться только после запуска команд на контролируемом домене и сравнения ответов. Не публикуйте в диагностике токены, cookie, персональные query-параметры и внутренние адреса.
\nРаботу можно считать завершённой, когда для каждого выбранного URL есть короткая карточка: входы, допустимая давность, ожидаемые директивы, валидатор и граница, на которой это проверяется. Два запроса с разными входами не смешивают тела. После истечения срока неизменённое представление даёт ожидаемую условную проверку, а изменённое — новый ответ. Новый asset получает новый URL. Персональный ответ не попадает в общий кэш. Если хотя бы один пункт нельзя показать заголовками и телом ответа, контракт ещё не доказан.
\nETag, Vary и условных ответов.no-cache и no-store.После релиза пользователь открывает знакомый адрес и видит старый JavaScript или HTML не на том языке. В ответе есть Cache-Control: max-age=60, поэтому команда ждёт обновления через минуту. Но один клиент получает новый файл, другой — старый, а CDN продолжает отвечать из своей записи. Цена ошибки — неверное состояние страницы, лишняя нагрузка на origin и попытки очищать кэш вслепую.
Причина часто не в числе 60. Кэш сначала решает, подходит ли сохранённый ответ этому запросу, и только потом проверяет его свежесть. Если ключ варианта неполон, свежий ответ всё равно будет неправильным. Если ключ верен, но ответ устарел, помогает валидатор вроде ETag. Эти две задачи нельзя заменять одним большим TTL.
В HTTP cache key как минимум включает метод и target URI запроса. Для обычных GET-кэшей на практике главным компонентом остаётся URI. Но origin может выбирать представление по заголовкам запроса, например по Accept-Language или Accept-Encoding. Тогда ответ должен сообщить об этом через Vary: кэш сравнивает названные поля текущего запроса с полями запроса, который породил сохранённый ответ.
Управляемый CDN может добавить к ключу собственные признаки: cookies, географию, эксперимент или правило из панели. Это не отменяет HTTP-контракт. Заголовок, снятый на origin, не доказывает поведение публичного адреса: proxy или CDN может выбрать другую запись, изменить свежесть или отправить запрос дальше. Название настройки вроде «cache key» тоже не доказывает, какие поля реально участвуют в выборе.
\nmax-age задаёт срок, после которого ответ считают несвежим. Это не срок жизни записи и не команда удалить её. Несвежая запись может быть повторно проверена у origin. При совпавшем валидаторе origin для GET или HEAD обычно возвращает 304 Not Modified без нового тела; при изменении представления — 200 с новым телом и новым валидатором.
ETag сравнивает сохранённое представление с текущим. Он не очищает запись и сам по себе не делает её свежей. no-cache в ответе тоже не означает «не хранить»: перед повторным использованием кэш должен обратиться к origin для успешной проверки. no-store запрещает кэшу намеренно хранить ответ и использовать его для другого запроса, но не является полноценной защитой от скомпрометированного посредника.
private запрещает хранение ответа в shared cache, но разрешает private cache пользователя при остальных условиях. Это подходит для персонализированного ответа, если его всё равно допустимо хранить в браузере. Для личного HTML часто безопаснее выбрать no-store. Для shared cache отдельный s-maxage задаёт максимальный возраст и переопределяет max-age для общего кэша; после истечения срок всё равно требует корректного правила повторной проверки.
Из этого следует порядок решения: сначала совпадение URI, метода и варианта; затем допустимость повторного использования; затем свежесть; затем, если нужно, условный запрос к origin. Нельзя выводить причину по одному заголовку. Age полезен как признак того, что ответ был сгенерирован или проверен не для этого запроса, но отсутствие Age не доказывает обращение к origin.
Ниже приведён учебный сценарий для контролируемого домена. Он меняет только Accept-Language, сохраняет заголовки и тело отдельно и не выдаёт результат за измерение production. Подставьте URL тестового origin и не используйте в команде настоящие cookies или токены.
# Origin должен вернуть разные представления для двух языков.\ncurl -sS -D /tmp/catalog-ru.headers -o /tmp/catalog-ru.html -H 'Accept-Language: ru' https://origin.example.test/catalog\ncurl -sS -D /tmp/catalog-en.headers -o /tmp/catalog-en.html -H 'Accept-Language: en' https://origin.example.test/catalog\n\n# Сначала сравниваем контракт, затем тела.\ngrep -Ei '^(cache-control|vary|etag|age|date):' /tmp/catalog-ru.headers\ngrep -Ei '^(cache-control|vary|etag|age|date):' /tmp/catalog-en.headers\nsha256sum /tmp/catalog-ru.html /tmp/catalog-en.html\n\n# Валидатор берём из ответа того же варианта.\ncurl -sS -D - -o /dev/null -H 'Accept-Language: ru' -H 'If-None-Match: "catalog-ru-v18"' https://origin.example.test/catalog\nЕсли origin действительно выбирает HTML по языку, для общего HTTP-кэша ожидается Vary: Accept-Language. Отдельное vendor-specific правило cache key может быть нужно CDN, но оно не заменяет заголовок для других посредников. Если origin отдал одинаковые тела, сначала исправьте выбор представления у источника: проверка CDN только замаскирует проблему.
Значение ETag в команде условное. В рабочем запуске его нужно скопировать из предыдущего ответа для русского варианта, сохранив кавычки. Нельзя использовать русский валидатор для английского. Если представление не менялось и запрос дошёл до origin, ожидаем 304; при изменении тела ожидаем новый 200. Статус нужно зафиксировать на вашем стенде, а не обещать заранее.
| Симптом | Гипотеза | Проверка | Действие |
|---|---|---|---|
| После релиза старый JavaScript | Постоянный URL и длинный TTL | Сравнить URL из нового HTML и digest файла | Добавить fingerprint или изменить контракт свежести |
| Английский запрос получает русский HTML | Нет Vary или CDN не учитывает вариант | Сделать два запроса, сравнить тела и заголовки на origin и public URL | Согласовать выбор представления, Vary и cache key |
| После TTL всегда приходит полный ответ | Нет валидатора или условный запрос не доходит до origin | Повторить запрос с прежним ETag и проверить маршрут | Настроить устойчивый валидатор и проверить каждый слой |
| Публичный ответ отличается от origin | Proxy или CDN меняет ключ, TTL или заголовки | Снять одинаковые поля и digest через обе точки | Сверить конфигурацию посредника с HTTP-контрактом |
| Данные одного пользователя видит другой | Персональный ответ попал в shared cache | Проверить две тестовые сессии, Authorization, cookies и директивы | Выбрать private, no-store или разделить публичную и личную части |
| CDN обслуживает запись дольше браузера | Используется s-maxage, отличный от max-age | Сравнить Age, Cache-Control и vendor headers | Зафиксировать отдельную политику shared cache и критерий purge |
Cache-Control, ETag, Vary, Age и Date, если поля есть.Vary по предположению.max-age и, если он используется, s-maxage. Не называйте истечение TTL удалением записи: оно может привести к условному запросу, а не к загрузке нового тела.304, новый 200 или иной фактический ответ и объясните его по заголовкам.HTTP задаёт правила кэша, но не описывает конфигурацию каждого CDN. Посредник может иметь отдельный cache key, TTL, stale policy и операцию purge. Браузер может показать данные из service worker или навигационной истории, а не из обычного HTTP-кэша. Поэтому один ответ curl не описывает весь путь пользователя.
Vary не заменяет проверку персонализации. Если тело зависит от cookie, эксперимента или пользователя, попытка перечислить все поля дробит кэш и может оставить риск утечки. Для личного HTML безопаснее начать с private или no-store, а публичную локализацию ограничить известным списком вариантов.
Учебный домен, хэш файла и значение ETag не являются результатами измерения. Стандарт не гарантирует конкретный hit ratio, ускорение или поведение vendor-specific purge. В диагностике нельзя публиковать токены, cookies, персональные query-параметры и внутренние адреса.
Проверку можно считать завершённой, когда для выбранного URL записаны входы, допустимая давность, владелец правила cache key, ожидаемые директивы, валидатор и граница, на которой каждый пункт проверяется. Два запроса с разными входами не смешивают тела. После истечения срока неизменённое представление даёт подтверждённую условную проверку, а изменённое — новый ответ. Новый asset получает новый URL. Персональный ответ не попадает в shared cache.
\nЕсли это нельзя показать заголовками, телом и снимками на доступных границах, контракт ещё не доказан. Исправление начинается с уровня, где появилось расхождение: origin, proxy, CDN или браузер. Очистка кэша может убрать симптом, но не доказывает, что ключ и срок теперь согласованы.
\nVary, свежести, валидации, Age и директив Cache-Control.ETag, Vary и условных ответов.no-cache, no-store, proxy и managed cache.