Опирается на правила: R-CACHE-KEY-1R-CACHE-KEY-4 и R-CACHE-KEY-X1R-CACHE-KEY-X4 из Caching Rules → раздел 3. Ключи.

Важно знать

  • В Go нет декларативного @Cacheable — ключ формируется явно в функции adapters/out/cache/keys.go.
  • Namespace через префикс: "customer-profiles:" + customerID. Redis key: customer-profiles:42.
  • Имена namespace в kebab-case: customer-profiles, order-summaries, feature-flags. Не camelCase, не snake_case.
  • Составной ключ — явный join через :, задокументированный в функции.
  • Sensitive данные (email, phone, токены) в ключе plain-text — утечка в мониторинг. Хешируй через crypto/sha256.
  • fmt.Sprintf("%v", args...) как ключ — ломается при изменении количества аргументов.
  • Указатель или адрес struct в ключе — каждый вызов новый ключ, hit rate = 0%.
  • Один общий namespace (shared-cache:) для разных сущностей — TTL, метрики и evict перемешаются.

Ключ — то, по чему Redis находит значение. В Go он формируется явно, без магии фреймворка. Это хорошо: поведение предсказуемо и лежит в одном месте — файле keys.go. Всё, что нужно, это соблюдать три вещи: правильный namespace, явный детерминированный ключ, защита PII.

Namespace через префикс

R-CACHE-KEY-1: каждый тип данных живёт в своём namespace — отдельной строке-префиксе.

// adapters/out/cache/keys.go
func customerSummaryKey(customerID string) string { return "customer-profiles:" + customerID }
func orderSummaryKey(orderID string) string       { return "order-summaries:" + orderID }
func productKey(productID string) string          { return "product-catalog:" + productID }
func featureFlagKey(name string) string           { return "feature-flags:" + name }

Redis key выглядит так: customer-profiles:42. Разделитель : — Redis-конвенция для вложенных namespaces.

Что это даёт:

  • Изолированный evict. DEL customer-profiles:42 не затрагивает order-summaries:.
  • Читаемые метрики. cache_hits_total{cache="customer-profiles"} — статистика по типу данных отдельно.
  • Навигация в redis-cli. SCAN 0 MATCH customer-profiles:* — все ключи одного namespace.

Kebab-case для имён namespace

R-CACHE-KEY-2: имена namespace — kebab-case slug, как URL-пути и event-topics.

customer-profiles    ✓
order-summaries      ✓
feature-flags        ✓
payment-methods      ✓
customerProfiles     ✗ — camelCase
Customer_Profiles    ✗ — mixed
shared               ✗ — нет смысла

Соглашение сквозное: URL /customer-profiles/42, Redis key customer-profiles:42, Prometheus label cache="customer-profiles" — всё одно имя.

Явный ключ — одна функция на тип

R-CACHE-KEY-3: для каждого типа данных — отдельная именованная функция. Никакого fmt.Sprintf("%v", args...).

Простой ключ по ID:

func customerSummaryKey(customerID string) string {
    return "customer-profiles:" + customerID
}

Использование в Use Case Handler:

// core/customer/query_handler.go
func (h *GetCustomerSummaryHandler) Handle(ctx context.Context, q GetCustomerSummaryQuery) (*CustomerSummary, error) {
    cached, err := h.cache.GetSummary(ctx, q.CustomerID)
    if err == nil && cached != nil {
        return cached, nil
    }
    summary, err := h.repo.GetSummary(ctx, q.CustomerID)
    if err != nil {
        return nil, err
    }
    _ = h.cache.SetSummary(ctx, q.CustomerID, summary, h.cfg.Cache.CustomerSummaryTTL)
    return summary, nil
}

Адаптер знает только ключ, хендлер знает только customerID — разделение сохранено.

Составной ключ

R-CACHE-KEY-4: несколько параметров — явный join через :, в отдельной именованной функции.

// adapters/out/cache/keys.go
func ordersByCustomerKey(customerID, status string) string {
    return "order-summaries:by-customer:" + customerID + ":" + status
}

func productBySlugKey(categoryID, slug string) string {
    return "product-catalog:by-slug:" + categoryID + ":" + slug
}

Redis key: order-summaries:by-customer:42:CONFIRMED.

Когда полей больше четырёх — выносим в именованный builder:

type OrderSearchKey struct {
    CustomerID string
    Status     string
    FromDate   string
    ToDate     string
    Page       int
    Size       int
}

func (k OrderSearchKey) String() string {
    return strings.Join([]string{
        "order-search",
        k.CustomerID,
        k.Status,
        k.FromDate,
        k.ToDate,
        strconv.Itoa(k.Page),
        strconv.Itoa(k.Size),
    }, ":")
}

Использование:

key := OrderSearchKey{
    CustomerID: cmd.CustomerID,
    Status:     cmd.Status,
    FromDate:   cmd.From.Format("2006-01-02"),
    ToDate:     cmd.To.Format("2006-01-02"),
    Page:       cmd.Page,
    Size:       cmd.Size,
}.String()

Преимущество перед fmt.Sprintf: порядок полей явный, добавление нового поля не ломает старые ключи молча.

Sensitive данные — sha256

R-CACHE-KEY-X4: Redis-ключи видны в redis-cli MONITOR, slow query log, резервных копиях, сетевом перехвате при non-TLS. Email или токен в plain ключе — утечка.

// adapters/out/cache/keys.go
import (
    "crypto/sha256"
    "encoding/hex"
)

func customerByEmailKey(email string) string {
    h := sha256.Sum256([]byte(email))
    return "customer-by-email:" + hex.EncodeToString(h[:])
}

func sessionKey(token string) string {
    h := sha256.Sum256([]byte(token))
    return "sessions:" + hex.EncodeToString(h[:])
}

Лучше — не использовать sensitive данные как идентификатор вообще. Внутренний customerID (UUID или BIGINT) идеален: уникален, не несёт PII, безопасен в ключе.

Пример с OrderID на доменах Sber — безопасно:

// account-id не PII в контексте Redis (UUID, не номер счёта)
func accountSummaryKey(accountID string) string {
    return "account-summaries:" + accountID
}

// номер карты — PII, хешируем
func cardLimitKey(maskedPAN string) string {
    h := sha256.Sum256([]byte(maskedPAN))
    return "card-limits:" + hex.EncodeToString(h[:])
}

Что запрещено

АнтипаттернПравилоЧто взамен
fmt.Sprintf("%v", args...) как ключR-CACHE-KEY-X1явная именованная функция
Указатель struct fmt.Sprintf("%p", &req) в ключеR-CACHE-KEY-X2поля struct через :
Один namespace shared-cache: для разных сущностейR-CACHE-KEY-X3per-entity префикс
PII/токены plain в ключеR-CACHE-KEY-X4crypto/sha256 + hex.EncodeToString
camelCase / snake_case в имени namespaceR-CACHE-KEY-2kebab-case slug
Ключ собирается inline в хендлереR-CACHE-KEY-3функция в keys.go
Разделитель _ в составном ключеR-CACHE-KEY-3: (Redis-конвенция)
Глобальная переменная с форматом ключаR-CACHE-KEY-3именованная функция xyzKey(id)

Куда дальше

  • TTL — per-cache TTL через CacheConfig; 0 в go-redis = infinite.
  • Invalidation — DeleteSummary использует те же ключевые функции.
  • Где кешируем — какие проекции стоит ключевать, какие нет.
  • Конфигурация — CacheConfig с envconfig и per-cache TTL.
  • Observability — label cache в Prometheus = имя namespace.
  • Cache stampede — singleflight и SetNX-lock по тому же ключу.
  • Паттерны — cache-aside, write-through, refresh-ahead на этих ключах.