Опирается на правила: R-CACHE-PATTERN-1R-CACHE-PATTERN-3 и R-CACHE-PATTERN-X1R-CACHE-PATTERN-X2 из Caching Rules → раздел 6. Паттерны.

Важно знать

  • В Go нет декларативного @Cacheable — кеш явный: get-or-load в Use Case Handler, порт в core/, реализация в adapters/out/cache/.
  • Cache-aside (явный GetSummary → промах → SetSummary + DeleteSummary на write) — дефолтный паттерн.
  • Write-through — явный SetSummary на write вместо DeleteSummary; применяется, когда данные нужны сразу после записи в том же flow.
  • Refresh-ahead — горутина-тикер перезаливает кеш до истечения TTL; гарантирует no cache miss для hot keys.
  • Write-behind (запись в кеш, async flush в БД) — запрещён для money/critical: crash до flush = потеря данных.
  • Один cache-namespace = один паттерн. Mix cache-aside и write-through на одном namespace = непонятная invalidation-логика.
  • Ошибка evict-а логируется slog.WarnContext, не возвращается — TTL сделает работу, evict best-effort.
  • Stampede: singleflight для single-instance; SetNX-lock для Redis multi-instance; refresh-ahead исключает stampede по дизайну.

Три паттерна — три точки на спектре «свежесть данных vs производительность». Cache-aside — простой компромисс, читается как код; write-through — кеш актуален сразу после write ценой явного Set; refresh-ahead — максимальная защита от cache miss ценой фоновой горутины.

Cache-aside — дефолт

R-CACHE-PATTERN-1: get из кеша → при промахе загружаем из БД и кладём в кеш; на write — evict.

GET  → GetSummary
         │
         ├── HIT  → return cached
         │
         └── MISS → repo.GetSummary → SetSummary(ttl) → return

POST/PATCH → repo.Update → DeleteSummary   (следующий GET загрузит свежее)

Порт объявлен в core/:

// core/order/cache_port.go
type OrderCache interface {
    GetSummary(ctx context.Context, orderID string) (*OrderSummary, error)
    SetSummary(ctx context.Context, orderID string, v *OrderSummary, ttl time.Duration) error
    DeleteSummary(ctx context.Context, orderID string) error
}

Query handler:

// core/order/get_order_summary_handler.go
func (h *GetOrderSummaryHandler) Handle(ctx context.Context, q GetOrderSummaryQuery) (*OrderSummary, error) {
    if cached, err := h.cache.GetSummary(ctx, q.OrderID); err == nil && cached != nil {
        cacheHits.WithLabelValues("order-summaries").Inc()
        return cached, nil
    }
    cacheMisses.WithLabelValues("order-summaries").Inc()

    summary, err := h.repo.GetSummary(ctx, q.OrderID)
    if err != nil {
        return nil, err
    }
    if err := h.cache.SetSummary(ctx, q.OrderID, summary, h.cfg.Cache.OrderSummaryTTL); err != nil {
        slog.WarnContext(ctx, "cache set failed", "order_id", q.OrderID, "error", err)
    }
    return summary, nil
}

Command handler (evict):

// core/order/cancel_order_handler.go
func (h *CancelOrderHandler) Handle(ctx context.Context, cmd CancelOrderCommand) error {
    if err := h.repo.Cancel(ctx, cmd); err != nil {
        return err
    }
    if err := h.cache.DeleteSummary(ctx, cmd.OrderID); err != nil {
        slog.WarnContext(ctx, "cache evict failed", "order_id", cmd.OrderID, "error", err)
    }
    return nil
}

Свойства:

  • Явный код. Нет магии аннотаций — поток виден в хендлере.
  • Кеш отстаёт от БД на TTL. Write evict-ит, но между write одного инстанса и read другого может быть гонка — для большинства случаев допустимо.
  • Холодный старт. После рестарта или массового evict-а все reads — cache miss; БД получает полную нагрузку.
  • Устойчивость к отказу Redis. Если Redis недоступен — fallback в БД, сервис работает с худшим latency.

Подходит для подавляющего большинства сценариев. Нет конкретной причины выбрать другой паттерн — cache-aside.

Write-through — явный Set на write

R-CACHE-PATTERN-2: на write кеш обновляется свежим значением вместо evict-а.

POST/PATCH → repo.UpdateAndReturn → SetSummary(updated, ttl)   (кеш свежий сразу)
GET        → GetSummary → HIT (почти всегда)
// core/customer/update_customer_handler.go
func (h *UpdateCustomerHandler) Handle(ctx context.Context, cmd UpdateCustomerCommand) error {
    updated, err := h.repo.UpdateAndReturn(ctx, cmd)
    if err != nil {
        return err
    }
    summary := toSummary(updated)
    if err := h.cache.SetSummary(ctx, cmd.CustomerID, summary, h.cfg.Cache.CustomerSummaryTTL); err != nil {
        slog.WarnContext(ctx, "cache write-through failed", "customer_id", cmd.CustomerID, "error", err)
    }
    return nil
}

UpdateAndReturn возвращает сохранённое состояние — именно оно кладётся в кеш, не промежуточный объект из команды.

Когда применять:

  • High read + write ratio одного значения с continuation: после UpdateCustomer тот же flow делает GetCustomerSummary → write-through даёт ответ мгновенно из кеша.
  • Метод возвращает финальное состояние (*CustomerSummary), а не только id или void.

Когда не применять:

  • Write меняет состояние через несколько сервисов (saga): write-through на одном шаге = несогласованность с остальными.
  • Репозитарный метод не возвращает финальное состояние — SetSummary некому вызвать с корректным значением.

Refresh-ahead — горутина до истечения TTL

R-CACHE-PATTERN-3: фоновая горутина перезаливает кеш до того, как TTL истекает.

ticker каждые 80% TTL
     │
     └── repo.GetTopProducts(100) → cache.SetTopProducts(top, TTL)

GET top-products → GetTopProducts → HIT всегда (кеш никогда не пустой)
// adapters/background/product_cache_refresher.go
type ProductCacheRefresher struct {
    repo  ProductRepository
    cache ProductCache
    cfg   CacheConfig
}

func (r *ProductCacheRefresher) Run(ctx context.Context) {
    interval := time.Duration(float64(r.cfg.ProductCatalogTTL) * 0.8)
    ticker := time.NewTicker(interval)
    defer ticker.Stop()

    if err := r.refresh(ctx); err != nil {
        slog.WarnContext(ctx, "product cache initial refresh failed", "error", err)
    }

    for {
        select {
        case <-ctx.Done():
            return
        case <-ticker.C:
            if err := r.refresh(ctx); err != nil {
                slog.WarnContext(ctx, "product cache refresh failed", "error", err)
            }
        }
    }
}

func (r *ProductCacheRefresher) refresh(ctx context.Context) error {
    top, err := r.repo.GetTopProducts(ctx, 100)
    if err != nil {
        return fmt.Errorf("get top products: %w", err)
    }
    return r.cache.SetTopProducts(ctx, top, r.cfg.ProductCatalogTTL)
}

Горутина стартует при инициализации приложения и останавливается через context cancellation на graceful shutdown.

Свойства:

  • No cache miss for hot keys. Пользователи никогда не платят latency «load from DB» на top-products.
  • Постоянная нагрузка на БД. Каждые ~80% TTL независимо от read-нагрузки — это компромисс.
  • Подходит для bounded hot keys. top-products, главная страница, feature-flags — заранее знаем ключи.
  • Не подходит для unbounded keys. Миллион customer-profiles — refresh-ahead невозможен, cache-aside.
  • Stampede исключён по дизайну. Кеш всегда заполнен; несколько инстансов обновляют независимо, но значения идентичны.

Комбинация: refresh-ahead для hot bounded keys + cache-aside для остального — нормальная стратегия.

Stampede при cache miss

Отдельная тема, вытекающая из cache-aside: при cache miss несколько горутин одновременно идут в БД.

Для single-instance — singleflight (golang.org/x/sync/singleflight):

// adapters/out/cache/singleflight_order_cache.go
type SingleflightOrderCache struct {
    inner OrderCache
    group singleflight.Group
}

func (c *SingleflightOrderCache) GetOrLoad(
    ctx context.Context,
    orderID string,
    loader func() (*OrderSummary, error),
) (*OrderSummary, error) {
    v, err, _ := c.group.Do(orderID, func() (any, error) {
        if cached, err := c.inner.GetSummary(ctx, orderID); err == nil && cached != nil {
            return cached, nil
        }
        return loader()
    })
    if err != nil {
        return nil, err
    }
    return v.(*OrderSummary), nil
}

Для Redis multi-instance — SetNX-lock (R-CACHE-STAMP-2; детально в Cache stampede).

Что запрещено

АнтипаттернПравилоЧто взамен
Write-behind: write в кеш, async flush в БДR-CACHE-PATTERN-X1write-through или cache-aside
Mix DeleteSummary и SetSummary на одном namespaceR-CACHE-PATTERN-X2один namespace = один паттерн
Refresh-ahead для unbounded keys (миллион customer-profiles)R-CACHE-PATTERN-3cache-aside для unbounded
UpdateAndReturn не реализован, SetSummary вызван с данными из командыR-CACHE-PATTERN-2сохранить и вернуть из репо, кешировать только финальное состояние
Горутина refresh стартует без начального refresh()R-CACHE-PATTERN-3явный первый вызов до старта ticker
Игнорировать singleflight при >100 RPS на hot endpointR-CACHE-STAMP-X1singleflight / SetNX-lock / refresh-ahead

Куда дальше

  • Cache stampede — singleflight и SetNX-lock для Redis multi-instance.
  • Invalidation — DeleteSummary по write + evict через доменные события.
  • Где кешируем — какие проекции подходят под какой паттерн.
  • TTL — refresh-ahead делает TTL резервным механизмом.
  • Ключи — namespace kebab-case, explicit ключи, хеширование sensitive.
  • Конфигурация — CacheConfig через envconfig, per-cache TTL.
  • Observability — cache_hits_total, cache_misses_total, alert по hit rate.