Кэширование: ETag и Cache-Control

Самый быстрый запрос — тот, который не пришлось выполнять заново.

Кэширование — повторное использование ранее полученного ответа вместо нового обращения к серверу, управляемое HTTP-заголовками.

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

Кэш в HTTP не один. Между клиентом и сервером выстраивается целая иерархия: приватный кэш браузера (личный для одного пользователя), общие кэши — корпоративные прокси и CDN, обслуживающие многих, — и кэш на самом сервере. Каждый слой смотрит на одни и те же заголовки и решает, хранить ли ответ. Поэтому так важно правильно помечать ответы: ошибка в одном заголовке может привести к тому, что персональные данные осядут в общем CDN и попадут не тому. Кэширование делится на два типа: с временем жизни (freshness) — ответ считается свежим заданное число секунд и отдаётся вообще без обращения к серверу; и с проверкой (validation) — по истечении свежести клиент переспрашивает сервер «не изменилось ли?», и в большинстве случаев получает короткий ответ «нет». Эти два механизма дополняют друг друга и управляются разными заголовками.

Cache-Control: правила хранения

Заголовок Cache-Control в ответе диктует политику. Основные директивы:

ДирективаСмысл
max-age=3600ответ свежий 3600 секунд, можно отдавать из кэша без перепроверки
no-storeне хранить вообще (чувствительные данные)
no-cacheхранить можно, но перед отдачей перепроверять на сервере
privateкэшировать только в браузере пользователя, не в общих прокси/CDN
publicможно кэшировать в общих прокси и CDN
HTTP/1.1 200 OK
Cache-Control: private, max-age=300
Content-Type: application/json

Здесь персональный ответ (private) считается свежим 5 минут. Для секретов (балансы, токены) ставят no-store, чтобы ответ нигде не осел. Стоит различать похожие на вид директивы: no-cache вовсе не запрещает кэширование — он разрешает хранить ответ, но требует перепроверять его на сервере перед каждой отдачей (то есть всегда выполнять условный запрос). А no-store запрещает само хранение. Путаница между ними — типичная причина того, что чувствительные данные неожиданно оказываются в кэше.

Валидаторы: ETag и Last-Modified

Когда max-age истёк, не обязательно тащить тело заново — можно проверить, не изменился ли ресурс. Для этого сервер выдаёт валидатор:

  • ETag — короткий «отпечаток» версии ресурса (хэш содержимого или номер версии): ETag: "a1b2c3". Меняется при любом изменении тела.
  • Last-Modified — дата последнего изменения: Last-Modified: Wed, 21 Jun 2026 10:00:00 GMT. Грубее (точность до секунды), но проще.
HTTP/1.1 200 OK
ETag: "a1b2c3"
Last-Modified: Wed, 21 Jun 2026 10:00:00 GMT
Cache-Control: max-age=60

Условные запросы на чтение: If-None-Match → 304

Получив ETag, клиент в следующий раз шлёт его обратно в If-None-Match:

curl -i https://api.example.com/v1/articles/42 \
  -H 'If-None-Match: "a1b2c3"'

Если ресурс не изменился, сервер отвечает 304 Not Modified без тела — клиент берёт данные из своего кэша:

HTTP/1.1 304 Not Modified
ETag: "a1b2c3"
Cache-Control: max-age=60

Экономия налицо: вместо мегабайта JSON по сети уходит крошечный заголовочный ответ. Если же ETag не совпал — сервер вернёт 200 с новым телом и новым ETag.

Условные обновления: If-Match → 412

ETag решает и другую задачу — оптимистичную блокировку при записи. Проблема «потерянного обновления»: двое прочитали ресурс, оба правят, второй затирает изменения первого, не зная о них. Решение — при PUT/PATCH прислать ETag прочитанной версии в If-Match:

curl -i -X PUT https://api.example.com/v1/articles/42 \
  -H 'If-Match: "a1b2c3"' \
  -H 'Content-Type: application/json' \
  -d '{"title":"Новый заголовок"}'

Сервер сверяет: если текущий ETag всё ещё "a1b2c3" — никто не успел изменить ресурс, обновление применяется (200/204) с новым ETag. Если ресурс уже изменён кем-то (ETag стал другим) — сервер отвечает 412 Precondition Failed, и клиент узнаёт: «перечитай свежую версию и повтори».

Алиса читает v1 (ETag "a1")     Боб читает v1 (ETag "a1")
        |                                |
        | PUT If-Match: "a1"  → 200       |
        | (ресурс стал v2, ETag "a2")     |
        |                                | PUT If-Match: "a1" → 412
        |                                | (текущий ETag уже "a2")

Так оптимистичная блокировка обходится без долгих захватов и блокирующих транзакций: мы не запрещаем другим читать или писать заранее, а лишь проверяем в момент записи, что наша версия всё ещё актуальна. Это «оптимизм» в названии — мы рассчитываем, что конфликты редки, и платим за конфликт только когда он реально случился, повторным чтением и повтором запроса.

Между ETag и Last-Modified есть практический выбор. Last-Modified проще (часто это уже имеющееся поле updated_at), но его точность — одна секунда: если ресурс изменили дважды в одну секунду, валидатор этого не заметит. ETag точнее, потому что отражает само содержимое, но его нужно вычислять. Для статики (картинки, скрипты) хватает Last-Modified; для часто меняющихся или критичных к версии ресурсов предпочтительнее ETag. Сервер может слать оба сразу — клиент использует то, что строже.

Как работает под капотом

ETag сервер обычно считает как хэш (например, MD5/SHA) от тела ответа или собирает из updated_at и версии строки в БД — главное, чтобы значение менялось при любой правке. На условный GET сервер пересчитывает ETag и сравнивает с присланным в If-None-Match; совпало — 304, и тело не сериализуется вовсе, что экономит и трафик, и CPU. Для If-Match сравнение идёт перед записью — это атомарная проверка «версия не устарела». Слабые ETag (с префиксом W/) допускают семантически эквивалентные, но байт-в-байт разные ответы (например, отличается порядок пробелов) — для кэша этого достаточно, для оптимистичной блокировки лучше сильные.

Частые ошибки

  • Кэшировать персональные ответы как public — данные одного пользователя попадут в общий прокси и утекут другому.
  • Ставить max-age на часто меняющиеся данные — клиент видит устаревшее.
  • Отдавать 304, но забывать прислать актуальные Cache-Control/ETag — кэш «протухает».
  • Не использовать If-Match на запись — гонки и потерянные обновления.
  • Класть тело в ответ 304 — он обязан быть без тела.
  • Забыть no-store на чувствительных эндпоинтах — секреты осядут в кэше браузера.

Итоги

  • Cache-Control задаёт политику: max-age, no-store, no-cache, private/public.
  • Валидаторы ETag (отпечаток версии) и Last-Modified позволяют перепроверять без скачивания тела.
  • Условный GET с If-None-Match даёт 304 Not Modified и экономит трафик.
  • Условный PUT/PATCH с If-Match реализует оптимистичную блокировку; конфликт → 412 Precondition Failed.
  • Персональные ответы — только private; секреты — no-store.
Проверьте себя
1. Клиент прислал If-None-Match с ETag, и ресурс не изменился. Что вернёт сервер?
A200 OK с полным телом
B304 Not Modified без тела
C412 Precondition Failed
D404 Not Found
2. Для чего служит заголовок If-Match при PUT/PATCH?
Aдля сжатия запроса
Bдля оптимистичной блокировки: обновить только если версия не устарела
Cдля аутентификации
Dдля указания формата ответа
3. Какая директива Cache-Control запрещает кэшировать ответ где-либо?
Amax-age=0
Bprivate
Cno-store
Dpublic
4. Почему персональный ответ нельзя помечать Cache-Control: public?
Apublic медленнее
Bобщий прокси/CDN сохранит ответ и может отдать его другому пользователю
Cpublic не поддерживает JSON
Dбраузер проигнорирует такой заголовок