Модуль 5. Cache-Aside: деревья файлов в Redis (go-redis)

Цель модуля: Снять random-read нагрузку с диска при просмотре файлов в UI. Применить Cache-Aside + Stale-While-Revalidate (arch m5) к Git-данным (git ls-tree), защититься от Cache Stampede через SETNX-лок на go-redis, и связать инвалидацию кэша с событием RepositoryPushed из Outbox (arch m5 + m3). Главное отличие от Symfony-модуля — фоновой refresh запускается нативной горутиной go refresh(id), без отдельного Messenger worker.

Перед началом: Arch m3 (Outbox), m5 (Cache-Aside, SWR) · Git-hosting-go модули 1–4 Инструменты: Redis, github.com/redis/go-redis/v9, os/exec, k6, git ls-tree Трудоёмкость: ~6 ч (теория 1.5 ч · практика 4 ч · ADR 0.5 ч)

🔥 Инцидент: папка src/, 1000 одновременных просмотров

Популярный open-source репозиторий. 1000 пользователей одновременно открывают папку src/ в UI. Каждый запрос → git ls-tree HEAD src/ → random-read с диска. IOPS упираются в потолок NVMe, дисковая очередь растёт, p99 = 4 секунды — UI «зависает» при банальном просмотре дерева.

Это классический Cache Stampede (arch m5): для каждого просмотра данных в кэше нет, и 1000 запросов одновременно идут к диску. Добавить «просто кэш с TTL» недостаточно — при протухании ключа stampede повторится. Нужны атомарный лок на время холодного старта и инвалидация по событию push.

Те же паттерны, что в arch m5, но для Git-данных на Go: ключ repo:{id}:tree:{sha} immutable (SHA не меняется), а push чреват устареванием дерева — значит, инвалидация через RepositoryPushed из Outbox (модуль 4). Фоновой refresh — нативной горутиной, без Messenger.

📖 Теория: Раскрытые термины и скрытые риски

Глоссарий модуля:

  • Cache-Aside — приложение само читает кэш, при Cache Miss идёт к источнику (git ls-tree) и заполняет кэш.
  • Cache Stampede (Dogpile) — массовый одновременный промах по протухшему/отсутствующему ключу → лавина запросов к диску/БД.
  • Stale-While-Revalidate (SWR) — отдавать stale-данные сразу, кэш обновлять в фоне одним воркером.
  • SETNX-лок — rdb.SetNX(ctx, lockKey, 1, 5*time.Second): первый запрос идёт к диску, остальные ждут (защита от stampede).
  • Delayed double-delete — повторный DEL после паузы против гонки stale-after-invalidate (arch m5).
  • Immutable SHA-ключ — дерево по фиксированному SHA никогда не меняется → можно кэшировать надолго / на Nginx.

5.1. Cache-Aside: git ls-tree за Redis

Ключ repo:{id}:tree:{sha} → результат git ls-tree {sha} <path>. Поскольку SHA фиксирует состояние дерева, значение immutable для данного ключа: его можно кэшировать надолго, а на уровне Nginx — пометить как cacheable indefinitely. На Go используем github.com/redis/go-redis/v9: один клиент на сервис (*redis.Client), пул соединений настраивается через PoolSize.

5.2. Защита от Cache Stampede (связь с arch m5)

Холодный старт: rdb.SetNX(ctx, lockKey, 1, 5*time.Second). Первый запрос берёт лок, идёт к диску (exec.Command("git", "ls-tree", sha, path).Output()), заполняет кэш; остальные 999 на короткой паузе (time.Sleep(50*time.Millisecond)) перечитывают кэш. При наличии stale-данных — отдаём их сразу (SWR) и запускаем фоновое обновление горутиной go r.refresh(repoID). Результат: при 1000 параллельных просмотрах к диску уходит ровно 1 read.

5.3. Инвалидация через Outbox (связь с arch m5 + m3)

Push меняет refs, дерево по старому SHA остаётся immutable, но HEAD/branch-дерево устаревает. Сценарий инвалидации:

  1. Push-хендлер уже пишет RepositoryPushed в Outbox (модуль 4) — в одной транзакции с метаданными.
  2. Консьюмер (горутина, читающая outbox_events через FOR UPDATE SKIP LOCKED) ловит событие и делает rdb.Del(ctx, "repo:"+id+":tree:branch:*") — после коммита, без Dual-Write.
  3. Delayed double-delete: повторный DEL после паузы (time.AfterFunc(500*time.Millisecond, ...)) против гонки (читатель мог загрузить старое значение до коммита).

5.4. SWR: нативная горутина (главное отличие от Symfony)

В Symfony-курсе фоновое обновление — это bus->dispatch(new RefreshTreeCache(...)) через Messenger worker. В Go — go r.refresh(repoID) прямо в обработчике Cache Miss: горутина стартует за микросекунды, без брокера и worker-процесса. Это не «лучше» архитектурно (тот же паттерн SWR из arch m5), а проще в инфраструктуре: нет Symfony Messenger, нет Redis-Streams-консьюмера для refresh. Цена — monitoring горутин (runtime.NumGoroutine(), pprof goroutine leak check) и контекст с таймаутом ctx, cancel := context.WithTimeout(...).

5.5. Компромиссы (Trade-offs)

ПодходПлюсМинусКогда выбирать
Cache-Aside + SWR + SETNX1 disk read при stampede; ≥ 90% hit; низкая p99stale до обновления фонаread-heavy просмотр файлов
go-redis SETNXатомарный лок без extra-зависимостейне distributed lock (multi-node — нужен redsync)single-node или sticky-маршрутизация
redsync (distributed lock)работает на кластере Redis, безопасен при failover+зависимость, +latency на quorummulti-node при hot-ключах
sync.Map (in-process)нет сетевого хопа, наносекундыне шарится между подами, теряется при рестартеsingle-node, низкий cardinality
Нативная горутина для refresh0 инфраструктуры, мгновенный стартno durability (падение процесса = потеря refresh)SWR, где допустимо потерять одно обновление

Вывод: Cache-Aside + SWR + SETNX на go-redis; фоновое обновление — нативной горутиной (вместо Messenger); инвалидация branch-head по RepositoryPushed из Outbox + delayed double-delete.


🛠 Практические задания

Сценарий: 1000 одновременных просмотров папки src/ популярного репозитория. Цель: 1 disk read, p99 < 100 мс.

План:

  1. CachedTreeReader: Redis → exec.Command("git", "ls-tree", ...) → cache.
  2. SETNX-лок на холодный старт; SWR при наличии stale.
  3. Консьюмер RepositoryPushed: rdb.Del + delayed double-delete через time.AfterFunc.
  4. k6: 1000 параллельных → 1 disk read, ≥ 90% Cache Hit.
  5. ADR-GG5 «Cache-Aside для tree + SWR горутиной».

Задача 1: CachedTreeReader (Cache-Aside + SETNX)

go
func (r *CachedTreeReader) ReadTree(ctx context.Context, repoID int64, sha, path string) ([]Entry, error) {
    key := fmt.Sprintf("repo:%d:tree:%s", repoID, sha)
    if cached, err := r.rdb.Get(ctx, key).Bytes(); err == nil {
        return decodeEntries(cached) // Cache Hit
    }
    // холодный старт: атомарный лок, в диск идёт один запрос
    lockKey := "lock:" + key
    ok, err := r.rdb.SetNX(ctx, lockKey, 1, 5*time.Second).Result()
    if err != nil { return nil, err }
    if !ok {
        time.Sleep(50 * time.Millisecond) // другой воркер обновляет
        return r.ReadTree(ctx, repoID, sha, path) // перечитываем кэш
    }
    defer r.rdb.Del(ctx, lockKey)
    tree, err := r.git.LsTree(ctx, sha, path) // единственный disk read
    if err != nil { return nil, err }
    r.rdb.Set(ctx, key, encodeEntries(tree), 10*time.Minute)
    return tree, nil
}

✅ Готово, когда:

  • Cache Hit — отдаётся из Redis
  • Холодный старт: SetNX — ровно 1 disk read
  • Остальные 999 ждут (50 мс) и перечитывают кэш

Задача 2: SWR для branch-head

Для tree/{branch} (не immutable): при наличии stale-данных (rdb.Get есть, но updated_at старый) — отдать сразу и запустить фоновое обновление горутиной.

go
if stale, _ := r.rdb.Get(ctx, key).Bytes(); stale != nil {
    go r.refresh(context.Background(), repoID, branch) // нативная горутина, не блокируем
    return decodeEntries(stale)
}
// ...Cache Miss → холодный старт через SETNX

✅ Готово, когда:

  • Stale-данные отдаются сразу
  • Фоновое обновление запускается горутиной (одной, под SETNX-локом — иначе stampede в refresh)
  • У горутины есть context.WithTimeout (нет «утечки» зависшего refresh)

Задача 3: Инвалидация через Outbox

Консьюмер RepositoryPushed (читает Outbox через FOR UPDATE SKIP LOCKED, модуль 4) делает DEL repo:{id}:tree:branch:* (branch-head ключи) + delayed double-delete.

go
func (c *OutboxConsumer) onRepositoryPushed(ctx context.Context, e RepositoryPushed) error {
    pattern := fmt.Sprintf("repo:%d:tree:branch:*", e.RepoID)
    iter := c.rdb.Scan(ctx, 0, pattern, 100).Iterator() // ❌ KEYS — блокирует Redis
    for iter.Next(ctx) { c.rdb.Del(ctx, iter.Val()) } // первый DEL после коммита
    return nil
}, nil, &redis.ZRangeArgs{...} // или Lua-скрипт
// delayed double-delete:
time.AfterFunc(500*time.Millisecond, func() {
    ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
    defer cancel()
    c.rdb.Del(ctx, /* branch-keys repeat */)
})

✅ Готово, когда:

  • DEL выполняется после коммита (через событие Outbox, не в хендлере push)
  • Delayed double-delete: повторный DEL после паузы (arch m5), через time.AfterFunc
  • Используется SCAN (не KEYS, который блокирует Redis на больших кэшах)

Задача 4: Тест k6

1000 параллельных запросов на один ключ после очистки кэша. SLO: к диску уходит 1 read, ≥ 90% Cache Hit, p99 < 100 мс.

✅ Готово, когда:

  • 1000 параллельных → 1 disk read (проверяется метрикой git_ltree_calls_total)
  • ≥ 90% Cache Hit; p99 < 100 мс
  • runtime.NumGoroutine() стабилен (нет утечки refresh-горутин)

⚠️ Антипаттерн

go
tree, err := r.rdb.Get(ctx, key).Bytes()
if err == redis.Nil {
    // ❌ Нет SETNX: 1000 запросов одновременно пойдут к диску (Cache Stampede)
    tree, _ = r.git.LsTree(ctx, sha, path)
    r.rdb.Set(ctx, key, encode(tree), 5*time.Second) // ❌ короткий TTL, риск протухания
}

Вопрос: Что будет с диском, если ключ протух под 1000 RPS без SETNX?

🧩 Проверь себя

1. Cache Stampede при просмотре дерева — это…
2. Как инвалидировать branch-head кэш после push на Go?
3. SWR (Stale-While-Revalidate) на Go реализуется через…
4. Зачем SetNX-лок при холодном старте ключа на go-redis?
5. Почему ключ repo:{id}:tree:{sha} immutable?

🔗 Мостик к Модулю 6 (PR Saga)

Чтение файлов ускорено, диск свободен, а refresh гоняется нативной горутиной без Messenger. Но merge pull request — это несколько шагов в разных системах: проверить CI-статусы, выполнить git merge, уведомить вотчеров, переиндексировать поиск. Если один шаг упадёт, PR зависнет в «merging», а git merge уже не откатить. В Модуле 6 применим паттерн Saga (arch m4) к merge-флоу на Go: стейт-машина на switch, компенсация «mark as merge-failed» (НЕ ROLLBACK), и pgx-транзакция с FOR UPDATE от гонок между горутинами.