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-ответ может пройти через несколько кэшей по пути от сервера к клиенту.
Запрос проходит через несколько кэширующих слоёв. Каждый может отдать ответ из памяти
Каждый слой смотрит headers ответа и решает: сохранить копию (и на сколько), и можно ли отдать эту копию следующему запросу. HTTP-кэширование — это набор правил, по которым все слои договариваются.
Ключевое разделение:
- Private cache: только клиент (браузер). Может хранить персональные данные конкретного пользователя.
- Shared cache: CDN, прокси. Один и тот же кэш отдаётся разным пользователям. Никогда не должен хранить персональные данные.
Cache-Control: главный header
Кэширование HTTP: ETag, If-Modified-Since, Cache-Control, VaryCache-Control — это header (request или response), который определяет правила кэширования. Он содержит набор директив через запятую. Большинство директив идут в response, некоторые — в request.
Несколько директив комбинируются:
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 часто их путают.
Практический 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 бывает двух видов:
Откуда сервер берёт 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 — это запрос, который сервер обрабатывает условно: если условие выполнено, возвращает обычный ответ; если нет, возвращает специальный статус.
Клиент отправляет If-None-Match -- сервер сравнивает с актуальным ETag и решает 200 vs 304
Экономия от 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 — кэш считает их разными ключами и хранит две копии.
Слишком широкий Vary (особенно Vary: User-Agent) убивает hit-rate кэша. Каждый юзер имеет уникальный User-Agent, кэш будет хранить копию для каждого юзера. Используйте Vary осторожно, только для headers, которые реально влияют на body.
Когда кэш безопасен для API
Reverse proxy: NGINX, HAProxy, TLS termination и кэшированиеГлавный вопрос для API-разработчика: какие ответы можно кэшировать?
Хорошие 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, например публичная справочная информация — но это очень редкий случай).