Кеширование
ETag и условные запросы: 304 экономит байты, но не round-trip
Дашборд опрашивает /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/:
- Strong —
ETag: "a3f5c901"— обещает побайтовую идентичность. Два strong ETag совпадают, только если представления абсолютно равны, каждый байт. Strong-валидаторы обязательны для range-запросов (If-Range): если ты возобновляешь загрузку с байта 1 000 000, «семантически эквивалентно» недостаточно — нужны ровно те же байты. - Weak —
ETag: 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 зависит от чего-то локального для узла, а не от контента:
- ETag на основе inode. Дефолтный
FileETagв Apache исторически включал номер inode файла. У идентичных файлов, разложенных на три сервера, три разных inode — значит, три разных ETag. Apache 2.4 убрал inode из дефолта именно потому, что он отравлял кэширование в кластере; фикс —FileETag MTime Size(выводится из контента, идентичен везде). - Дрейф таймстампа/mtime между репликами. Даже ETag на основе mtime ломается, если деплой копирует файлы на каждую реплику в чуть разное время по стенным часам, или если пересборка в CI трогает файлы. Те же байты, другой mtime, другой ETag.
- Состояние на процесс. Счётчик, случайная соль или таймстамп старта, запечённые в 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 Первый запрос: клиент делает GET ресурса без валидатора
- 2 Сервер отвечает 200 OK с телом и заголовком ETag
- 3 Клиент кэширует тело и сохраняет ETag рядом с ним
- 4 Позже: клиент перезапрашивает с If-None-Match, равным сохранённому ETag
- 5 Сервер пересчитывает ETag, видит совпадение, отвечает 304 с пустым телом
- 6 Клиент отдаёт кэшированную копию — по проводу прошли только заголовки
- 01Коллега говорит: «ETag делают повторные запросы бесплатными». Поправь его точно: что 304 реально экономит и что всё равно стоит?
- 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 чистой функцией контента — и каждый узел согласуется.