Опирается на правила:
R-CACHE-WHERE-1…R-CACHE-WHERE-3иR-CACHE-WHERE-X1…R-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 flags | 60 секунд | FeatureFlagsTTL |
| Heavy aggregations | 5–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: добавить кеш «потому что быстрее», не ответив на три вопроса:
- Что произойдёт при stale на TTL длительности? Кто пострадает?
- Бизнес может это терпеть? (Каталог продуктов — да; счёт пользователя — нет.)
- Есть ли явная 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-X2 | read-проекции (OrderSummary) |
| Money-кеш без TTL/invalidation | R-CACHE-WHERE-X3 | TTL ≤ 30s + double-evict |
| Кеш бизнес-данных без trade-off оценки | R-CACHE-WHERE-X4 | три вопроса перед добавлением кеша |
| Кеш результатов авторизации | R-CACHE-WHERE-X5 | ABAC каждый раз; JWK встроен |
| Cache-aside на write-heavy endpoint | R-CACHE-WHERE-3 | не кешируй вообще |
c.redis.Set(..., 0) (infinite TTL) | R-CACHE-TTL-X1 | explicit 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.