Learning Platform
Глоссарий Troubleshooting
Урок 04.02 · 22 мин
Начальный
HTTP cacheCache-ControlETagConditional requestsVary

HTTP-кэширование: как сэкономить трафик и не сломать данные

Кэш в HTTP — это базовая часть протокола. Браузеры, прокси, CDN-узлы (Cloudflare, Fastly), reverse proxies (nginx) — все они смотрят на специальные headers и решают, можно ли отдать ответ из памяти вместо запроса на origin-сервер.

Для data engineer кэш важен по двум причинам. Первая: вы как клиент выигрываете время и деньги, если умеете правильно использовать conditional requests (304 Not Modified). Вторая: вы как разработчик API понимаете, какие ответы можно кэшировать, какие — никогда, и почему юзер получил вчерашние данные вместо свежих.

В этом уроке разберём Cache-Control, ETag, Last-Modified, conditional requests, Vary, и когда кэш безопасен для API.


Кто кэширует и зачем

HTTP-ответ может пройти через несколько кэшей по пути от сервера к клиенту.

Слои кэша

Запрос проходит через несколько кэширующих слоёв. Каждый может отдать ответ из памяти

Browser cacheКэш в браузере или HTTP-клиенте. Личный для пользователя. Хранит ответы конкретно для этого пользователя (включая ответы с private данными)
ISP / corp proxyПромежуточный shared прокси. Раньше был массово (snicki экономия трафика для ISP), сейчас редко из-за HTTPS -- прокси без MITM не видит содержимое
CDN edgeCloudflare, Fastly, AWS CloudFront. Кэширует на edge-узлах ближе к пользователю. Главный источник кэша в современном вебе
Reverse proxynginx, Varnish, Apache перед origin. Кэширует, чтобы разгрузить application server
ApplicationСам код приложения. Может тоже кэшировать в Redis/memcached, но это отдельный слой, не HTTP-кэш

Каждый слой смотрит headers ответа и решает: сохранить копию (и на сколько), и можно ли отдать эту копию следующему запросу. HTTP-кэширование — это набор правил, по которым все слои договариваются.

Ключевое разделение:

  • Private cache: только клиент (браузер). Может хранить персональные данные конкретного пользователя.
  • Shared cache: CDN, прокси. Один и тот же кэш отдаётся разным пользователям. Никогда не должен хранить персональные данные.

Cache-Control: главный header

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

Cache-Control — это header (request или response), который определяет правила кэширования. Он содержит набор директив через запятую. Большинство директив идут в response, некоторые — в request.

Основные директивы Cache-Control в response
max-age=NСколько секунд ответ считается свежим. Cache-Control: max-age=3600 -- кэш отдаёт ответ без обращения к origin в течение часа после получения
publicМожно кэшировать в shared caches (CDN, прокси). По умолчанию для GET-ответов с обычными статусами это и так разрешено, public явно подтверждает
privateТолько private кэш (браузер). CDN не должен сохранять. Используется для персональных данных: ответ для пользователя X не должен попасть пользователю Y
no-cacheМожно сохранить копию, но перед использованием НУЖНО проверить актуальность через conditional request (If-None-Match / If-Modified-Since). Не путать с no-store!
no-storeЗапрещено сохранять КУДА БЫ ТО НИ БЫЛО. Используется для чувствительных данных. Самая строгая директива
must-revalidateКогда max-age истёк -- ОБЯЗАТЕЛЬНО revalidate с origin. Нельзя отдать stale-копию даже при сетевой ошибке
s-maxage=NТо же что max-age, но только для shared caches (CDN). Позволяет настроить разный TTL для CDN и для браузера
stale-while-revalidate=NПосле истечения max-age в течение N секунд можно отдать stale-копию И параллельно сходить к origin за свежей. Пользователь получает быстрый ответ, кэш обновляется в фоне
stale-if-error=NЕсли origin недоступен (5xx или сетевая ошибка), в течение N секунд после max-age можно отдать stale. Резильентность при downtime

Несколько директив комбинируются:

Cache-Control: public, max-age=3600, s-maxage=86400, stale-while-revalidate=600

Что это значит:

  • Можно кэшировать в shared caches.
  • Браузер считает свежим 1 час.
  • CDN считает свежим 24 часа.
  • В течение 10 минут после истечения CDN может отдать stale-ответ и обновить в фоне.

no-cache vs no-store: важная путаница

Это две разные директивы с очень разным смыслом, и junior часто их путают.

no-cache vs no-store
no-cacheИмя сбивает с толку: кажется 'не кэшируй вообще'. На самом деле: КЭШИРУЙ, но перед использованием спроси origin, актуальна ли копия. Это conditional request, который часто возвращает 304 Not Modified -- экономит трафик ответа, но всё равно делает round-trip
no-storeИстинное 'не храни нигде'. Не сохраняй ответ ни в memory, ни на диск, ни в swap, ни в логи. Используется для credit-card форм, банковских данных, healthcare PHI
max-age=0Свежесть = 0 секунд. Эквивалентно no-cache в большинстве реализаций. Тоже forces revalidation
must-revalidateКогда expired -- обязательно revalidate. Без этого некоторые кэши могут отдать stale при ошибке

Практический mental model:

  • Хочешь, чтобы cache ВСЕГДА проверял у origin, но мог использовать локальную копию при 304? — no-cache.
  • Хочешь, чтобы данные НИКОГДА не сохранялись? — no-store.
  • Эти две комбинируются: no-cache, no-store — самое жёсткое, никогда не сохранять.

ETag: версия ресурса

ETag (Entity Tag) — это идентификатор конкретной версии ресурса. Сервер выставляет ETag: "abc123" в ответе. Клиент при следующем запросе может прислать If-None-Match: "abc123" — сервер проверит и ответит:

  • Не изменилось -> 304 Not Modified (без body, экономия трафика).
  • Изменилось -> 200 OK + новый body + новый ETag.

ETag бывает двух видов:

Strong vs Weak ETag
Strong ETag: "abc123"Кавычки без префикса. Гарантирует byte-for-byte идентичность. Если изменился хоть один байт -- ETag другой. Подходит для byte-range requests (можно докачать файл, зная, что серверная копия не изменилась)
Weak ETag: W/"abc123"Префикс W/. Гарантирует семантическую эквивалентность, но не байтовую. Например, JSON может быть отформатирован по-разному, но представлять те же данные. Не подходит для range requests

Откуда сервер берёт ETag — на его усмотрение. Распространённые подходы:

  • Хеш контента (md5, sha1, sha256) — strong.
  • Версия в БД (например, WHERE version = 7) — может быть strong или weak.
  • Last-Modified timestamp + content hash — обычно strong.
  • Random + timestamp — допустимо как weak.

ETag должен меняться при любом семантически значимом изменении. Если ETag не меняется, а данные поменялись — клиент будет видеть старые данные неопределённое время.


Conditional requests: 304 Not Modified

Conditional request — это запрос, который сервер обрабатывает условно: если условие выполнено, возвращает обычный ответ; если нет, возвращает специальный статус.

Conditional request с ETag

Клиент отправляет If-None-Match -- сервер сравнивает с актуальным ETag и решает 200 vs 304

Client
Server
GET /users/1 (first time)200 OK + ETag: "abc123" + bodyGET /users/1 + If-None-Match: "abc123"304 Not Modified (без body!)GET /users/1 + If-None-Match: "abc123" (после изменения)200 OK + ETag: "def456" + new body

Экономия от 304: ответ без body. Например, JSON со списком 1000 пользователей весит 200 КБ. Если он не изменился, 304-ответ весит ~200 байт. В 1000 раз меньше.

Аналогично работают Last-Modified и If-Modified-Since — но с timestamp вместо ETag. Сервер выставляет Last-Modified: Wed, 21 Oct 2026 07:28:00 GMT, клиент при повторном запросе шлёт If-Modified-Since: Wed, 21 Oct 2026 07:28:00 GMT. Сравнение по времени.

ETag предпочтительнее Last-Modified:

  • Точность 1 секунда у Last-Modified — этого мало для часто меняющихся ресурсов.
  • ETag может отражать любую версионность (не только время).
  • Strong ETag — byte-exact, Last-Modified — нет.

Если сервер отдаёт оба — клиент использует ETag.


Vary: разные ответы для разных клиентов

Один URL может возвращать разный body в зависимости от headers запроса. Например:

  • Accept: application/json -> JSON.
  • Accept: application/xml -> XML.
  • Accept-Language: ru -> русский.
  • Accept-Encoding: gzip -> сжатый.

Если CDN закэширует ответ для одного клиента и отдаст другому — будет ошибка (русский получит английский, JSON-клиент получит XML).

Решение — header Vary в ответе сервера. Он перечисляет, какие headers запроса влияют на ответ.

Vary: Accept, Accept-Language, Accept-Encoding

Это говорит CDN: «кэшируй отдельно для каждой комбинации этих headers». Если два клиента шлют разный Accept-Language — кэш считает их разными ключами и хранит две копии.

WARNING

Слишком широкий Vary (особенно Vary: User-Agent) убивает hit-rate кэша. Каждый юзер имеет уникальный User-Agent, кэш будет хранить копию для каждого юзера. Используйте Vary осторожно, только для headers, которые реально влияют на body.


Когда кэш безопасен для API

Reverse proxy: NGINX, HAProxy, TLS termination и кэширование

Главный вопрос для API-разработчика: какие ответы можно кэшировать?

Кэшируемость по типу запроса
GET /users (public list)Идемпотентен, не меняет состояние. Если данные публичные -- можно ставить max-age минут на 5-10. Для редко меняющихся справочников -- часы
GET /users/me (user-specific)Идемпотентен, но возвращает данные конкретного юзера. private + короткий max-age. Никогда public -- иначе кэш CDN отдаст ваши данные другому
GET /payments/123 (sensitive)Финансовые данные. no-store или хотя бы no-cache + private. Регуляторика (PCI DSS) часто запрещает кэшировать на shared layer
POST /ordersНе идемпотентный, меняет состояние. По умолчанию POST не кэшируется. Cache-Control: no-store на всякий случай
PUT/PATCH/DELETEМеняют состояние. Не должны кэшироваться. Сервер обычно отдаёт no-store. Кроме того, успешный PUT/DELETE должен ИНВАЛИДИРОВАТЬ кэш для соответствующего GET
GET с Authorization headerПо спецификации (RFC 7234) shared cache не должен кэшировать ответ на запрос с Authorization, кроме явных случаев public + s-maxage. Иначе утечка персональных данных

Хорошие defaults для API:

  • Публичные read endpoints (списки категорий, справочники, погода): Cache-Control: public, max-age=300, stale-while-revalidate=60.
  • User-specific GET: Cache-Control: private, max-age=0, must-revalidate + ETag (304 экономит трафик).
  • Чувствительные данные: Cache-Control: no-store.
  • Mutations (POST/PUT/DELETE): Cache-Control: no-store.

Подводные камни

Кэш отдаёт устаревшие данные. Юзер обновил профиль, но видит старое имя. Причина: max-age на endpoint /users/me слишком большой, или CDN не инвалидируется при PUT. Решение: короткий max-age + ETag, или явная инвалидация (purge) в CDN после mutation.

Утечка через shared кэш. Public endpoint случайно отдаёт user-specific данные. CDN сохраняет ответ для одного юзера и отдаёт другому. Решение: private или no-store для всего, что зависит от Authorization. Многие CDN (Cloudflare) по умолчанию не кэшируют ответы с Set-Cookie или Authorization, но не полагайтесь на это.

Кэширование 4xx/5xx. По умолчанию некоторые статусы кэшируются (например, 404 с подходящими headers). Это может спрятать факт, что endpoint починили. Используйте Cache-Control: no-store для error-ответов, или короткий max-age.

Vary с Authorization. Vary: Authorization означает «кэшируй отдельно для каждого токена». Это и небезопасно (токен в ключе кэша), и неэффективно. Лучше private + не использовать Vary с такими headers.


Попробуй сам

Поэкспериментируйте с реальными API.

# 1. GitHub API возвращает ETag -- попробуйте 304
curl -i https://api.github.com/users/octocat 2>&1 | grep -iE "etag|cache"
# ETag: W/"abc..."
# Cache-Control: public, max-age=60, s-maxage=60

# 2. Conditional request -- возвращаем 304
ETAG=$(curl -s -i https://api.github.com/users/octocat | grep -i etag | awk '{print $2}' | tr -d '\r')
curl -i -H "If-None-Match: $ETAG" https://api.github.com/users/octocat
# HTTP/2 304

# 3. То же из Python с requests
python3 - <<'PY'
import requests
r1 = requests.get("https://api.github.com/users/octocat")
print(f"First: {r1.status_code}, ETag: {r1.headers.get('ETag')}, body size: {len(r1.content)}")

etag = r1.headers["ETag"]
r2 = requests.get("https://api.github.com/users/octocat", headers={"If-None-Match": etag})
print(f"Second: {r2.status_code}, body size: {len(r2.content)}")
# 304, body size: 0 -- данные не пришли, мы экономим трафик
PY

# 4. Посмотреть Cache-Control популярных сайтов
curl -sI https://www.python.org/ | grep -i cache
curl -sI https://api.openweathermap.org/data/2.5/weather | grep -i cache

Подумайте над вопросами:

  • Если сервер выставляет max-age=3600, но через 30 минут данные изменились — что увидят клиенты? Как разрулить? (вариант: короткий max-age + ETag).
  • Когда CDN-кэш безопасен для API с Authorization? (когда Authorization не влияет на body, например публичная справочная информация — но это очень редкий случай).

Проверка знанийKnowledge check
API возвращает Cache-Control: no-cache, public, max-age=3600. Кажется противоречиво. Что эта комбинация на самом деле значит, и как поведёт себя CDN при первом и втором запросе одного и того же ресурса?
ОтветAnswer
Это валидная (хоть и непривычная) комбинация. no-cache не означает 'не кэшируй' -- это значит 'кэшируй, но перед использованием revalidate'. public разрешает shared caches (CDN). max-age=3600 определяет, как долго ответ считается свежим -- но в данном случае no-cache переопределяет: revalidate всё равно нужен. На практике поведение: (1) Первый запрос: CDN получает ответ от origin, сохраняет копию вместе с ETag. (2) Второй запрос: CDN не может отдать копию без проверки (no-cache). Делает conditional request к origin (If-None-Match с сохранённым ETag). Если 304 -- отдаёт клиенту копию из кэша + 304. Если 200 -- обновляет копию. Эта комбинация полезна когда вы хотите экономить трафик между CDN и клиентом (за счёт 304), но всегда проверять актуальность у origin (no-cache). Альтернатива -- max-age=0, must-revalidate -- даёт похожий эффект. Для большинства API лучше выбрать что-то одно: либо короткий max-age (3-5 минут) с обычным кэшем, либо no-cache + ETag для частых проверок. Комбинировать их в одном Cache-Control -- признак того, что разработчик не разобрался в семантике директив.

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

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

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

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

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

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