Суть Читай реальные заголовки, хэндлеры и single-flight lock поперёк стека кэширования, предсказывай поведение и выбирай исправление с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги кэширования читаются в заголовках, хэндлерах и логах, а не в прозе. Прочитай каждый сниппет, предскажи, как поведут себя уровни под реальной нагрузкой, затем выбери исправление, которое сениор сделает первым.
Цель
Отработай цикл, который ты запускаешь на каждом инциденте кэширования: прочитай директиву или код на горячем пути, предскажи, где каждый уровень кэширует или утекает, и возьмись за изменение, чинящее композицию — а не более громкий TTL.
Это публичное API прайс-листа, которое команда хочет кэшировать на edge CDN, часто обновлять и всё равно отдавать, если origin ляжет.
Викторина
Completed
Что этот заголовок реально делает против того, что хочет команда, и какое исправление с наибольшим рычагом?
Heads-up Разделяемые кэши предпочитают s-maxage, когда он есть; max-age без s-maxage применяется к обоим. Важнее, что нет SWR или stale-if-error, поэтому требования команды о мягком обновлении и выживании сбоя не выполнены.
Heads-up no-cache форсирует ревалидацию на каждом запросе, убирая разгрузку edge, которую команда явно хочет, и добавляя round-trip на попадание. Это противоречит цели кэшировать на edge.
Heads-up Меньший max-age обновляет чаще, но всё равно не даёт разделения edge/браузер, мягкого истечения и fallback на сбой. Он тюнит одно число и оставляет два требования невыполненными.
Сниппет 2 — хэндлер условного GET
app.get("/articles/:id", async (req, res) => { const article = await db.getArticle(req.params.id); const etag = `"${article.version}"`; res.setHeader("ETag", etag); res.setHeader("Cache-Control", "private, max-age=0, must-revalidate"); // всегда шлёт полное тело res.json(article);});
Викторина
Completed
Хэндлер ставит ETag, но оптимизация условного запроса никогда не срабатывает. Чего не хватает и в чём выигрыш после исправления?
Heads-up Закавыченная строка версии — валидный strong ETag. Дефект на стороне сервера: хэндлер никогда не смотрит If-None-Match, поэтому не может выдать 304 независимо от формата ETag.
Heads-up must-revalidate говорит кэшу ревалидировать, когда устарело — именно он триггерит условный запрос. Недостающая часть — серверная проверка If-None-Match, превращающая этот запрос в 304.
Heads-up Установка заголовка ETag не генерирует 304 автоматически, если conditional-GET middleware фреймворка не отработает до res.json. Здесь тело шлётся безусловно, поэтому 304 не производится никогда.
Сниппет 3 — stampede lock
async function getHot(key) { let val = await cache.get(key); if (val !== null) return val; // холодно: каждый параллельный вызывающий доходит сюда и пересчитывает val = await expensiveQuery(key); // бьёт в БД await cache.set(key, val, { ttl: 60 }); return val;}
Викторина
Completed
Горячий ключ истёк, и 5000 запросов приходят в одну секунду. Что происходит и в чём single-flight исправление?
Heads-up cache.set у первого вызывающего не блокирует со второго по 5000-й — они уже прошли проверку на null и параллельно выполняют свой expensiveQuery. Ничто не сериализует их без явного lock.
Heads-up Запись только что истекла — это и есть триггер. TTL управляет тем, сколько живёт пересчитанное значение, а не тем, сколько параллельных промахов наваливается в момент истечения.
Heads-up Более длинный TTL делает истечения реже, но dogpile так же тяжёл всякий раз, когда оно случается. Single-flight (или вероятностное раннее истечение, или SWR) — то, что ограничивает параллельный пересчёт.
Ответ закэширован в 12:00:00. Запросы приходят в 12:00:30, 12:01:30 и 12:07:00. Записи или purge не было.
Викторина
Completed
Что разделяемый кэш отдаёт в каждый из трёх моментов?
Heads-up s-maxage отсчитывает возраст с момента сохранения ответа, а не с последнего доступа. Только запрос 12:00:30 в пределах окна свежести 60с.
Heads-up Смысл stale-while-revalidate именно в том, что устаревшая запись отдаётся немедленно, а обновление идёт в фоне — читатель 12:01:30 не блокируется.
Heads-up После окна SWR кэш не вытесняет-и-ошибается; он делает синхронную (блокирующую) ревалидацию против origin и откатывается на устаревшее только если stale-if-error задан и origin падает.
Итог
Читай сверху вниз: заголовок Cache-Control должен разделять edge и браузер (s-maxage против max-age) и нести SWR плюс stale-if-error, чтобы обновляться мягко и пережить сбой; ETag инертен, пока сервер не читает If-None-Match и не отвечает 304; холодный горячий ключ требует single-flight, чтобы 5000 промахов стали одним запросом к БД; а два окна SWR решают, получит ли читатель свежее попадание, мгновенно-устаревшее-с-фоновым-обновлением или блокирующую ревалидацию. Диагностируй по заголовку и логу, чини композицию, затем перепроверь под той же нагрузкой.