From beef44cca9d29b840f41f70e7bab52d1502bb2e5 Mon Sep 17 00:00:00 2001 From: "E.Gavrilov" Date: Thu, 3 Sep 2026 23:35:10 +0300 Subject: [PATCH] Rewrite editorial article 310 with cache verification --- editorial/agent-rewrites/310.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/editorial/agent-rewrites/310.json b/editorial/agent-rewrites/310.json index 74a9a38..ca99fc2 100644 --- a/editorial/agent-rewrites/310.json +++ b/editorial/agent-rewrites/310.json @@ -3,5 +3,5 @@ "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. Если в ответе есть персональные данные, ошибка кэша становится утечкой.

\n

Тезис простой: кэш хранит не «URL вообще», а представление ответа для конкретного запроса. Чтобы использовать сохранённый ответ безопасно, нужно согласовать четыре вещи: какие входы меняют тело, как они попадают в cache key, сколько ответ считается свежим и как сервер подтверждает его версию. Один правильный заголовок не исправляет расхождение между приложением и edge.

\n

Механизм: представление, вариант и свежесть

\n

В учебном примере тело /catalog зависит только от Accept-Language. Русский и английский ответы имеют один URL, но являются разными представлениями. Origin должен сообщить это через Vary: Accept-Language. Кэш использует указанные поля запроса, чтобы отличить сохранённые варианты. Если поле не указано, edge может считать русский и английский ответы одним объектом.

\n

Cache-Control задаёт правила хранения и повторного использования. max-age ограничивает свежесть ответа для обычного кэша. s-maxage позволяет отдельно задать срок для shared cache, если это нужно архитектуре. private запрещает общий кэш для ответа, который нельзя отдавать другой сессии. no-store запрещает хранение, но не превращает любой публичный ответ в персональный.

\n

После истечения свежести кэш может не получать всё тело заново. Он отправляет условный запрос с валидатором. Для этого сервер возвращает ETag, а клиент или кэш повторяет его в If-None-Match. Если представление не изменилось, сервер отвечает 304 Not Modified. Если изменилось, он возвращает новое тело и новый валидатор. Проверяйте ETag внутри одного варианта: русский ETag нельзя интерпретировать запросом без того же языка.

\n
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 придумано для примера и не является результатом измерения. В реальном сервисе валидатор должен меняться вместе с выбранным представлением, а не с каждым запросом из-за случайного идентификатора.

\n

Сначала докажите различие на origin

\n

Начните с безопасного тестового домена. Снимите два ответа, изменив только язык. Сохраните заголовки и тела отдельно. Не добавляйте cookies, User-Agent и экспериментальные параметры одновременно: иначе нельзя понять, какой вход изменил результат.

\n
curl -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. Исправьте выбор представления или передачу заголовка.

\n
\"Проверка
Один URL может иметь несколько представлений. Язык должен участвовать в проверяемом договоре кэширования.
\n

Потом сравните публичный путь

\n

Повторите те же запросы через CDN. Сравните статус, тело и ключевые заголовки. Edge может добавлять служебные поля, например Age, но не должен терять смысл Vary, срока свежести и валидатора. Отсутствие Age само по себе не доказывает, что origin был вызван.

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

\n

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

\n
СимптомПричинаПроверкаДействие
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
\n

Проверяем ETag без ложного вывода

\n

Выберите один язык и короткий срок на тестовом стенде. Сначала получите ETag. Затем отправьте условный запрос с тем же Accept-Language и этим ETag.

\n
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 после изменения, валидатор слишком грубый. Это учебные критерии; конкретные статусы нужно подтвердить на вашем стенде.

\n

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

\n
  1. Выберите публичный тестовый URL без персональных данных и назовите единственный вход, который должен менять тело.
  2. Снимите два ответа origin: сохраните заголовки, тела и значения входов отдельно.
  3. Сравните тела. Если они различаются, проверьте Vary и не расширяйте ключ случайными полями.
  4. Повторите те же два запроса через CDN. Зафиксируйте digest тел, Cache-Control, Vary, ETag и доступные служебные поля.
  5. Для одного варианта выполните условный запрос с If-None-Match после выбранного срока свежести.
  6. Исправьте слой, на котором появилось расхождение: приложение, proxy или cache key. Не начинайте с полной очистки кэша.
  7. Повторите положительный и отрицательный сценарии: правильный язык и попытку получить другой язык из того же URL.
  8. Оставьте команды и ожидаемые признаки в runbook или автоматической проверке.
\n

Ограничения

\n

Один заголовок не описывает всю конфигурацию CDN. Провайдер может иметь собственные правила нормализации, дополнительные части ключа и отдельные механизмы purge. Их нужно проверять по документации и на вашем тестовом URL.

\n

Если тело зависит от cookie, Authorization, географии, устройства или эксперимента, добавьте каждый реальный вариант в модель. Не превращайте Vary: Cookie в универсальный ремонт: он может раздробить кэш и не решить задачу персональных данных. Для личного HTML безопаснее отказаться от shared cache.

\n

Сценарий не доказывает production-результаты и не заменяет нагрузочные тесты. Учебные домены, ETag и digest выше нужны только для воспроизводимой проверки механизма. Переход к production требует согласованного TTL, политики очистки, наблюдаемости и владельца каждого правила.

\n

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

\n

Работа готова, когда два запроса к origin дают ожидаемые представления, response headers объявляют реальные входы через Vary, а два запроса через CDN возвращают соответствующие тела, а не первый сохранённый вариант. Для одного языка условный запрос возвращает 304 без изменения представления и 200 с новым ETag после контролируемого изменения. Персональный ответ не доступен через общий кэш. Все четыре результата записаны в повторяемую проверку.

\n

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

" + "contentHtml": "

В учебном сценарии утром после релиза дежурный инженер открыл /catalog и заметил странный результат: запрос с Accept-Language: en получил русский HTML. На origin уже была новая подпись, но публичный адрес иногда отдавал старую. Инженер сначала решил очистить CDN, затем сравнил ответы по слоям. Цена ошибки — неверный интерфейс и часы на спор между frontend, backend и CDN; если в ответе есть персональные данные, ошибка кэша становится утечкой.

\n

Этот разбор отвечает на один вопрос: как доказать, что два запроса с одним URL получают свои представления. Сначала проверяем, что именно создаёт origin (источник ответа), затем смотрим, как edge (пограничный кэш CDN) выбирает сохранённый объект, и только после этого проверяем свежесть через ETag. Очистка кэша может скрыть симптом, но не подтверждает правильность контракта.

\n

Разбор ситуации: один URL, две версии

\n

Представим публичный URL https://www.example.test/catalog. Тело ответа зависит от одного входа — Accept-Language. Для ru и en это два разных представления одного ресурса. Они могут иметь одинаковый путь, но кэш не должен считать их взаимозаменяемыми.

\n

Первое предположение дежурного — в CDN осталась старая запись. Проверка origin изменила картину: origin действительно различал языки, а edge возвращал один и тот же digest тела. В этот момент purge перестал быть диагностикой. Нужно было проверить, объявляет ли ответ зависимость через Vary и совпадает ли это правило с фактическим cache key провайдера.

\n

Как кэш выбирает сохранённый ответ

\n

Минимальный cache key включает метод и целевой URI. Для согласования содержимого кэш может хранить несколько ответов на один URI. Заголовок Vary сообщает, какие request headers исходного запроса участвуют в сопоставлении: если ответ содержит Vary: Accept-Language, значение этого поля должно совпасть с запросом, который кэш хочет обслужить без повторной проверки.

\n

Vary — часть HTTP-контракта ответа. Настройка CDN может дополнительно включать в ключ cookie, query-параметр или нормализованное значение заголовка. Поэтому наличие Vary не доказывает, что конкретный edge настроен верно: нужно сравнить фактические тела и заголовки на origin и публичном адресе. Если тело не зависит от языка, добавлять Vary «на всякий случай» не следует — это создаст лишние варианты.

\n

Свежесть — отдельная координата. max-age задаёт срок свежести для обычного кэша, а s-maxage может задать отдельный срок для shared cache. Директива private ограничивает хранение ответа общим кэшем, а no-store запрещает его хранить. Ни одна из этих директив не выбирает язык и не исправляет неполный ключ.

\n
Что наблюдаемЧто это означаетЧто проверить
Origin возвращает разные телаПредставление зависит от входаЗаписать вход, digest тела и response headers
В ответе нет VaryЗависимость от request header не объявленаСверить фактические входы генератора
Origin различает, edge склеиваетПравило cache key не соответствует вариантуСравнить холодные запросы и настройки CDN
Тело верное, но староеОтвет ещё свежий или revalidation не работаетПроверить Age, TTL, ETag и 304
В ответе есть пользовательские данныеShared cache может отдать объект другому пользователюПроверить Authorization, cookie и Cache-Control
\n
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 придуманы и не являются результатом измерения. В реальной системе значение тега непрозрачно для клиента; важно, что оно идентифицирует выбранное представление, а не то, как именно сервер его вычисляет.

\n

Сначала докажите различие на origin

\n

Начните с безопасного тестового домена без персональных данных. Меняйте только язык, сохраняйте заголовки и тела отдельно. Не добавляйте одновременно cookie, User-Agent и экспериментальные параметры: иначе после этого нельзя будет сказать, какой вход изменил результат.

\n
curl -sS -L -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 -L -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\ndiff -u /tmp/catalog-ru.html /tmp/catalog-en.html || true\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

Команды предназначены для стенда и здесь не выполнялись. Разные digest и осмысленный diff подтверждают, что origin сформировал разные тела. В обоих ответах ищите Vary: Accept-Language. Если origin уже отдаёт неверный язык, не начинайте с purge: исправьте выбор представления или передачу заголовка.

\n
Проверка вариантов HTTP-кэша через origin и CDN с разными языками и ETag
Сначала сравниваются два ответа origin, затем те же варианты на edge. Digest тела показывает склейку даже тогда, когда статус обоих запросов равен 200.
\n

Поворот расследования: origin и edge расходятся

\n

После проверки origin инженер повторяет те же запросы через публичный адрес. Статус 200 сам по себе ничего не доказывает: сервер мог вернуть HTML от другого варианта. Нужны тело, итоговый URL после redirect, Content-Type, Cache-Control, Vary, ETag и, если его добавляет shared cache, Age. Отсутствие Age не доказывает обращение к origin.

\n
for lang in ru en; do\n  curl -sS -L -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 отдаёт один digest, причина находится между ними: cache key, нормализация заголовка, proxy или правило провайдера. RFC описывает поведение HTTP-кэша, но конкретный CDN может иметь собственные настройки ключа; их нужно сверить с документацией провайдера и подтвердить запросами.

\n

Если публичный ответ содержит персональные данные, не пытайтесь лечить это добавлением всех cookie в ключ. Такой ключ может раздробить кэш и всё равно оставить ошибку в политике хранения. Для личного HTML безопаснее отказаться от shared cache и проверить директивы private или no-store согласно модели данных.

\n

ETag и условный запрос

\n

ETag — непрозрачный валидатор выбранного представления. Он может различать версии ресурса во времени и одновременно существующие варианты content negotiation. При If-None-Match сервер сравнивает тег с выбранным представлением запроса; для GET при совпадении возвращается 304 Not Modified без тела. Поэтому язык в условном запросе должен выбирать тот же вариант, для которого был снят тег.

\n

Сначала получите тег для одного языка, затем повторите запрос с тем же Accept-Language. После контролируемого изменения представления ожидайте новый тег и 200. Случайно меняющийся тег ломает повторное использование, а неизменный тег после изменения позволяет отдать устаревшее представление. Сравнивать тег от ru с запросом en нельзя: это уже другая выбранная репрезентация, даже если URL совпадает.

\n
curl -sS -L -D /tmp/catalog-ru-first.headers -o /tmp/catalog-ru-first.html \\\n  -H \"Accept-Language: ru\" \\\n  https://www.example.test/catalog\n\nETAG=$(awk 'BEGIN{IGNORECASE=1} /^etag:/ {sub(/^etag:[[:space:]]*/, \"\"); print; exit}' \\\n  /tmp/catalog-ru-first.headers)\n\ncurl -sS -L -D - -o /dev/null \\\n  -H \"Accept-Language: ru\" \\\n  -H \"If-None-Match: $ETAG\" \\\n  https://www.example.test/catalog
\n

Ожидаемый 304 означает, что выбранное представление не изменилось и валидатор совпал; это не доказательство попадания в CDN, потому что условный запрос может обработать origin. Для проверки edge отдельно фиксируйте публичный URL и доступные признаки кэша. После изменения только русской версии повторите оба шага и убедитесь, что английский тег и тело не изменились случайно.

\n

Порядок проверки

\n
  1. Выберите тестовый URL без персональных данных и назовите единственный вход, который должен менять тело.
  2. Снимите два ответа origin, меняя только Accept-Language; сохраните входы, заголовки, тела и digest.
  3. Сравните тела. Если они различаются, проверьте, что каждый response header содержит согласованный Vary.
  4. Повторите те же запросы через публичный edge и запишите итоговый URL, статус, заголовки и digest каждого тела.
  5. Если ответы склеились, проверьте cache key, нормализацию заголовка и правило провайдера; не начинайте с полной очистки кэша.
  6. Для одного языка снимите ETag и выполните условный GET с тем же языком и If-None-Match.
  7. Измените только выбранное представление и повторите запрос: ожидайте новый тег и 200 с новым телом, если сервер применяет этот контракт.
  8. Запишите команды и ожидаемые признаки в runbook или автоматическую проверку, а затем удалите тестовые персональные данные.
\n

Ограничения и критерий готовности

\n

Один Vary не описывает всю конфигурацию CDN. Тело может зависеть от cookie, Authorization, географии, устройства, query-параметра или эксперимента. Каждый реальный вход нужно включить в модель варианта либо исключить shared caching. Redirect, CORS, service worker и серверная нормализация могут изменить наблюдаемую загрузку, поэтому проверяйте итоговый ответ.

\n

Сценарий не сообщает production-результаты: домены, языки, теги и digest выше учебные. Работа готова, когда origin отдаёт ожидаемые тела, заголовки объявляют реальные входы, edge не смешивает варианты, а условный запрос проверен для того же выбранного представления. Вернувшись к утренней сцене, инженер оставляет purge аварийным действием, а доказательством считает повторяемую пару запросов и зафиксированный результат.

\n

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

" }