Кеширование¶
Самая частая фраза в архитектурных собесах: «добавим Redis». Звучит безобидно, но за ней — десяток сложных решений: где кэш, что кэшируешь, на сколько, как обновлять, что делать когда упадёт. Здесь разберёмся.
Где кэшировать: уровни¶
🧩 Простыми словами. Кэш — это кружка горячего кофе, которую ты держишь поближе. Чем ближе, тем быстрее достать. В архитектуре «ближе» означает «ближе к пользователю по сети».
graph LR
U[User] --> CDN[CDN cache<br/>edge, 10ms]
CDN --> RP[Reverse proxy cache<br/>nginx, 1ms]
RP --> App[App in-memory<br/>sync.Map, 100ns]
App --> R[Redis<br/>Memcached, 1ms]
R --> DB[(Database<br/>10-100ms)]
| Уровень | Где живёт | Latency | Что кэшируют |
|---|---|---|---|
| CDN | Edge серверы | 5–50 ms | Статика, картинки, видео, public API |
| Reverse proxy | nginx/varnish | 1 ms | HTML страницы, API responses |
| In-app | Память процесса | 100 ns | Конфиги, словари, hot keys |
| Distributed | Redis/Memcached | 0.5–5 ms | Сессии, сериализованные объекты |
| DB cache | shared_buffers | included | Страницы данных |
⚙️ Под капотом. В app кэш дёшев (нет network), но не делится между подами и теряется при рестарте. Redis шарится, переживает рестарт сервиса, но добавляет сетевой hop. CDN бесплатен для статики и спасает от cross-region latency.
💥 Зачем это нужно. Снижает latency, разгружает БД, экономит деньги на инфре. Чтение из Redis в 100x быстрее, чем из Postgres с диска.
Стратегии кеширования¶
Cache-aside (lazy loading)¶
Самый частый паттерн. Приложение знает про кэш и БД отдельно.
sequenceDiagram
App->>Cache: GET key
alt hit
Cache-->>App: value
else miss
Cache-->>App: nil
App->>DB: SELECT
DB-->>App: row
App->>Cache: SET key, TTL
end
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
if v, err := s.rdb.Get(ctx, key).Bytes(); err == nil {
var u User
if err := json.Unmarshal(v, &u); err == nil {
return &u, nil
}
}
u, err := s.repo.Get(ctx, id)
if err != nil {
return nil, err
}
if b, _ := json.Marshal(u); b != nil {
s.rdb.Set(ctx, key, b, 5*time.Minute)
}
return u, nil
}
✅ Контроль на стороне приложения. Кэш можно вынести/заменить.
❌ Запись минует кэш — после UPDATE нужна явная инвалидация.
Read-through¶
Кэш сам ходит в БД при miss. Приложение знает только про кэш.
graph LR
App --> C[Cache Layer]
C -->|miss| DB[(DB)]
✅ Чище код приложения. ❌ Cache layer становится критичным компонентом.
Write-through¶
Запись идёт в кэш и в БД синхронно.
sequenceDiagram
App->>Cache: SET key,val
Cache->>DB: write through
DB-->>Cache: OK
Cache-->>App: OK
✅ Кэш всегда свежий. ❌ Каждая запись становится дороже (в БД + в кэш).
Write-back (write-behind)¶
Запись в кэш — мгновенно. В БД — асинхронно, batched.
✅ Очень быстрая запись. ❌ Если кэш упадёт — данные потеряны. Используется только там, где можно потерять часть данных (метрики, аналитика).
Write-around¶
Запись напрямую в БД, кэш не трогается. Кэш заполняется при следующем чтении.
✅ Не загрязняет кэш «холодными» данными. ❌ Сразу после записи будет cache miss.
| Стратегия | Чтение | Запись | Когда |
|---|---|---|---|
| Cache-aside | Lazy | Direct DB + invalidate | Дефолт |
| Read-through | Lazy через слой | Direct DB | Простой код |
| Write-through | Hit, всегда свежий | Cache + DB sync | Read-heavy с критичной свежестью |
| Write-back | Hit | Cache → DB async | Write-heavy, можно терять |
| Write-around | Lazy | Direct DB, cache untouched | Большие записи, редко читаемые |
Eviction policies¶
🧩 Простыми словами. Кэш ограничен по памяти. Когда заполняется — что выкидывать?
- LRU (Least Recently Used) — выкидываем то, к чему дольше не обращались. Дефолт почти везде. Хорошо для типичных workload'ов.
- LFU (Least Frequently Used) — по частоте обращений. Лучше держит «вечно горячие» ключи, хуже реагирует на смену паттерна.
- FIFO — по порядку добавления. Глупо, но дёшево.
- Random — случайный. Иногда (например в Redis
allkeys-random) — приемлемо. - TTL-based — выкидываем по времени жизни.
Redis по умолчанию noeviction — при заполнении возвращает ошибку. Для кэша
ставим allkeys-lru или volatile-lru.
TTL — главный инструмент¶
🧩 Простыми словами. TTL = «через сколько секунд это устареет». Без TTL кэш становится свалкой — забитой устаревшими данными.
⚙️ Как выбирать.
- Для read-heavy неизменяемого: длинный TTL (1 ч – 1 сутки).
- Для часто меняющегося: короткий (10–60 секунд).
- Для критически свежего: TTL + явная инвалидация.
🛠 Применение в Go-проекте.
Не пиши «магические» 300/600/3600 в коде — выноси в константы и в конфиг.
Инвалидация — главная боль¶
«Есть всего две сложные вещи в Computer Science: cache invalidation, naming things, и off-by-one errors.» — Phil Karlton
Подходы:
- TTL. Самый простой. Принимаешь, что N секунд кэш стейл — и забываешь.
- Write-through invalidation. При
UPDATE—DEL keyв кэше. Просто, но между UPDATE и DEL может быть гонка: другой read поставил старое значение. - Write-through с версионированием. Ключ —
user:42:v17. После UPDATE увеличиваешьv— старый ключ просто забыт, через TTL умрёт. - Pub/sub invalidation. Бэкенды слушают канал и инвалидируют локальные кэши. Сложно, но мощно.
- Cache stampede protection. При miss блокируем повторные запросы к БД
через
SETNXили singleflight (см. ниже).
Двойное удаление (delete-write-delete)¶
Когда кэш и БД могут разойтись из-за гонки:
Костыль, но в hot paths применяется.
Thundering herd¶
🧩 Простыми словами. Популярный ключ протух. 1000 параллельных запросов получили miss и одновременно ломанулись в БД. БД легла.
⚙️ Решения.
graph LR
R1[Request 1] --> S{singleflight}
R2[Request 2] --> S
R3[Request 3] --> S
S -->|первый идёт в DB| DB[(DB)]
S -->|остальные ждут результата| Wait
DB --> Done
import "golang.org/x/sync/singleflight"
var sg singleflight.Group
func (s *Service) GetUser(ctx context.Context, id int64) (*User, error) {
key := fmt.Sprintf("user:%d", id)
v, err, _ := sg.Do(key, func() (any, error) {
// только один поток сюда попадёт; остальные дождутся результата
return s.fetchUser(ctx, id)
})
if err != nil {
return nil, err
}
return v.(*User), nil
}
Дополнительно: probabilistic early refresh — обновляешь чуть раньше TTL, чтобы не было одновременного протуха.
Hot keys¶
🧩 Простыми словами. Один ключ читают/пишут так часто, что один шард Redis горит, остальные простаивают.
⚙️ Решения.
- Локальный in-app кэш перед Redis. Снижает RPS на ключ.
- Sharded local cache (см. sharded-cache.md) с репликацией.
- Replicate hot key на несколько Redis-нод вручную (
hot:user:42:r1,r2,r3). - CDN для public-data (профиль звезды, главная страница).
❌ Если шардишь Redis cluster и один ключ попал на горячий слот — оставь его там, но добавь L1-кэш в приложении.
🔥 Пример: feed одного пользователя¶
graph LR
U[GET /feed] --> A[API]
A --> L1[L1: in-app<br/>sync.Map<br/>5s TTL]
L1 -->|miss| L2[L2: Redis<br/>30s TTL]
L2 -->|miss| DB[(Postgres)]
DB --> L2
L2 --> L1
L1 --> A
Двухуровневый кэш: in-app + Redis. Каждый со своим TTL. На уровне API — singleflight
по user_id, чтобы не штурмовать Redis при miss.
❌ Типичные ошибки¶
- Кэш без TTL → накапливаются устаревшие данные.
- Кэш на каждый запрос без обоснования → дополнительная сложность без выигрыша.
- Игнор thundering herd → один rebuild убьёт БД.
- Один Redis на всё → SPOF (single point of failure). Используй cluster или master-replica.
- Кэш без serialization-version → при выкатке нового формата старые ключи ломают код.
🛠 Применение в Go-проекте: чек-лист¶
github.com/redis/go-redis/v9— официальный клиент Redis.github.com/dgraph-io/ristretto— отличный in-memory cache с LFU + TinyLFU.golang.org/x/sync/singleflight— против thundering herd.sync.Mapдля маленьких read-heavy словарей внутри инстанса.groupcache— старая, но рабочая идея от Google: P2P-cache между процессами.
🤖 Что спрашивает AI-ментор¶
- Назови 5 уровней кэша от пользователя до БД.
- Чем отличаются cache-aside и read-through?
- Когда выбирать write-back, и какой главный риск?
- Что такое thundering herd и как его побороть в Go?
- Зачем нужен TTL, и почему «вечный» кэш — плохая идея?
📊 Уровни глубины¶
L1. Знаешь Redis. Понимаешь, что кэш ускоряет чтение. Можешь поставить
SET key val EX 300.
L2. Различаешь стратегии (cache-aside / write-through / write-back). Понимаешь LRU, TTL, инвалидацию. Использовал singleflight против stampede.
L3. Проектировал многоуровневый кэш (CDN + reverse proxy + in-app + Redis). Решал hot key, cache stampede в проде. Понимаешь probabilistic early refresh, двойное удаление, version-keyed cache. Видел инцидент после плохой инвалидации.
📝 Подумай¶
- Сервис показа товара (read-heavy). Какую стратегию кэширования выберешь и какой TTL?
- У тебя hot key, который читают 50k RPS. Шардить Redis не помогает (всё на одном слоте). Что сделаешь?
- После UPDATE товара через секунду читатель видит старую цену. Как починишь?
Ответ
- Cache-aside, Redis. TTL 5–10 минут (товар меняется редко). Singleflight
против стампеды. На UPDATE цены —
DELключа товара (или версионируешь ключ). - (a) Добавь L1 in-app кэш с TTL 1–2 секунды — снимет нагрузку на Redis; (b) реплицируй hot key на 3–5 ключей с суффиксом, клиент случайно выбирает один; (c) если данные публичные — CDN.
- Гонка cache-aside: между DEL и SET кто-то успел поставить старое.
Лечи: версионирование ключа (
product:123:v8), либо двойное удаление (DEL → UPDATE → wait → DEL), либо очередь инвалидации.
Дальше: databases.md — где собственно лежат данные.