Перейти к содержанию

Кеширование

Самая частая фраза в архитектурных собесах: «добавим 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.

# redis.conf
maxmemory 4gb
maxmemory-policy allkeys-lru

TTL — главный инструмент

🧩 Простыми словами. TTL = «через сколько секунд это устареет». Без TTL кэш становится свалкой — забитой устаревшими данными.

⚙️ Как выбирать.

  • Для read-heavy неизменяемого: длинный TTL (1 ч – 1 сутки).
  • Для часто меняющегося: короткий (10–60 секунд).
  • Для критически свежего: TTL + явная инвалидация.

🛠 Применение в Go-проекте.

const (
    UserTTL  = 5 * time.Minute
    StatsTTL = 30 * time.Second
    StaticTTL = 24 * time.Hour
)

Не пиши «магические» 300/600/3600 в коде — выноси в константы и в конфиг.

Инвалидация — главная боль

«Есть всего две сложные вещи в Computer Science: cache invalidation, naming things, и off-by-one errors.» — Phil Karlton

Подходы:

  1. TTL. Самый простой. Принимаешь, что N секунд кэш стейл — и забываешь.
  2. Write-through invalidation. При UPDATEDEL key в кэше. Просто, но между UPDATE и DEL может быть гонка: другой read поставил старое значение.
  3. Write-through с версионированием. Ключ — user:42:v17. После UPDATE увеличиваешь v — старый ключ просто забыт, через TTL умрёт.
  4. Pub/sub invalidation. Бэкенды слушают канал и инвалидируют локальные кэши. Сложно, но мощно.
  5. Cache stampede protection. При miss блокируем повторные запросы к БД через SETNX или singleflight (см. ниже).

Двойное удаление (delete-write-delete)

Когда кэш и БД могут разойтись из-за гонки:

1. DELETE cache
2. UPDATE db
3. WAIT 100ms
4. DELETE cache  -- ловим тех, кто успел заполнить старым

Костыль, но в 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. Видел инцидент после плохой инвалидации.

📝 Подумай

  1. Сервис показа товара (read-heavy). Какую стратегию кэширования выберешь и какой TTL?
  2. У тебя hot key, который читают 50k RPS. Шардить Redis не помогает (всё на одном слоте). Что сделаешь?
  3. После UPDATE товара через секунду читатель видит старую цену. Как починишь?
Ответ
  1. Cache-aside, Redis. TTL 5–10 минут (товар меняется редко). Singleflight против стампеды. На UPDATE цены — DEL ключа товара (или версионируешь ключ).
  2. (a) Добавь L1 in-app кэш с TTL 1–2 секунды — снимет нагрузку на Redis; (b) реплицируй hot key на 3–5 ключей с суффиксом, клиент случайно выбирает один; (c) если данные публичные — CDN.
  3. Гонка cache-aside: между DEL и SET кто-то успел поставить старое. Лечи: версионирование ключа (product:123:v8), либо двойное удаление (DEL → UPDATE → wait → DEL), либо очередь инвалидации.

Дальше: databases.md — где собственно лежат данные.