Кеширование
Dogpile: чтение кода и блокировок
Dogpile живёт в пути пересчёта: обработчик промаха, захват блокировки, TTL, освобождение. Прочитай каждый сниппет, найди, где коллизия открывается снова или блокировка дедлочит, и выбери фикс, который senior сделал бы первым.
Потренируй чтение кода коалесинга и блокировок так, как читаешь его в инциденте — проследи конкурентный путь, заметь пропущенный таймаут, потерянный lease или незафенсенную запись, и чини механизм, а не симптом.
Сниппет 1 — локальный single-flight
var group singleflight.Group
func getFeed(ctx context.Context, key string) ([]byte, error) {
if v, ok := cache.Get(key); ok {
return v, nil
}
// коалесим одновременные промахи по ключу на один пересчёт
v, err, _ := group.Do(key, func() (any, error) {
out, err := recomputeFeed(ctx) // 200ms-агрегация в БД
if err != nil {
return nil, err
}
cache.Set(key, out, 5*time.Minute)
return out, nil
})
if err != nil {
return nil, err
}
return v.([]byte), nil
}
Это работает на 20 инстансах за балансировщиком. Сколько пересчётов в БД произойдёт на момент истечения и какое изменение с наибольшим рычагом приведёт к одному?
Сниппет 2 — distributed-блокировка без TTL
func getFeedLocked(ctx context.Context, key string) ([]byte, error) {
if v, ok := cache.Get(key); ok {
return v, nil
}
lockKey := "lock:" + key
// захват: SET lock:key <token> NX (БЕЗ аргумента истечения)
ok, _ := rdb.SetNX(ctx, lockKey, token, 0).Result()
if !ok {
time.Sleep(50 * time.Millisecond) // кто-то другой пересчитывает
return getFeedLocked(ctx, key) // повтор чтения
}
defer rdb.Del(ctx, lockKey) // освобождение при выходе
out, err := recomputeFeed(ctx)
if err != nil {
return nil, err
}
cache.Set(key, out, 5*time.Minute)
return out, nil
}
SetNX использует истечение 0 (без TTL); освобождение через defer Del. Какой production-сбой и какой фикс?
Сниппет 3 — фиксированный TTL, слишком короткий для хвоста
const lockTTL = 5 * time.Second // пересчёт p50 ~2s, p99 ~25s
func recomputeUnderLock(ctx context.Context, key, token string) error {
ok, _ := rdb.SetNX(ctx, "lock:"+key, token, lockTTL).Result()
if !ok {
return errLockHeld // вызывающий отдаёт stale и ретраит позже
}
out, err := recomputeFeed(ctx) // до 25s на холодном шарде
if err != nil {
return err
}
// блокировка тут уже могла истечь на медленном пересчёте
cache.Set(key, out, 5*time.Minute)
rdb.Del(ctx, "lock:"+key) // безусловное удаление
return nil
}
С TTL блокировки 5s и пересчётом p99 25s назови оба бага и принципиальный фикс.
Сниппет 4 — держатель, продлевающий lease
func recomputeWithLease(ctx context.Context, key, token string) error {
if ok, _ := rdb.SetNX(ctx, "lock:"+key, token, 10*time.Second).Result(); !ok {
return errLockHeld
}
// heartbeat: продлеваем lease каждые 3s, пока работаем
stop := make(chan struct{})
go func() {
t := time.NewTicker(3 * time.Second)
defer t.Stop()
for {
select {
case <-stop:
return
case <-t.C:
// PEXPIRE lock:key 10000, если токен ещё наш
renewIfOwner(ctx, "lock:"+key, token, 10*time.Second)
}
}
}()
out, err := recomputeFeed(ctx)
close(stop)
if err != nil {
return err
}
cache.Set(key, out, 5*time.Minute)
releaseIfOwner(ctx, "lock:"+key, token) // CAS-удаление
return nil
}
Lease продлевается каждые 3s при TTL 10s и освобождается через CAS-по-токену. Длинная stop-the-world GC-пауза (≥10s) бьёт по этому воркеру в середине пересчёта. Что всё ещё может пойти не так и что от этого защищает?
Каждый фикс dogpile читается в пути блокировки. Локальный single-flight капит пересчёты числом инстансов, а не одним — distributed lock координирует флот. Блокировка без TTL дедлочит всех читателей, если держатель умер, поэтому всегда SET NX PX. Фиксированный TTL короче худшего пересчёта открывает стадо снова и рискует удалить чужую блокировку, поэтому продлевай lease по heartbeat и освобождай условно по токену. И даже продлеваемый lease может лапситься при длинной STW-паузе или partition, поэтому фенси запись монотонным токеном (или версионным CAS), чтобы возобновившийся держатель не перезаписал свежие данные. Чини жизненный цикл блокировки, потом перепроверь под сценарием медленного пересчёта и инъекции паузы.