Кэширование: 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.