Опирается на правила:
R-CACHE-PATTERN-1…R-CACHE-PATTERN-3иR-CACHE-PATTERN-X1…R-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-X1 | write-through или cache-aside |
Mix DeleteSummary и SetSummary на одном namespace | R-CACHE-PATTERN-X2 | один namespace = один паттерн |
Refresh-ahead для unbounded keys (миллион customer-profiles) | R-CACHE-PATTERN-3 | cache-aside для unbounded |
UpdateAndReturn не реализован, SetSummary вызван с данными из команды | R-CACHE-PATTERN-2 | сохранить и вернуть из репо, кешировать только финальное состояние |
Горутина refresh стартует без начального refresh() | R-CACHE-PATTERN-3 | явный первый вызов до старта ticker |
Игнорировать singleflight при >100 RPS на hot endpoint | R-CACHE-STAMP-X1 | singleflight / 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.