Опирается на правила: R-CACHE-WHERE-1R-CACHE-WHERE-3 и R-CACHE-WHERE-X1R-CACHE-WHERE-X5 из Caching Rules → раздел 1. Где кешируем.

Важно знать

  • Кеш — оптимизация, не часть бизнес-контракта. Сервис обязан работать корректно и без кеша.
  • В Go нет декларативного @Cacheable: кеш — явный cache-aside через cache-порт (interface в core/) и реализацию в adapters/out/cache/.
  • Read-heavy + редко меняющееся — да: CustomerSummary (15 мин), ProductCatalog (6 ч), feature flags (60 сек), heavy aggregations.
  • Money — допустимо, но TTL 5–30 секунд + явный Delete* на каждом write + Delete* перед критичным чтением.
  • Cache-aside (lazy get-or-load + evict на write) — дефолтный паттерн.
  • Доменный агрегат целиком — никогда: нарушает границы агрегата; invalidation становится неуправляемой. Кешируем read-проекции (OrderSummary).
  • Авторизация не кешируется: ABAC-проверки выполняются каждый раз — кеш ролей = security risk при изменении прав.

Кеш экономит latency и снижает нагрузку на БД, но добавляет stale-data риск и invalidation-сложность. Главный вопрос перед добавлением кеша: «что произойдёт, если значение устарело на 30 секунд, и кто это заметит?»

Порт и реализация — фундамент

В Go кеш — явный контракт, а не аспект. Порт живёт в core/, реализация — в adapters/out/cache/.

// core/customer/cache_port.go
type CustomerCache interface {
    GetSummary(ctx context.Context, customerID string) (*CustomerSummary, error)
    SetSummary(ctx context.Context, customerID string, v *CustomerSummary, ttl time.Duration) error
    DeleteSummary(ctx context.Context, customerID string) error
}

nil при cache miss возвращает GetSummary, а не ошибку — miss не является ошибкой:

// adapters/out/cache/customer_cache.go
func (c *RedisCustomerCache) GetSummary(ctx context.Context, customerID string) (*CustomerSummary, error) {
    raw, err := c.redis.Get(ctx, summaryKey(customerID)).Bytes()
    if errors.Is(err, redis.Nil) {
        return nil, nil
    }
    if err != nil {
        return nil, fmt.Errorf("get customer summary %s: %w", customerID, err)
    }
    var v CustomerSummary
    if err := json.Unmarshal(raw, &v); err != nil {
        return nil, fmt.Errorf("unmarshal customer summary %s: %w", customerID, err)
    }
    return &v, nil
}

Ошибки — значения (apperr.Kind): cache miss — nil, nil; ошибка Redis — nil, err; успех — &v, nil.

Read-heavy + редко меняющееся — кешируем

R-CACHE-WHERE-1: кандидаты по характеру данных.

ЧтоTTLПоле конфига
CustomerSummary, профили15 минутCustomerSummaryTTL
ProductCatalog, справочники6 часовProductCatalogTTL
Feature flags60 секундFeatureFlagsTTL
Heavy aggregations5–10 минут

Критерий: ratio read/write > 100:1, бизнес-смысл допускает задержку TTL, данные не содержат PII в ключе.

Cache-aside для CustomerSummary в query-хендлере:

// core/customer/query_handler.go
func (h *GetCustomerSummaryHandler) Handle(ctx context.Context, q GetCustomerSummaryQuery) (*CustomerSummary, error) {
    if cached, err := h.cache.GetSummary(ctx, q.CustomerID); err == nil && cached != nil {
        cacheHits.WithLabelValues("customer-profiles").Inc()
        return cached, nil
    }
    cacheMisses.WithLabelValues("customer-profiles").Inc()

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

Ошибка SetSummary логируется Warn, но не возвращается: кеш — best-effort, TTL подстрахует.

Money — допустимо, но строго

R-CACHE-WHERE-2: Balance, CreditLimit можно кешировать только с явной invalidation strategy.

Три условия одновременно:

  • TTL 5–30 секунд (не больше).
  • DeleteSummary / DeleteBalance на каждом write-методе того же ресурса.
  • Для критичных операций (списание, перевод) — DeleteBalance перед чтением, явный обход кеша.
// core/payment/debit_handler.go
func (h *DebitHandler) Handle(ctx context.Context, cmd DebitCommand) error {
    // evict перед чтением: защита от stale при concurrent write
    if err := h.cache.DeleteBalance(ctx, cmd.AccountID); err != nil {
        slog.WarnContext(ctx, "pre-debit cache evict failed", "account_id", cmd.AccountID, "error", err)
    }

    balance, err := h.repo.GetBalance(ctx, cmd.AccountID)
    if err != nil {
        return err
    }
    if balance.LessThan(cmd.Amount) {
        return apperr.New(apperr.KindValidation, "insufficient funds")
    }
    if err := h.repo.Debit(ctx, cmd.AccountID, cmd.Amount); err != nil {
        return err
    }

    // evict после записи: сбросить то, что мог положить параллельный read
    if err := h.cache.DeleteBalance(ctx, cmd.AccountID); err != nil {
        slog.WarnContext(ctx, "post-debit cache evict failed", "account_id", cmd.AccountID, "error", err)
    }
    return nil
}

Double-evict (до и после) защищает от race: параллельный запрос мог положить старое значение между первым evict и вызовом Debit.

Cache-aside — дефолтный паттерн

R-CACHE-WHERE-3: get-or-load на read, delete на write.

Request → GetSummary(ctx, id)
              │
              ├── != nil  → return cached value
              │
              └── == nil  → repo.GetSummary → SetSummary(ttl) → return

Write   → repo.Update → DeleteSummary → next read loads fresh

Простой, читаемый, предсказуемый. Cache-aside также отделяет кеш от репозитория: репозиторий не знает о кеше, хендлер управляет обоими явно.

Подробнее — Паттерны.

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

Кеш на write-path

R-CACHE-WHERE-X1: SetSummary / GetSummary на CreateOrder, ConfirmPayment — нонсенс.

Write-операции порождают side-effects и не возвращают «то же значение для тех же входов». Cache-aside предполагает идемпотентность read: для одного customerID — один CustomerSummary. CreateOrder для одного customerID создаёт разные заказы при каждом вызове — кешировать нечего.

// ЗАПРЕЩЕНО — R-CACHE-WHERE-X1
func (h *CreateOrderHandler) Handle(ctx context.Context, cmd CreateOrderCommand) (*Order, error) {
    if cached, _ := h.cache.GetOrder(ctx, cmd.CustomerID); cached != nil {
        return cached, nil // второй заказ не создастся — вернёт первый
    }
    order, err := h.repo.Create(ctx, cmd)
    // ...
    _ = h.cache.SetOrder(ctx, cmd.CustomerID, order, 5*time.Minute)
    return order, nil
}

Кеш доменного агрегата целиком

R-CACHE-WHERE-X2: кешировать Order с []OrderItem, Payment, Shipment — запрещено.

Проблемы:

  • Нарушает границы агрегата. Агрегат управляет собственными invariants; внешний кеш видит внутреннее состояние.
  • Invalidation hell. Любое изменение OrderItem или Payment должно evict-ить весь Order. Цепочки evict сложно отследить.
  • Sensitive data. Агрегат может содержать payment details или PII, которые не должны попадать в Redis.
  • Размер. Полный агрегат с join'ами — несколько KB; кеш заполняется быстро.

Корректно — read-проекции:

// core/order/order_summary.go
type OrderSummary struct {
    OrderID     string      `json:"order_id"`
    CustomerID  string      `json:"customer_id"`
    Status      OrderStatus `json:"status"`
    ItemCount   int         `json:"item_count"`
    TotalAmount int64       `json:"total_amount_cents"`
    CreatedAt   time.Time   `json:"created_at"`
}

// 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
}

OrderSummary — небольшая проекция, без child-collections, без sensitive-данных. Кешируется без побочных эффектов.

Money без TTL/invalidation

R-CACHE-WHERE-X3: «кешируем Balance на час».

Сценарий: пользователь потратил деньги, баланс в БД 0, в Redis 1000 ещё 55 минут. Открывает приложение — видит 1000 — пытается потратить — отказ — рекламация.

Правила для money:

  • TTL ≤ 30 секунд.
  • Явный evict на каждом write.
  • Evict перед критичным чтением.
// ЗАПРЕЩЕНО — R-CACHE-WHERE-X3
_ = c.redis.Set(ctx, balanceKey(accountID), raw, time.Hour) // TTL 1 час для баланса

Кеш бизнес-критичных данных без trade-off оценки

R-CACHE-WHERE-X4: добавить кеш «потому что быстрее», не ответив на три вопроса:

  1. Что произойдёт при stale на TTL длительности? Кто пострадает?
  2. Бизнес может это терпеть? (Каталог продуктов — да; счёт пользователя — нет.)
  3. Есть ли явная invalidation strategy?

Если все три «да» — кешируем. Иначе — нет.

Кеш результатов авторизации

R-CACHE-WHERE-X5: кешировать «у customerID=42 есть роль ADMIN» на 10 минут — security risk.

Сценарий: в 10:00 администратор отозвал роль. До 10:10 пользователь продолжает выполнять admin-действия, потому что кеш живой. ABAC-проверки выполняются каждый раз — цена несколько мс на запрос, выгода — мгновенный revoke без ожидания TTL.

Что запрещено — таблица

АнтипаттернПравилоЧто взамен
GetSummary / SetSummary на write-методеR-CACHE-WHERE-X1только read, write — Delete*
Кеш доменного агрегата целикомR-CACHE-WHERE-X2read-проекции (OrderSummary)
Money-кеш без TTL/invalidationR-CACHE-WHERE-X3TTL ≤ 30s + double-evict
Кеш бизнес-данных без trade-off оценкиR-CACHE-WHERE-X4три вопроса перед добавлением кеша
Кеш результатов авторизацииR-CACHE-WHERE-X5ABAC каждый раз; JWK встроен
Cache-aside на write-heavy endpointR-CACHE-WHERE-3не кешируй вообще
c.redis.Set(..., 0) (infinite TTL)R-CACHE-TTL-X1explicit TTL из CacheConfig

Куда дальше

  • Конфигурация — redis/go-redis/v9, CacheConfig, JSON-сериализация.
  • TTL — типовые значения, time.Duration из envconfig, запреты.
  • Invalidation — Delete* на write, доменные события, distributed.
  • Ключи — namespace-префикс kebab-case, составные ключи, хеширование sensitive.
  • Паттерны — cache-aside, write-through, refresh-ahead.
  • Cache stampede — singleflight, SetNX-lock, refresh-ahead.
  • Observability — promauto, hit rate, slog.DebugContext.