Суть Читай реальные cache-aside-сниппеты — баг в дизайне ключа, purge, гоняющийся с читателем, и стампида на фиксированном TTL — и выбирай фикс наивысшего рычага, который сеньор делает первым.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги инвалидации прячутся в строке cache key и в порядке двух строк на пути записи. Прочитай каждый сниппет, найди открытый им разрыв несвежести и выбери фикс, который сеньор сделает первым.
Цель
Отработай цикл, который ты запускаешь в каждом инциденте консистентности кэша: прочитать дизайн ключа и путь записи, предсказать, где протекает устаревшее, и потянуться за фиксом наивысшего рычага — а не за самым большим молотком.
Сниппет 1 — cache key
// GET /products?category=shoes&sort=price&page=2&utm_source=newsletterfunction cacheKey(req) { return "products:" + req.url; // весь URL, со строкой запроса целиком}async function handler(req, res) { const k = cacheKey(req); let body = await redis.get(k); if (!body) { body = await db.queryProducts(req.query); await redis.set(k, body, "EX", 300); } res.send(body);}
Викторина
Completed
Каталог товаров меняется редко, но hit rate почти ноль, а origin занят. Что не так с этим ключом и какой фикс?
Heads-up Более длинный TTL помог бы чуть-чуть, но настоящий дефект — фрагментация ключа: идентичные результаты кэшируются под тысячами различных ключей, поэтому почти каждый запрос промахивается независимо от TTL.
Heads-up Префикс-пространство имён — хорошая практика и не проблема. Фрагментация идёт от вмешивания трекингового/порядкового шума в ключ, а не от префикса.
Heads-up Пайплайнинг — микрооптимизация пропускной способности. Он ничего не делает с hit rate, убитым кодированием нерелевантных параметров запроса в cache key.
Сниппет 2 — purge на записи
async function updateUser(id, patch) { const row = await db.users.update(id, patch); // БД теперь свежая await redis.del("user:" + id); // сбрасываем запись кэша return row;}// в другом месте, путь чтения (cache-aside):async function getUser(id) { const k = "user:" + id; let u = await redis.get(k); if (!u) { u = await db.users.find(id); // может прочитать строку до обновления await redis.set(k, u, "EX", 3600); // ...и записать её обратно } return u;}
Викторина
Completed
Это проходит все локальные тесты, но в продакшене откатывает правки пользователя до часа. Дефект и самый дешёвый production-патч?
Heads-up Перестановка не помогает: читатель всё равно может промахнуться после DEL и до коммита, прочитать старую строку и перезаполнить. Окно — между чтением читателя и его SET, и перенос DEL его не закрывает.
Heads-up Более короткий TTL лишь сжимает, как долго устаревшее значение выживает — он не мешает гоняющемуся читателю его записать. Фикс целит в гонку (double-delete или лизы), а не в страховку.
Heads-up Retry на DEL адресует отдельный сбой dual-write, а не эту гонку. Даже успешный DEL отменяется поздним SET читателя, поэтому один retry оставляет баг на месте.
Сниппет 3 — TTL
const TTL = 600; // 10 минут, одинаково для каждой записиasync function cacheConfig(tenantId) { const k = "config:" + tenantId; let cfg = await redis.get(k); if (!cfg) { cfg = await db.loadConfig(tenantId); // дорого: ~400 мс, джоин 5 таблиц await redis.set(k, cfg, "EX", TTL); } return cfg;}
Викторина
Completed
После выкатки на весь флот, прогревшей конфиги многих тенантов разом, p99 латентности резко скачет каждые 10 минут. Что происходит и первый фикс?
Heads-up Стоимость джоина реальна, но вторична — периодический скачок p99 гонит синхронизированное истечение, стампидящее origin. Jitter и single-flight убирают скачок без изменения схемы.
Heads-up Вытеснение не периодично так; 10-минутная периодичность точно совпадает с фиксированным TTL. Это синхронизированное истечение, чинимое jitter, а не сменой политики вытеснения.
Heads-up Более длинный фиксированный TTL лишь растягивает интервал между идентичными синхронными стампидами — скачок так же резок, когда срабатывает. Рассеивание истечений через jitter — вот что убирает скачок.
Итог
Три классических бага инвалидации, все прочитанные прямо из кода: cache key, вмешивающий нерелевантные параметры, разносит hit rate, поэтому строй ключи только из влияющих на ответ параметров, нормализованных; delete-on-write гоняется с перезаполняющим SET параллельного читателя, дешевле всего патчится delayed double-delete (лизы или write-through для сильного фикса); а фиксированный TTL, прогретый в одном окне, стампидит origin на каждом истечении, гасится jitter плюс single-flight. Сначала прочитай ключ и путь записи, назови разрыв, затем выбери наименьший фикс, который его закрывает.