Опирается на правила:
R-CACHE-INV-1…R-CACHE-INV-4иR-CACHE-INV-X1…R-CACHE-INV-X3из Caching Rules → раздел 5. Invalidation.
Важно знать
- На каждом write-методе того же ресурса — вызов
Delete*на cache-порту; ошибку логироватьslog.WarnContext, не возвращать.- Несколько кешей — несколько
Delete*-вызовов подряд; ни одним не пренебрегать.- Доменные события (
CustomerUpdatedEvent) — invalidation как side-effect обработчика вadapters/in/events/; сам хендлер о кешах не знает.- Distributed invalidation встроена в Redis:
DELна одном поде виден всем инстансам, никакого pub/sub для evict не нужно.FLUSHDB/DEL namespace:*— только при административных операциях; в prod-хендлерах запрещено.- Только TTL для consistency — допустимо для
FeatureFlags; для money/orders — только с явным evict.- Eventual consistency без декларации — нарушение контракта; фиксируется в OpenAPI
description.
В Go нет декларативного @CacheEvict — invalidation явная: метод Delete* на cache-порту вызывается там, где произошло изменение. Это делает логику видимой и тестируемой, но требует дисциплины: пропущенный evict = stale в проде.
Evict на write-методе
R-CACHE-INV-1: каждый write-хендлер evict-ит ключ затронутого ресурса.
// core/customer/update_handler.go
func (h *UpdateCustomerHandler) Handle(ctx context.Context, cmd UpdateCustomerCommand) error {
if err := h.repo.Update(ctx, cmd); err != nil {
return err
}
if err := h.cache.DeleteSummary(ctx, cmd.CustomerID); err != nil {
slog.WarnContext(ctx, "cache evict failed",
"customer_id", cmd.CustomerID,
"error", err,
)
}
return nil
}
Ошибка evict-а — Warn, не возвращаемая. TTL сделает работу через N секунд; evict — best-effort оптимизация. Если evict вернуть как ошибку, падение Redis будет блокировать write-операции — неприемлемо.
Порядок: сначала repo.Update, потом cache.DeleteSummary. Ни в коем случае не наоборот: если БД не обновится, кеш уже будет пустым — следующий read загрузит старое значение и положит его обратно в кеш.
// core/order/confirm_handler.go
func (h *ConfirmOrderHandler) Handle(ctx context.Context, cmd ConfirmOrderCommand) (*OrderSummary, error) {
order, err := h.repo.Confirm(ctx, cmd.OrderID)
if err != nil {
return nil, err
}
if err := h.cache.DeleteOrderSummary(ctx, cmd.OrderID); err != nil {
slog.WarnContext(ctx, "cache evict failed",
"order_id", cmd.OrderID,
"error", err,
)
}
return toSummary(order), nil
}
Несколько кешей — несколько Delete
R-CACHE-INV-2: одно изменение может затрагивать несколько проекций.
// core/product/update_handler.go
func (h *UpdateProductHandler) Handle(ctx context.Context, cmd UpdateProductCommand) error {
if err := h.repo.Update(ctx, cmd); err != nil {
return err
}
_ = h.cache.DeleteProduct(ctx, cmd.ProductID)
_ = h.cache.DeleteProductBySlug(ctx, cmd.CategoryID, cmd.Slug)
return nil
}
Ошибки Delete здесь намеренно проигнорированы (_): каждый evict независим, неудача одного не отменяет другой. Критичнее — залогировать на уровне реализации порта (см. R-CACHE-OBS-3), а не дублировать логику в хендлере.
Для Customer изменение данных профиля может затрагивать сводку и разрешения:
// core/customer/update_role_handler.go
func (h *UpdateCustomerRoleHandler) Handle(ctx context.Context, cmd UpdateCustomerRoleCommand) error {
if err := h.repo.UpdateRole(ctx, cmd); err != nil {
return err
}
_ = h.cache.DeleteSummary(ctx, cmd.CustomerID)
_ = h.cache.DeletePermissions(ctx, cmd.CustomerID)
return nil
}
Invalidation как side-effect доменного события
R-CACHE-INV-3: когда один ресурс меняется из нескольких use case — invalidation вынесена в обработчик события.
// adapters/in/events/customer_events_handler.go
type CustomerEventHandler struct {
cache CustomerCache
}
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,
"event", "CustomerUpdated",
"error", err,
)
}
return nil
}
func (h *CustomerEventHandler) OnCustomerDeleted(ctx context.Context, e CustomerDeletedEvent) error {
_ = h.cache.DeleteSummary(ctx, e.CustomerID)
_ = h.cache.DeletePermissions(ctx, e.CustomerID)
return nil
}
Почему этот паттерн важен:
- Единое место.
UpdateCustomerHandler,BanCustomerHandler,MergeCustomersHandler— все публикуютCustomerUpdatedEvent; кеш инвалидируется ровно один раз, в одном месте, независимо от источника. - Внешние события. Если
CustomerUpdatedEventприходит из Kafka (другой сервис изменил клиента), тот же обработчик инвалидирует локальный кеш. - Хендлер не знает о кешах.
UpdateCustomerHandlerработает сrepoи публикует событие; что с ним делать дальше — не его зона ответственности.
Публикация события из хендлера:
// core/customer/update_handler.go
func (h *UpdateCustomerHandler) Handle(ctx context.Context, cmd UpdateCustomerCommand) error {
updated, err := h.repo.Update(ctx, cmd)
if err != nil {
return err
}
h.events.Publish(CustomerUpdatedEvent{CustomerID: updated.ID})
return nil
}
Синхронная публикация через EventBus (in-process) — стандарт в UCP Go-стеке. Kafka-события приходят через отдельный adapters/in/kafka/.
Distributed invalidation — встроено
R-CACHE-INV-4: Redis — общий backend для всех инстансов.
pod-1: repo.Update → cache.DeleteSummary → redis.Del "customer-profiles:42"
pod-2: следующий read → Get → redis.Nil → load from DB → Set
redis.Del виден всем подам мгновенно. Никакого pub/sub для синхронизации evict между инстансами не нужно.
Многоуровневый кеш (L1 = ristretto на поде, L2 = Redis) требует отдельного механизма инвалидации L1 между подами — Redis Keyspace Notifications или собственный pub/sub. Это нестандартная архитектура; её применение — осознанный trade-off.
Что запрещено
| Антипаттерн | Правило | Что взамен |
|---|---|---|
FLUSHDB / DEL customer-profiles:* в prod-хендлере | R-CACHE-INV-X1 | DeleteSummary(ctx, id) — точечный evict |
Только TTL для money (Balance) и orders | R-CACHE-INV-X2 | Delete* на каждом write-методе |
Eventual consistency без OpenAPI description | R-CACHE-INV-X3 | "Значение актуально с задержкой до 30 секунд" в описании endpoint |
Evict до repo.Update | R-CACHE-INV-1 | Сначала запись в БД, потом evict |
Пропустить Delete* при добавлении нового write use case | R-CACHE-INV-1 | Паттерн EventHandler-а для всех write-путей |
Несколько Delete* заменить FLUSHDB «для простоты» | R-CACHE-INV-X1 | Явный Delete* для каждого затронутого ключа |
sync.Map-lock как защита distributed evict | нет аналога | Redis common backend — evict виден всем подам |
FLUSHDB — только для администрирования
FLUSHDB / DEL namespace:* сбрасывает весь namespace разом. Сценарий:
10 000 CustomerSummary в кеше, нагрузка 500 RPS
→ один write → FLUSHDB
→ 500 requests/s все промахиваются → 500 параллельных запросов в БД
→ DB latency растёт → cascading timeout
Допустимо только при:
- Перестройке всего кеша после миграции структуры данных.
- Отладке в dev-окружении.
- Явных административных операциях (
POST /admin/cache/flush).
В Handle-методах — никогда. Точечный DeleteSummary(ctx, id) при любом объёме.
Только TTL для consistency — когда ок, когда нет
| Данные | Только TTL — допустимо? |
|---|---|
FeatureFlags | Да — задержка 60 с терпима |
ProductCatalog, справочники | Да — меняется редко |
CustomerSummary, профили | Спорно — пользователь видит свои старые данные после save |
OrderSummary | Нет — пользователь видит устаревший статус |
Balance, CreditLimit | Нет — stale money = инцидент |
Для Balance и OrderSummary — Delete* обязателен; TTL — только как страховка.
Eventual consistency — декларация в OpenAPI
Если GET /customers/{id}/summary может вернуть stale-данные — это часть контракта:
/customers/{id}/summary:
get:
summary: Сводка клиента
description: |
Возвращает сводку из кеша.
**Eventual consistency**: после `PATCH /customers/{id}` возможна
задержка до 15 минут до отражения изменений. Для немедленного
обновления — `GET /customers/{id}/summary?fresh=true`.
responses:
"200":
description: Сводка клиента (возможна задержка TTL)
Без декларации клиент пишет тест PATCH → immediate GET и сообщает о баге, который не баг — а архитектурный выбор.
Куда дальше
- Cache stampede —
singleflightиSetNXпротив stampede при evict. - Конфигурация —
CacheConfig, TTL изenvconfig,NoopCache. - Ключи — namespace-префиксы, sensitive-данные через
sha256. - Observability —
cache_evictions_total,slog.DebugContextдля evict. - Паттерны — write-through вместо evict, refresh-ahead.
- TTL — TTL как backup для invalidation.
- Где кешируем — что вообще кешировать.