Суть Читаем реальные cache-aside сниппеты — lock-on-miss, request coalescing, XFetch — предсказываем поведение и выбираем фикс с наибольшим рычагом.
Высота — путь к senior
НольJuniorMiddleSenior
Ты на senior-высоте — в орбите
◷ 14 min
Баги stampede прячутся в самом cache-aside коде: lock с неправильным EX, коалесцер, регистрирующий in-flight промис на строку позже нужного, правило XFetch, срабатывающее слишком рано. Читайте каждый сниппет так, как читали бы на ревью, и выбирайте фикс, который сеньор делает первым.
Цель
Потренируйтесь читать сам код митигаций — lock-on-miss, request coalescing и probabilistic early expiration — и находить дефект, который рано или поздно вскроет нагрузочный тест.
Сниппет 1 — lock-on-miss
func get(ctx context.Context, key string) ([]byte, error) { if v, ok := cache.Get(key); ok { return v, nil } // miss: try to become the rebuilder locked := redis.SetNX(ctx, "lock:"+key, uuid, 30*time.Second).Val() if !locked { // someone else is rebuilding return rebuild(ctx, key) // <-- rebuild anyway } v, err := rebuild(ctx, key) if err == nil { cache.Set(key, v, 60*time.Second) redis.Del(ctx, "lock:"+key) } return v, nil}
Викторина
Completed
Lock через SetNX берётся корректно, но нагрузочный тест всё равно даёт N одновременных rebuild'ов в БД. Где баг?
Heads-up Большой EX рискует задержкой из-за зависшего lock при краше, но не вызывает N одновременных rebuild'ов. Дефект в том, что проигравшие lock всё равно делают rebuild вместо ожидания.
Heads-up SET ... NX в Redis атомарен — выигрывает ровно один. Отказ в том, что делают проигравшие, а не в захвате.
Heads-up Set-затем-Del — правильный порядок. Перестановка не меняет того, что каждый проигравший lock всё равно зовёт rebuild и заливает БД.
Сниппет 2 — request coalescing
const inflight = new Map(); // key -> Promiseasync function getCoalesced(key) { const cached = await cache.get(key); if (cached !== null) return cached; const fresh = await rebuild(key); // (A) await the rebuild... inflight.set(key, fresh); // (B) ...then record it const value = await fresh; cache.set(key, value, 60); inflight.delete(key); return value;}
Викторина
Completed
Этот код должен коалесцировать одновременные промахи по одному ключу в один rebuild, но не коалесцирует никогда. Что не так?
Heads-up JS однопоточен для этого кода; доступ к Map между await безопасен. Баг в том, что промис регистрируется после await, а карта не читается на входе.
Heads-up Отсутствие джиттера — отдельная многоключевая проблема. Этот сниппет не коалесцирует один ключ из-за await-до-регистрации и отсутствия проверки карты.
Heads-up Delete выполняется после кеширования значения, что верно. Реальный дефект — порядок await-до-регистрации, полностью убивающий коалесцинг.
Сниппет 3 — probabilistic early expiration (XFetch)
Оператор хочет меньше лишних ранних rebuild'ов на тёплых ключах и поднимает beta с 1.0 до 4.0. Каков эффект на горячем ключе против более холодного?
Heads-up Всё наоборот: больший beta умножает левую часть, заставляя её превысить ttl_remaining раньше, так что окно открывается раньше и срабатывает чаще.
Heads-up Оба линейно масштабируют левую часть, так что beta напрямую управляет тем, насколько рано открывается окно. Меняя его, меняем момент срабатывания.
Heads-up Поведение обычного TTL — это НИЗКИЙ-beta предел, где окно схлопывается к границе. Большой beta обновляет раньше и чаще.
Сниппет 4 — запись с fencing-token
def rebuild_and_write(key, my_token): value = rebuild(key) # may take longer than the lock EX if redis.get("lock:" + key) != my_token: return # we lost the lock — abort the write redis.set("cache:" + key, value, ex=60)
Викторина
Completed
Проверка fencing 'GET lock, затем SET cache' защищает от медленного rebuild, пережившего свой lock. Какая гонка остаётся и что её закрывает?
Heads-up Проверка — это два отдельных round-trip. Lock может истечь в зазоре, поэтому запись должна быть атомарной или нести монотонную версию, чтобы быть свободной от гонок.
Heads-up Обработка типов — тривиальная деталь; считаем её корректной. Реальная остаточная гонка — неатомарное окно между чтением токена и записью.
Heads-up TTL кеша не связан с гонкой lock. Остаточный дефект — неатомарный зазор между чтением токена и записью значения.
Итог
Каждая защита от stampede живёт в коде, который легко тонко испортить: lock помогает, только если проигравшие ждут и перечитывают, а не делают rebuild всё равно; коалесцер помогает, только если in-flight промис регистрируется синхронно до любого await и читается на входе; beta в XFetch двигает окно раннего обновления в сторону, противоположную интуиции большинства (больший beta — раньше и чаще); а проверка fencing-token безопасна, только когда чтение-затем-запись атомарны или подкреплены монотонной версией. Прочитайте митигацию, проведите через неё два конкурентных вызова — и баг обычно проявит себя ещё до любого нагрузочного теста.