Кэширование 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, 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
Главные директивы:
Примеры реальных конфигураций:
# Статический 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 ETag —
W/"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.
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 можно указать список:
Vary: Accept-Language, Accept-Encoding, User-Agent
Здесь CDN держит отдельный кэш для каждой комбинации трёх заголовков. Чем больше Vary — тем больше кэшей — тем меньше cache hit rate. Поэтому Vary надо ставить минимально необходимым.
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 получают то, что было сгенерировано для атакующего.
Защита:
- Всегда правильно указывать
Varyдля всех request headers, от которых зависит ответ. - Для приватных ответов —
Cache-Control: privateилиno-store. - Не использовать 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 -- реально пошли и скачали
Что вы должны вынести
- HTTP-кэширование многослойное: browser, CDN, reverse proxy.
Cache-Control— главный директор.max-age,public/private,no-cache/no-store.no-cacheНЕ значит ‘не кэшировать’ — это ‘кэшируй, но валидируй’. ‘Не кэшировать’ —no-store.- Validation: ETag + If-None-Match (точно), Last-Modified + If-Modified-Since (timestamp).
- 304 Not Modified — сервер говорит ‘используй кэш’, тело не передаётся.
Vary— ключ для разнесения кэша по разным значениям header’ов. Vary: Cookie — обычно ошибка.- Optimistic locking через If-Match: безопасный update, 412 при конфликте.
- Cache poisoning — security risk. Правильный Vary + careful caching policy.
Стратегии кэширования API: ETag, Vary и Cache-Control в контексте REST