Опирается на правила: R-CACHE-TTL-1R-CACHE-TTL-4 и R-CACHE-TTL-X1R-CACHE-TTL-X3 из Caching Rules → раздел 4. TTL.

Важно знать

  • Каждый именованный cache имеет explicit TTL. Передаётся как time.Duration при вызове Set* — не хранится внутри реализации без параметра.
  • TTL берётся из CacheConfig, инициализированного через envconfig. Хардкод в вызове запрещён.
  • 0 в go-redis трактуется как «без TTL» (infinite) — Redis при max-memory eviction по LRU без вашего контроля.
  • TTL > 24 часов для бизнес-данных переживает деплой — в Redis могут остаться JSON-структуры от прошлого релиза.
  • Money-данные: TTL 5–30 секунд + evict на каждом write + evict перед критичным чтением.
  • Если есть естественный invalidation-event (CustomerUpdated) — TTL можно увеличить, invalidation делает основную работу.
  • Один TTL-параметр на кеш, не глобальный дефолтCustomerSummaryTTL и BalanceTTL никогда не должны быть одним значением.

TTL — страховочный слой. Если invalidation надёжен, TTL — резерв; если invalidation нет — TTL единственное, что не даёт кешу жить вечно. В Go нет декларативного @Cacheable, поэтому TTL передаётся явно в каждый Set-вызов, что делает его видимым и проверяемым в код-ревью.

Explicit TTL на каждом cache-порту

R-CACHE-TTL-1: ни один Set-вызов не идёт без TTL.

Порт в core/ не знает ничего о значении TTL — это контракт слоя конфигурации:

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

Реализация в adapters/out/cache/ пробрасывает TTL напрямую в go-redis:

// adapters/out/cache/order_cache.go
func (c *RedisOrderCache) SetSummary(
    ctx context.Context,
    orderID string,
    v *OrderSummary,
    ttl time.Duration,
) error {
    raw, err := json.Marshal(v)
    if err != nil {
        return fmt.Errorf("marshal order summary %s: %w", orderID, err)
    }
    return c.redis.Set(ctx, orderSummaryKey(orderID), raw, ttl).Err()
}

Use Case Handler вызывает порт и передаёт TTL из конфига:

// 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 {
        return cached, nil
    }
    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
}

TTL через CacheConfig — не хардкод

R-CACHE-TTL-3: TTL задаётся в конфиге, чтобы SRE мог поднять или снизить значение без пересборки сервиса.

// config/config.go
type CacheConfig struct {
    RedisAddr        string        `envconfig:"REDIS_ADDR"                   required:"true"`
    CustomerSummaryTTL time.Duration `envconfig:"CACHE_CUSTOMER_SUMMARY_TTL" default:"15m"`
    ProductCatalogTTL  time.Duration `envconfig:"CACHE_PRODUCT_CATALOG_TTL"  default:"6h"`
    FeatureFlagsTTL    time.Duration `envconfig:"CACHE_FEATURE_FLAGS_TTL"    default:"60s"`
    OrderSummaryTTL    time.Duration `envconfig:"CACHE_ORDER_SUMMARY_TTL"    default:"5m"`
    BalanceTTL         time.Duration `envconfig:"CACHE_BALANCE_TTL"          default:"15s"`
}

Когда в проде обнаруживается «stale CustomerSummary вызывает инциденты» — один configmap patch меняет CACHE_CUSTOMER_SUMMARY_TTL=5m и rolling restart подов применяет его за минуты, не часы.

Прямой хардкод в хендлере — антипаттерн, который не пройдёт код-ревью:

// недопустимо — TTL зашит, SRE ничего не может изменить без PR
if err := h.cache.SetSummary(ctx, id, summary, 15*time.Minute); err != nil {

Типовые значения по характеру данных

R-CACHE-TTL-2: TTL определяется природой данных, не «короче — безопаснее».

Тип данныхTTLПоле конфига
CustomerSummary, профили15 минутCustomerSummaryTTL
ProductCatalog, справочники6 часовProductCatalogTTL
FeatureFlags60 секундFeatureFlagsTTL
OrderSummary, агрегации5 минутOrderSummaryTTL
Money (Balance, CreditLimit)5–30 секундBalanceTTL

Справочники (ProductCatalog) меняются редко — 6 часов stale терпимо; при необходимости инициируется ручной evict через админ-операцию. Feature flags обновляются часто и влияют на поведение в реальном времени — 60 секунд достаточно, чтобы изменение флага дошло до всех подов за минуту. Money — самый строгий случай: TTL 15–30 секунд + обязательный evict при каждом списании.

TTL и invalidation — два независимых слоя

R-CACHE-TTL-4: наличие надёжного invalidation-события позволяет увеличить TTL.

Сценарий с событием: CustomerUpdated → обработчик делает evict:

// adapters/in/events/customer_events_handler.go
func (h *CustomerEventHandler) OnCustomerUpdated(ctx context.Context, e CustomerUpdatedEvent) error {
    if err := h.cache.DeleteSummary(ctx, e.CustomerID); err != nil {
        slog.WarnContext(ctx, "cache evict on event failed", "customer_id", e.CustomerID, "error", err)
    }
    return nil
}

При наличии такого обработчика CustomerSummaryTTL можно держать на 30 минутах — invalidation сработает быстро, TTL страхует от пропущенного события. Без надёжного события (внешний справочник через ETL, нет Kafka-топика об изменении) — TTL короче, это осознанный компромисс: stale до истечения TTL.

Главный принцип: invalidation первичен, TTL вторичен. Если invalidation ненадёжен — не увеличивай TTL в надежде «потом настроим evict».

Money — отдельный режим

R-CACHE-TTL-X3 в связке с R-CACHE-WHERE-X3: баланс или лимит кредита не живёт в кеше дольше 30 секунд без строгой стратегии evict.

Три обязательных условия для Balance:

// 1. TTL из конфига, не дефолтный
if err := h.cache.SetBalance(ctx, cmd.CustomerID, balance, h.cfg.Cache.BalanceTTL); err != nil {
    slog.WarnContext(ctx, "balance cache set failed", "customer_id", cmd.CustomerID, "error", err)
}

// 2. Evict на каждом write (списание, пополнение)
func (h *DebitHandler) Handle(ctx context.Context, cmd DebitCommand) error {
    if err := h.repo.Debit(ctx, cmd); err != nil {
        return err
    }
    if err := h.cache.DeleteBalance(ctx, cmd.CustomerID); err != nil {
        slog.WarnContext(ctx, "balance cache evict failed", "customer_id", cmd.CustomerID, "error", err)
    }
    return nil
}

// 3. Evict перед критичным чтением (авторизация крупного платежа)
func (h *AuthorizePaymentHandler) Handle(ctx context.Context, cmd AuthorizePaymentCommand) (*AuthResult, error) {
    _ = h.cache.DeleteBalance(ctx, cmd.CustomerID)
    balance, err := h.repo.GetBalance(ctx, cmd.CustomerID)
    // ...
}

«Кешируем баланс на 5 минут, потому что прод медленный» → нужно оптимизировать запрос (индекс, read-replica), не увеличивать TTL.

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

АнтипаттернПравилоЧто взамен
c.redis.Set(ctx, key, val, 0) — infinite TTLR-CACHE-TTL-X1explicit time.Duration из CacheConfig
TTL > 24 часов для бизнес-данныхR-CACHE-TTL-X2TTL ≤ 24h или version-suffix в ключе (order-summaries-v2:)
Money без TTL или TTL > 1 минуты без evictR-CACHE-TTL-X3BalanceTTL ≤ 30s + evict на write + evict перед критичным чтением
TTL хардкодом в хендлере (15*time.Minute)R-CACHE-TTL-3поле CacheConfig + envconfig
Один DefaultTTL для всех кешейR-CACHE-TTL-1отдельное поле на каждый тип данных
TTL без учёта характера данныхR-CACHE-TTL-2таблица типовых значений выше

Куда дальше

  • Конфигурация — CacheConfig, envconfig, инициализация go-redis.
  • Где кешируем — money-rules, read-проекции, запрет кеша на write-path.
  • Invalidation — evict на write-методах, event-driven invalidation.
  • Паттерны — refresh-ahead для hot-ключей (TTL менее важен по дизайну).
  • Observability — мониторинг hit rate, связь с TTL.
  • Cache stampede — singleflight и SetNX-lock при истечении TTL под нагрузкой.
  • Ключи — namespace-префиксы, version-suffix при breaking change.