Опирается на правила:
R-CACHE-TTL-1…R-CACHE-TTL-4иR-CACHE-TTL-X1…R-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 |
FeatureFlags | 60 секунд | 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 TTL | R-CACHE-TTL-X1 | explicit time.Duration из CacheConfig |
| TTL > 24 часов для бизнес-данных | R-CACHE-TTL-X2 | TTL ≤ 24h или version-suffix в ключе (order-summaries-v2:) |
| Money без TTL или TTL > 1 минуты без evict | R-CACHE-TTL-X3 | BalanceTTL ≤ 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.