awesome-everything EN
↑ Обратно к восхождению

Кеширование

ETag и условные запросы: 304 экономит байты, но не round-trip

Суть Сервер делает отпечаток ресурса в виде ETag; клиент перепроверяет через If-None-Match и получает 304 с пустым телом, когда ничего не изменилось. Классический прод-провал — ETag, который различается на каждом узле, так что 304 не видит ни один клиент.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на junior-высоте — поверхность
◷ 16 min

Дашборд опрашивает /api/config каждые 30 секунд. Ты добавил ETag, чтобы «сэкономить трафик», выкатил — а счёт за egress не сдвинулся. Открываешь DevTools: каждый опрос — чистый 200 OK с полным телом на 8 КБ. Ни одного 304. Конфиг не менялся уже несколько дней. Баг не в клиенте — в прошлом месяце ты масштабировал API до трёх подов, каждый считает свой ETag из счётчика внутри процесса, а балансировщик раскидывает запросы по разным подам по кругу. If-None-Match клиента никогда не совпадает с тем, кто отвечает следующим.

Механизм: отпечаток, эхо, сравнение

Условный запрос — это соглашение двух сторон о версии ресурса. В первом ответе сервер прикрепляет заголовок ETag — непрозрачный токен, обычно в кавычках, который идентифицирует именно эту версию тела: ETag: "a3f5c901". Клиент сохраняет его рядом с кэшированной копией.

В следующем запросе клиент говорит «отдай, только если изменилось», эхом отправляя токен в If-None-Match: "a3f5c901". Сервер пересчитывает (или ищет) текущий ETag и сравнивает:

  • Совпало → у клиента уже актуальная версия. Сервер отвечает 304 Not Modified без тела и со свежими заголовками. Клиент отдаёт свою кэшированную копию.
  • Не совпало → версия сдвинулась. Сервер отвечает 200 OK с полным новым телом и новым ETag.

Токен непрозрачен намеренно: интерпретировать его имеет право только origin, который его выпустил. Клиент его не парсит, не рассуждает о нём — он просто хранит и возвращает. Эта непрозрачность и позволяет менять схему ETag (хеш → номер версии → mtime+size), не трогая ни один клиент.

ШагНаправлениеЗаголовок / статус
1. Первый запросклиент → серверGET /api/config
2. Origin делает отпечатоксервер → клиент200 OK + ETag: “a3f5c901” + тело
3. Перепроверкаклиент → серверIf-None-Match: “a3f5c901”
4a. Не изменилосьсервер → клиент304 Not Modified — пустое тело
4b. Изменилосьсервер → клиент200 OK + новый ETag + новое тело

Что 304 действительно экономит — и что нет

Вот число, которое решает, стоят ли ETag своих усилий: 304 экономит байты тела, но ты по-прежнему платишь полный round-trip. Клиент обязан отправить запрос и дождаться ответа, прежде чем узнает, что тело не изменилось. Если RTT до origin — 80 мс, каждый опрос всё равно стоит 80 мс задержки, неважно, 200 ответ или 304.

Значит, выигрыш — чисто egress и время загрузки. JSON-конфиг на 8 КБ, который вернул 304, отправляет ~200 байт заголовков ответа вместо 8 КБ — сокращение в 40 раз на проводе, помноженное на каждого опрашивающего клиента. Для картинки на 2 МБ, которая редко меняется, 304 экономит всю передачу в 2 МБ на каждой перепроверке. Чем больше и реже меняется ресурс, тем выгоднее сделка.

Но ETag не бесплатны для сервера. Чтобы ответить на условный запрос, надо знать текущий ETag, а это обычно означает чтение или пересчёт ресурса — хеширование файла, запрос строки, рендер ответа — только чтобы сравнить и выбросить тело. Это и есть основной трейдофф CPU-против-трафика: ты тратишь CPU origin, чтобы избежать сетевых байт. Если ETag — дешёвый lookup (хранимая колонка версии, mtime+size файла через stat), сделка очевидно хороша. Если это SHA-256 от тела на 2 МБ на каждый запрос, ты можешь сжечь больше CPU, чем стоил сэкономленный трафик.

Почему это работает

304 — это не cache hit в смысле CDN, а перепроверка. Ресурс всё равно идёт по проводу (только заголовки), всё равно занимает соединение и round-trip. Поэтому Cache-Control: max-age и ETag решают разные задачи: max-age позволяет клиенту вообще пропустить запрос, пока ресурс свежий; ETag — это то, на что он откатывается после истечения свежести, чтобы не перезагружать то, что уже есть. Они композируются — свежесть по max-age, затем перепроверка по ETag — а не заменяют друг друга.

Strong vs weak: побайтовая идентичность против семантической эквивалентности

ETag бывают двух «сил», и разница — в префиксе W/:

  • StrongETag: "a3f5c901" — обещает побайтовую идентичность. Два strong ETag совпадают, только если представления абсолютно равны, каждый байт. Strong-валидаторы обязательны для range-запросов (If-Range): если ты возобновляешь загрузку с байта 1 000 000, «семантически эквивалентно» недостаточно — нужны ровно те же байты.
  • WeakETag: W/"a3f5c901" — обещает только семантическую эквивалентность. Слабо помеченная страница может отличаться комментарием с таймстампом или рекламным слотом, но «достаточно та же», чтобы переиспользовать. Используй weak, когда вычислить точный по байтам тег дорого или когда тривиальные различия не должны рушить кэш.

Тонкость: If-None-Match всегда использует слабый алгоритм сравнения. Для перепроверки W/"x" и "x" считаются совпадением — префикс игнорируется. Различие strong/weak кусается только там, где обязательна побайтовая точность, то есть на практике — в range-запросах. Так что для обычного «изменилось ли это?» выбор weak vs strong редко меняет исход; для возобновляемых загрузок — меняет точно.

ETag vs Last-Modified: слепое пятно в одну секунду

До ETag был Last-Modified / If-Modified-Since, валидатор по таймстампу. Он по-прежнему работает и дешевле (просто stat файла), но у него жёсткое ограничение: HTTP-даты имеют разрешение в одну секунду. Если ресурс меняется дважды в течение одной секунды — обычное дело для горячего конфига, генерируемых файлов или часто пишущихся строк — Last-Modified не различит две версии, и клиент получит устаревший 304. У ETag этого слепого пятна нет: хеш контента или счётчик версии меняется на каждое реальное изменение независимо от тайминга.

Дефолт сеньора: когда присутствуют оба, ETag имеет приоритет, и клиенты должны предпочитать If-None-Match над If-Modified-Since. Многие серверы отдают оба, чтобы тупые кэши, понимающие только таймстампы, всё же получали какую-то перепроверку, а ETag-aware клиенты — точную. Цена отдачи обоих — одна лишняя строка заголовка.

ВалидаторРазрешениеСтоимость вычисленияКогда использовать
ETag (хеш контента)На каждое изменение, точноВысокая — читать/хешировать телоКонтент меняется чаще секунды; важна точность
ETag (версия/mtime+size)На каждое изменение, дешёвый проксиНизкая — lookup или statЕсть колонка версии или стабильные метаданные файла
Last-ModifiedОдна секундаНизкая — statСтатика, которая меняется редко

Почему ломается в проде: ETag на узел

Баг из Hook — каноничный провал ETag, и на нём стоит задержаться, потому что на одном сервере в dev он невидим. Контракт прост: один и тот же контент должен давать один и тот же ETag, неважно, какой сервер отвечает. Сломай это — и ни один клиент не увидит 304.

Три классических способа сломать, все из одного корня — ETag зависит от чего-то локального для узла, а не от контента:

  1. ETag на основе inode. Дефолтный FileETag в Apache исторически включал номер inode файла. У идентичных файлов, разложенных на три сервера, три разных inode — значит, три разных ETag. Apache 2.4 убрал inode из дефолта именно потому, что он отравлял кэширование в кластере; фикс — FileETag MTime Size (выводится из контента, идентичен везде).
  2. Дрейф таймстампа/mtime между репликами. Даже ETag на основе mtime ломается, если деплой копирует файлы на каждую реплику в чуть разное время по стенным часам, или если пересборка в CI трогает файлы. Те же байты, другой mtime, другой ETag.
  3. Состояние на процесс. Счётчик, случайная соль или таймстамп старта, запечённые в ETag, означают, что каждый под чеканит свой namespace — ровно провал из Hook.

И ещё один, который удивляет людей: сжатие меняет ETag, или должно. Gzip-версия и identity-версия ресурса — разные потоки байт, поэтому strong ETag обязан различаться между ними — иначе клиент, отправивший Accept-Encoding: gzip и получивший gzip-200, позже перепроверит, попадёт на узел, отдающий identity, и побайтовая семантика If-Range сломается. Некоторые прокси срезают или ослабляют ETag, когда сжимают на лету; если твой не делает этого, слой сжатия, добавленный между деплоями, может молча инвалидировать сразу все кэшированные валидаторы.

Выбери лучший вариант

JSON API за 4 подами отдаёт ответ на 6 КБ, который опрашивают каждые 15 с тысячи клиентов. Тело меняется несколько раз в день. Хочешь, чтобы перепроверка реально возвращала 304. Выбери стратегию ETag.

Викторина

Клиент шлёт If-None-Match, и ресурс не изменился. Что отправляет сервер и что он сэкономил?

Викторина

Ты масштабировал API с 1 до 3 подов, и перепроверка перестала возвращать 304. Какая самая вероятная причина?

Расставь шаги по порядку

Расставь успешный обмен условным GET, где ресурс не менялся:

  1. 1 Первый запрос: клиент делает GET ресурса без валидатора
  2. 2 Сервер отвечает 200 OK с телом и заголовком ETag
  3. 3 Клиент кэширует тело и сохраняет ETag рядом с ним
  4. 4 Позже: клиент перезапрашивает с If-None-Match, равным сохранённому ETag
  5. 5 Сервер пересчитывает ETag, видит совпадение, отвечает 304 с пустым телом
  6. 6 Клиент отдаёт кэшированную копию — по проводу прошли только заголовки
Вспомните перед уходом
  1. 01
    Коллега говорит: «ETag делают повторные запросы бесплатными». Поправь его точно: что 304 реально экономит и что всё равно стоит?
  2. 02
    Почему добавление второго и третьего сервера часто убивает 304, и как сделать ETag безопасными для балансировщика?
Итог

Условный запрос — это соглашение о версии: сервер делает отпечаток ресурса в виде непрозрачного ETag, клиент возвращает его в If-None-Match, и совпадение даёт 304 Not Modified с пустым телом. Это экономит байты тела — сокращение в 40 раз на маленьком JSON, всю передачу на большой картинке — но никогда не round-trip, потому что клиенту всё равно надо спросить и подождать; ETag — оптимизация трафика, цена которой платится в CPU origin на пересчёт или чтение ресурса только ради сравнения. Strong ETag обещают побайтовую идентичность (обязательно для range-запросов); weak (W/) обещают лишь семантическую эквивалентность, а If-None-Match сравнивает слабо в любом случае, так что различие важно в основном для возобновляемых загрузок. Против Last-Modified ETag выигрывают в точности — у таймстампов слепое пятно в одну секунду — ценой большей стоимости вычисления. Провал, который кусается в проде, — это ETag на узел: теги на основе inode, дрейф mtime между репликами, счётчики на процесс или сжатие, меняющее байты, заставляют один и тот же контент хешироваться по-разному на разных серверах, так что за балансировщиком ни один клиент никогда не совпадает и ты шлёшь полные 200 навсегда, считая, что кэширование включено. Держи ETag чистой функцией контента — и каждый узел согласуется.

Продолжить восхождение ↑ETag: тест с выбором ответа
хоткеи развернуть
поиск
K
пред. пьеса
k
след. пьеса
j
тиры
t
это меню
?
sources4
expand
  1. 01
  2. 02
  3. 03
  4. 04

Trademarks belong to their respective owners. Editorial reference only.