Модуль 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-дерево устаревает. Сценарий инвалидации:
- Push-хендлер уже пишет
RepositoryPushedв Outbox (модуль 4) — в одной транзакции с метаданными. - Консьюмер (горутина, читающая
outbox_eventsчерезFOR UPDATE SKIP LOCKED) ловит событие и делаетrdb.Del(ctx, "repo:"+id+":tree:branch:*")— после коммита, без Dual-Write. - 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 + SETNX | 1 disk read при stampede; ≥ 90% hit; низкая p99 | stale до обновления фона | read-heavy просмотр файлов |
| go-redis SETNX | атомарный лок без extra-зависимостей | не distributed lock (multi-node — нужен redsync) | single-node или sticky-маршрутизация |
| redsync (distributed lock) | работает на кластере Redis, безопасен при failover | +зависимость, +latency на quorum | multi-node при hot-ключах |
| sync.Map (in-process) | нет сетевого хопа, наносекунды | не шарится между подами, теряется при рестарте | single-node, низкий cardinality |
| Нативная горутина для refresh | 0 инфраструктуры, мгновенный старт | 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 мс.
План:
CachedTreeReader: Redis →exec.Command("git", "ls-tree", ...)→ cache.- SETNX-лок на холодный старт; SWR при наличии stale.
- Консьюмер
RepositoryPushed:rdb.Del+ delayed double-delete черезtime.AfterFunc. - k6: 1000 параллельных → 1 disk read, ≥ 90% Cache Hit.
- ADR-GG5 «Cache-Aside для tree + SWR горутиной».
Задача 1: CachedTreeReader (Cache-Aside + SETNX)
gofunc (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 старый) — отдать сразу и запустить фоновое обновление горутиной.
goif 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.
gofunc (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-горутин)
⚠️ Антипаттерн
gotree, 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?
🧩 Проверь себя
🔗 Мостик к Модулю 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 от гонок между горутинами.