Cache stampede: чтение кода
Читаем реальные cache-aside сниппеты — lock-on-miss, request coalescing, XFetch — предсказываем поведение и выбираем фикс с наибольшим рычагом.
Баги 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
}
// промах: пробуем стать пересчётчиком
locked := redis.SetNX(ctx, "lock:"+key, uuid, 30*time.Second).Val()
if !locked {
// кто-то другой пересчитывает
return rebuild(ctx, key) // <-- пересчёт всё равно
}
v, err := rebuild(ctx, key)
if err == nil {
cache.Set(key, v, 60*time.Second)
redis.Del(ctx, "lock:"+key)
}
return v, nil
}
Lock через SetNX берётся корректно, но нагрузочный тест всё равно даёт N одновременных rebuild'ов в БД. Где баг?
Сниппет 2 — request coalescing
const inflight = new Map(); // ключ -> Promise
async function getCoalesced(key) {
const cached = await cache.get(key);
if (cached !== null) return cached;
const fresh = await rebuild(key); // (A) ждём rebuild...
inflight.set(key, fresh); // (B) ...только потом регистрируем
const value = await fresh;
cache.set(key, value, 60);
inflight.delete(key);
return value;
}
Этот код должен коалесцировать одновременные промахи по одному ключу в один rebuild, но не коалесцирует никогда. Что не так?
Сниппет 3 — probabilistic early expiration (XFetch)
def should_refresh(delta, beta, ttl_remaining):
# delta = типичное время пересчёта (с), ttl_remaining = секунд до истечения
return (-beta * delta * math.log(random.random())) >= ttl_remaining
Оператор хочет меньше лишних ранних rebuild'ов на тёплых ключах и поднимает beta с 1.0 до 4.0. Каков эффект на горячем ключе против более холодного?
Сниппет 4 — запись с fencing-token
def rebuild_and_write(key, my_token):
value = rebuild(key) # может занять дольше, чем EX блокировки
if redis.get("lock:" + key) != my_token:
return # потеряли блокировку — отменяем запись
redis.set("cache:" + key, value, ex=60)
Проверка fencing 'GET lock, затем SET cache' защищает от медленного rebuild, пережившего свой lock. Какая гонка остаётся и что её закрывает?
Каждая защита от stampede живёт в коде, который легко тонко испортить: lock помогает, только если проигравшие ждут и перечитывают, а не делают rebuild всё равно; коалесцер помогает, только если in-flight промис регистрируется синхронно до любого await и читается на входе; beta в XFetch двигает окно раннего обновления в сторону, противоположную интуиции большинства (больший beta — раньше и чаще); а проверка fencing-token безопасна, только когда чтение-затем-запись атомарны или подкреплены монотонной версией. Прочитайте митигацию, проведите через неё два конкурентных вызова — и баг обычно проявит себя ещё до любого нагрузочного теста.
Что-то непонятно?
Задай вопрос по этому уроку. Вопросы анонимны и попадают напрямую автору — урок станет лучше.