Learning Platform
Глоссарий Troubleshooting
Урок 11.05 · 25 мин
Средний
CachingETagCache-ControlCDNIf-Modified-Since

Кэширование HTTP — ETag, If-Modified-Since, Cache-Control, Vary

Кэширование — это, возможно, самая важная оптимизация в HTTP. Не «ускорить запрос», а вообще его не делать. Если браузер знает, что ответ не изменился — зачем заново ходить на сервер? И даже если ходить — зачем перекачивать те же 500 КБ JSON, если можно проверить «не изменилось ли», и получить либо 304 Not Modified (несколько байт), либо новые данные?

HTTP-протокол предусматривает многослойную систему кэширования: браузер кэширует, CDN кэширует, корпоративный прокси кэширует. Правильно настроенное кэширование уменьшает нагрузку на сервер в десятки раз и улучшает UX — страницы открываются мгновенно.

В этом уроке разберём, как работает кэширование в HTTP — что такое fresh/stale, как клиент валидирует кэш через conditional requests, какие директивы есть в Cache-Control и почему Vary критически важен для CDN.


Анатомия HTTP-кэша

Где живёт кэш? В нескольких местах одновременно:

Слои кэширования HTTP
Browser cacheDisk cache браузера. Хранит ответы по URL. Видим в DevTools -> Network -> 'from disk cache' / 'from memory cache'
CDN edgeCloudflare, CloudFront, Fastly -- кэшируют ответы близко к пользователю. Один edge обслуживает миллионы запросов, не доходя до origin
Reverse proxyNginx, Varnish, HAProxy -- перед бэкендом. Кэшируют 'горячие' ответы. Защищают backend от cache stampede
Application cacheRedis, Memcached -- внутри приложения. Кэшируют результаты запросов в БД, computed values. Управляет код приложения, не HTTP-механизмы

HTTP-кэширование — это в основном про первые три слоя (browser, CDN, proxy). Они общаются через стандартные HTTP-заголовки. Четвёртый слой (Redis в приложении) — это уже логика приложения, HTTP-протокол об этом не знает.

Ключевые понятия:

  • Private cache — хранит данные одного пользователя (его browser). Может содержать persona-specific вещи (cookies-зависимые ответы).
  • Shared cache — хранит данные многих пользователей (CDN, proxy). Не должен содержать privacy-sensitive контент.

Cache-Control: главный директор

Сервер управляет кэшированием через заголовок Cache-Control в ответе. Это набор директив:

Cache-Control: public, max-age=3600, must-revalidate

Главные директивы:

Cache-Control directives
publicОтвет можно кэшировать в shared caches (CDN, прокси). Без явного флага по умолчанию -- public для GET с 200 OK
privateТолько browser cache, не shared. Используется для персональных ответов (user dashboard, login state)
no-storeНе кэшировать вообще, нигде. Для чувствительных данных (банковские транзакции). Запрос всегда идёт на сервер
no-cacheКэшировать можно, НО перед использованием обязательно валидировать через conditional request. Не значит 'не кэшировать' (это no-store)
max-age=NОтвет считается свежим N секунд. После этого считается stale, нужна валидация. Самая частая директива
s-maxage=NКак max-age, но только для shared caches. Позволяет указать разное время для browser и CDN
must-revalidateПосле истечения max-age кэш ОБЯЗАН валидировать перед использованием. По умолчанию stale ответ может быть использован при ошибках сети
immutableРесурс гарантированно не изменится -- даже на reload не делай conditional request. Используется для assets с hash в URL (app.123abc.js)

Примеры реальных конфигураций:

# Статический asset с hash в URL -- кэш на год, не валидировать
Cache-Control: public, max-age=31536000, immutable

# API endpoint, который меняется -- 60 секунд OK
Cache-Control: public, max-age=60

# Личная страница (login state) -- только browser, 5 минут
Cache-Control: private, max-age=300

# Чувствительные данные -- никогда не кэшировать
Cache-Control: no-store

# Кэш OK, но всегда проверять перед использованием
Cache-Control: no-cache

Fresh vs Stale

Каждый закэшированный ответ имеет состояние: fresh (можно отдавать напрямую) или stale (нужно валидировать или перезапросить).

Request -> Cache lookup -> Found?
                              |
                              v
                          Fresh? (max-age не истёк)
                          /       \
                       YES        NO (stale)
                        |          |
                        v          v
                   Return         Conditional GET
                   cached         (If-None-Match,
                   directly       If-Modified-Since)

Параметр свежести (max-age) — это договор: клиент верит, что в течение этого времени ответ не изменился, и не беспокоит сервер. После истечения клиент идёт проверить.

Расчёт максимального возраста идёт от заголовка Date ответа (когда сервер его сгенерировал) + max-age. Если ответ в кэше дольше max-age — stale.


ETag и If-None-Match: validation tokens

Когда ответ становится stale, клиент не сразу перекачивает его заново. Сначала пытается validate — спросить сервер «не изменился ли ответ с момента, как я его получал?». Если не изменился, сервер вернёт 304 Not Modified — всего пару байт. Клиент использует кэшированный ответ.

Two механизма validation:

ETag (entity tag) — произвольная строка, идентифицирующая версию ресурса:

# Первый запрос
GET /api/users/42 HTTP/1.1

HTTP/1.1 200 OK
ETag: "abc123xyz"
Cache-Control: max-age=60
Content-Type: application/json

{"id": 42, "name": "Linus", "version": 1}
# Через час, ответ stale. Браузер шлёт conditional request
GET /api/users/42 HTTP/1.1
If-None-Match: "abc123xyz"

# Если не менялся, сервер отвечает 304:
HTTP/1.1 304 Not Modified
ETag: "abc123xyz"
Cache-Control: max-age=60
# (без тела!)

Размер 304-ответа: десятки байт. Сравните с 200-ответом, который мог быть мегабайтом. Огромная экономия.

Если ресурс изменился, сервер вернёт 200 с новым ETag и новым телом:

HTTP/1.1 200 OK
ETag: "def456abc"
Cache-Control: max-age=60

{"id": 42, "name": "Linus", "version": 2}

Strong vs weak ETag

ETag бывает strong и weak:

  • Strong ETag"abc123". Гарантирует byte-for-byte идентичность. Если ETag совпадает, ответ exactly тот же.
  • Weak ETagW/"abc123". Гарантирует семантическую идентичность, но содержимое может отличаться (другие компрессии, форматирование).

Strong ETag нужен для range requests (если клиент скачивает только кусок файла). Weak ETag — для обычных JSON-ответов.

Как сервер генерирует ETag? Чаще всего:

  • Hash содержимого (md5(content)).
  • Version из БД (v123).
  • Timestamp последнего изменения + размер.

Last-Modified и If-Modified-Since

Альтернативный механизм — по timestamp последнего изменения:

# Первый ответ
HTTP/1.1 200 OK
Last-Modified: Mon, 18 May 2026 10:00:00 GMT
Content-Type: application/json

{...}
# Conditional request -- 'не изменилось ли с тех пор?'
GET /api/users/42 HTTP/1.1
If-Modified-Since: Mon, 18 May 2026 10:00:00 GMT

# Если не изменилось:
HTTP/1.1 304 Not Modified

Last-Modified проще генерировать (это просто timestamp). Но менее точен:

  • Разрешение — секунда. Если изменения происходят чаще — не отличаются.
  • Не работает для ресурсов, у которых нет очевидного timestamp.

ETag — более универсальный механизм. На практике сервера часто шлют оба заголовка. Браузер использует более строгий: если есть ETag, шлёт If-None-Match.

NOTE

Conditional requests это часть стандартного REST flow для optimistic locking. Клиент шлёт PUT с If-Match: ‘etag’. Сервер: ‘если на сервере сейчас тот же ETag — обнови’. Если за это время кто-то обновил (ETag другой) — сервер возвращает 412 Precondition Failed. Клиент знает: ‘произошёл конфликт, надо переquery и переapply’.


Vary: кэш с учётом контекста

Самая коварная часть HTTP-кэширования. Vary говорит кэшу: «этот ответ зависит от значений вот этих request headers, кэшируй отдельно для каждой комбинации».

Пример: ваш сервер отдаёт разный JSON в зависимости от Accept-Language:

# Request 1
GET /api/welcome HTTP/1.1
Accept-Language: en

HTTP/1.1 200 OK
Vary: Accept-Language
Content-Type: application/json

{"message": "Hello"}
# Request 2
GET /api/welcome HTTP/1.1
Accept-Language: ru

HTTP/1.1 200 OK
Vary: Accept-Language

{"message": "Привет"}

Без Vary CDN мог бы закэшировать первый ответ и отдавать русскому пользователю «Hello» — катастрофа. С Vary: Accept-Language CDN держит два кэшированных ответа — для en и ru — и отдаёт правильный по request header.

Vary: один URL, несколько кэшей
URL: /api/welcomeОдин и тот же путь, но ответ зависит от Accept-Language. Без Vary CDN кэшировал бы первый ответ и отдавал всем -- bug
Accept-Language: enКэш-ключ = URL + 'en'. CDN запоминает: 'для этого URL с английским -- такой ответ'
Accept-Language: ruКэш-ключ = URL + 'ru'. Отдельный кэш-entry
Accept-Language: frЗапрос с fr -- кэш-miss, идём на origin

Vary можно указать список:

Vary: Accept-Language, Accept-Encoding, User-Agent

Здесь CDN держит отдельный кэш для каждой комбинации трёх заголовков. Чем больше Vary — тем больше кэшей — тем меньше cache hit rate. Поэтому Vary надо ставить минимально необходимым.

WARNING

Vary: Cookie — катастрофа для CDN. Каждый пользователь имеет уникальные cookies (session_id, tracking). С Vary: Cookie каждый юзер — отдельный кэш-entry, hit rate стремится к нулю. Если ответ зависит от cookies — скорее всего, его не надо кэшировать вообще (private, no-store), а не Vary: Cookie. Альтернатива: убрать cookies из запроса перед прохождением через CDN.


Реальный пример: настройка кэша для разных типов контента

Типичная конфигурация на nginx:

# Статические assets -- 1 год, immutable (URL с hash)
location ~* \.(js|css|woff2)$ {
    expires 1y;
    add_header Cache-Control "public, max-age=31536000, immutable";
}

# Картинки -- 1 месяц, можно валидировать
location ~* \.(jpg|jpeg|png|gif|webp|svg)$ {
    expires 1M;
    add_header Cache-Control "public, max-age=2592000";
}

# HTML страницы -- 5 минут на CDN, но валидируется
location ~* \.html$ {
    expires 5m;
    add_header Cache-Control "public, max-age=300, must-revalidate";
}

# API -- 60 секунд на shared, 0 на private (always revalidate browser)
location /api/ {
    add_header Cache-Control "s-maxage=60, max-age=0, must-revalidate";
}

# Auth-endpoints -- никогда не кэшировать
location /api/auth/ {
    add_header Cache-Control "no-store";
}

Логика:

  • Assets with hash in URL (app.123abc.js) — 1 год immutable. URL меняется при изменении файла, поэтому никогда не устаревает.
  • Картинки — месяц, можно validate (вдруг кто перезальёт).
  • HTML — короткий cache, важна актуальность контента.
  • API — зависит от endpoint’а. Публичные данные (/api/weather) cachable, личные нет.
  • Auth — никогда. Login state must be fresh.

Cache poisoning — как не сломать кэш безопасностью

Cache имеет важный security risk: если атакующий сможет «отравить» кэш (положить туда вредоносный ответ), все следующие пользователи получат его.

Типичный сценарий: сервер отдаёт ответ, который зависит от какого-то header, но не указывает его в Vary. Атакующий шлёт запрос с этим header, получает «свой» ответ, CDN его кэширует под общим URL. Все следующие пользователи на этот URL получают то, что было сгенерировано для атакующего.

Защита:

  1. Всегда правильно указывать Vary для всех request headers, от которых зависит ответ.
  2. Для приватных ответов — Cache-Control: private или no-store.
  3. Не использовать user-controlled values в формировании response без sanitization.

Попробуй сам

# 1. Посмотреть Cache-Control от популярных сайтов
curl -sI https://www.google.com | grep -i 'cache\|etag\|vary\|expires'
curl -sI https://github.com | grep -i 'cache\|etag\|vary\|expires'
curl -sI https://www.cloudflare.com | grep -i 'cache\|etag\|vary\|expires'

# 2. Получить ETag, потом сделать conditional request
curl -sI https://httpbin.org/etag/test123 | grep -i etag
# В output: ETag: test123

# Conditional request c этим ETag
curl -v -H 'If-None-Match: "test123"' https://httpbin.org/etag/test123 2>&1 | grep -E '^<|^>'
# Видим: < HTTP/1.1 304 Not Modified -- ответа нет

# С неподходящим ETag
curl -v -H 'If-None-Match: "wrong"' https://httpbin.org/etag/test123 2>&1 | grep -E '^<|^>' | head -10
# Видим: < HTTP/1.1 200 OK -- сервер думает 'не тот ETag, отдаю заново'

# 3. Посмотреть, как CDN кэширует через Vary
# CloudFlare cf-cache-status: HIT/MISS
curl -sI https://www.cloudflare.com | grep -i 'cf-cache\|cache-control'

# 4. Принудительно очистить browser cache
# В Chrome: F12 -> Network tab -> Disable cache (галочка) -- видно, как все запросы идут заново

# 5. Посмотреть, какой статус у запроса в DevTools:
# 200 (from disk cache) -- из локального cache, не ходили на сервер
# 200 (from memory cache) -- из RAM (только что был загружен)
# 304 Not Modified -- ходили, сервер сказал 'не менялось'
# 200 -- реально пошли и скачали

Что вы должны вынести

  1. HTTP-кэширование многослойное: browser, CDN, reverse proxy.
  2. Cache-Control — главный директор. max-age, public/private, no-cache/no-store.
  3. no-cache НЕ значит ‘не кэшировать’ — это ‘кэшируй, но валидируй’. ‘Не кэшировать’ — no-store.
  4. Validation: ETag + If-None-Match (точно), Last-Modified + If-Modified-Since (timestamp).
  5. 304 Not Modified — сервер говорит ‘используй кэш’, тело не передаётся.
  6. Vary — ключ для разнесения кэша по разным значениям header’ов. Vary: Cookie — обычно ошибка.
  7. Optimistic locking через If-Match: безопасный update, 412 при конфликте.
  8. Cache poisoning — security risk. Правильный Vary + careful caching policy.

Стратегии кэширования API: ETag, Vary и Cache-Control в контексте REST
Проверка знанийKnowledge check
Команда настроила CDN перед API. Эндпоинт /api/products возвращает каталог товаров (одинаковый для всех пользователей), но с локализацией -- русские пользователи видят названия на русском, английские на английском. Бэкенд возвращает: Cache-Control: public, max-age=600. После запуска английские пользователи начали получать русские названия и наоборот -- хаос. Что произошло и как починить?
ОтветAnswer
Произошёл cache mix-up из-за неправильного использования Vary. Сценарий: первый запрос приходит от русского клиента (Accept-Language: ru). Сервер генерирует JSON с русскими названиями, шлёт CDN. CDN кэширует ответ по URL: /api/products -- БЕЗ учёта Accept-Language. Через минуту английский клиент запрашивает /api/products, попадает на тот же CDN edge -- CDN отдаёт кэшированный русский ответ. И наоборот: если первый запрос был от англичанина -- русские получат английские названия. Это classical cache mismatch. Решения: (1) ОСНОВНОЕ -- добавить Vary header. Сервер должен возвращать 'Vary: Accept-Language'. CDN тогда будет кэшировать отдельный entry для каждого значения Accept-Language: ru, en, fr и т.д. Cache hit rate падает в 2-3 раза, но корректность восстанавливается. (2) Альтернатива -- разделить URL по локали. /api/ru/products, /api/en/products -- разные URL = разные кэш-ключи естественно. Это чище архитектурно, плюс полезно для SEO. (3) Если локалей много (20+) и Vary убивает hit rate -- использовать edge logic. Cloudflare Workers, AWS Lambda@Edge могут определять локаль и делать transform контента на edge без хождения в origin. Но это сложнее. Чтобы такие ошибки ловить заранее -- внедрите тест: запрос с разными Accept-Language должен возвращать разные ответы, проверять CI. Также мониторинг CDN cache hit rate per content-language для detection аномалий.

Проверьте понимание

Результат: 0 из 0
Концептуальный
Вопрос 1 из 6. В чём принципиальная разница между Cache-Control: no-cache и Cache-Control: no-store?

Закончили урок?

Отметьте его как пройденный, чтобы отслеживать свой прогресс

Войдите чтобы оценить урок

Прогресс модуля
0 из 6