Опирается на правила: R-CACHE-INV-1R-CACHE-INV-4 и R-CACHE-INV-X1R-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-X1DeleteSummary(ctx, id) — точечный evict
Только TTL для money (Balance) и ordersR-CACHE-INV-X2Delete* на каждом write-методе
Eventual consistency без OpenAPI descriptionR-CACHE-INV-X3"Значение актуально с задержкой до 30 секунд" в описании endpoint
Evict до repo.UpdateR-CACHE-INV-1Сначала запись в БД, потом evict
Пропустить Delete* при добавлении нового write use caseR-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 и OrderSummaryDelete* обязателен; 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.
  • Где кешируем — что вообще кешировать.