Опирается на правила: R-CACHE-CFG-1R-CACHE-CFG-5 и R-CACHE-CFG-X1R-CACHE-CFG-X4 из Caching Style Guide → раздел 2. Конфигурация.

Важно знать

  • Redis в продеredis/go-redis/v9; sync.Map и ristretto только для single-instance dev, в multi-instance каждый под имел бы свой кеш.
  • JSON через encoding/json — никогда encoding/gob: gob привязан к Go, ломается при изменении структуры; аналог Java native-сериализации.
  • Per-cache TTL — каждое поле CacheConfig явное; никакого единого DEFAULT_TTL для всех кешей.
  • CacheConfig из envconfig — TTL меняется переменной среды без перекомпиляции; SRE не трогает код.
  • Ошибка Redis при Set — логируем slog.WarnContext, не возвращаем; TTL сделает работу, write в кеш best-effort.
  • 0 в go-redis трактуется как «без TTL» (infinite), а не «ноль секунд» — передавать 0 нельзя.
  • В тестахtestcontainers-go с Redis-контейнером, не мок порта; мок теряет поведение TTL и eviction.
  • NoopCache при недоступном Redis в dev — явный, с предупреждением при старте; не nil-имплементация.

Redis вместо in-memory в проде

R-CACHE-CFG-1: prod-backend — redis/go-redis/v9.

Почему не sync.Map и не ristretto:

  • Multi-instance. 5 реплик order-service в K8s = 5 локальных кешей. CustomerSummary для cust-42 записана в pod-1, pod-2 её не знает. Пользователь, попавший на pod-2 после write, читает данные до изменения.
  • Invalidation race. Evict после UpdateCustomer сбрасывает кеш только в текущем поде. Остальные поды держат устаревшие значения до истечения TTL.
  • Observability. Локальный кеш — чёрный ящик; Redis — Redis Exporter для Prometheus, redis-cli, keyspace notifications.
// adapters/out/cache/redis.go
func NewRedisClient(cfg config.CacheConfig) *redis.Client {
    return redis.NewClient(&redis.Options{
        Addr:         cfg.RedisAddr,
        DialTimeout:  3 * time.Second,
        ReadTimeout:  1 * time.Second,
        WriteTimeout: 1 * time.Second,
    })
}

В dev-окружении, если Redis недоступен, — явный NoopCache с предупреждением при старте:

// adapters/out/cache/noop.go
type NoopCustomerCache struct{}

func NewNoopCustomerCache() *NoopCustomerCache {
    slog.Warn("customer cache: Redis unavailable, using no-op (dev only)")
    return &NoopCustomerCache{}
}

func (c *NoopCustomerCache) GetSummary(_ context.Context, _ string) (*CustomerSummary, error) {
    return nil, nil
}
func (c *NoopCustomerCache) SetSummary(_ context.Context, _ string, _ *CustomerSummary, _ time.Duration) error {
    return nil
}
func (c *NoopCustomerCache) DeleteSummary(_ context.Context, _ string) error { return nil }

Возврат nil-имплементации без предупреждения (R-CACHE-CFG-X4) — silent skip: cache-read молча ничего не кеширует, hit rate 0%, никто не замечает.

JSON-сериализация — никогда gob

R-CACHE-CFG-2: сериализация значений — encoding/json. encoding/gob запрещён.

Почему не gob:

  1. Хрупкость. Добавили поле в OrderSummary → старые gob-значения в Redis не читаются (gob: type mismatch). encoding/json игнорирует неизвестные поля по умолчанию.
  2. Go-only. gob читает только Go. JSON читает любой инструмент: redis-cli, Python-скрипт для миграции данных, дебажный запрос.
  3. Читаемость. redis-cli GET order-summaries:ord-777 возвращает читаемый JSON при отладке.
// adapters/out/cache/customer_cache.go
func (c *RedisCustomerCache) SetSummary(
    ctx context.Context,
    customerID string,
    v *CustomerSummary,
    ttl time.Duration,
) error {
    raw, err := json.Marshal(v)
    if err != nil {
        return fmt.Errorf("marshal customer summary %s: %w", customerID, err)
    }
    if err := c.redis.Set(ctx, summaryKey(customerID), raw, ttl).Err(); err != nil {
        return fmt.Errorf("redis set customer summary %s: %w", customerID, err)
    }
    return nil
}

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("redis 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
}

redis.Nil — не ошибка, а cache miss: возвращаем nil, nil. Handler обрабатывает это как сигнал идти в репозиторий.

Per-cache TTL через CacheConfig

R-CACHE-CFG-3 + R-CACHE-CFG-4: каждый именованный кеш — свой TTL; TTL берётся из конфига, не хардкодится.

// config/config.go
type CacheConfig struct {
    RedisAddr          string        `envconfig:"REDIS_ADDR"                    required:"true"`
    CustomerSummaryTTL time.Duration `envconfig:"CACHE_CUSTOMER_SUMMARY_TTL"    default:"15m"`
    ProductCatalogTTL  time.Duration `envconfig:"CACHE_PRODUCT_CATALOG_TTL"     default:"6h"`
    FeatureFlagsTTL    time.Duration `envconfig:"CACHE_FEATURE_FLAGS_TTL"       default:"60s"`
    BalanceTTL         time.Duration `envconfig:"CACHE_BALANCE_TTL"             default:"15s"`
    OrderSummaryTTL    time.Duration `envconfig:"CACHE_ORDER_SUMMARY_TTL"       default:"5m"`
}

Инициализация через kelseyhightower/envconfig:

// main.go
var cacheCfg config.CacheConfig
if err := envconfig.Process("", &cacheCfg); err != nil {
    log.Fatalf("cache config: %v", err)
}

Передача TTL в Set — всегда из конфига:

// 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 {
        return cached, nil
    }

    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
}

Почему TTL — поле конфига, а не константа в cache.go:

  • SRE регулирует TTL переменной среды под нагрузку без пересборки образа.
  • Добавление нового кеша = новое поле CacheConfig; envconfig даст ошибку при отсутствии required:"true" переменной.
  • default: закрывает типовой случай; staging и прод переопределяют через env.

R-CACHE-CFG-X3: единый DEFAULT_TTL=15m для CustomerSummary, Balance и ProductCatalog одновременно — либо баланс слишком долго устаревший, либо каталог пересчитывается бессмысленно часто.

Структура порта и реализации

В Go нет декларативного @Cacheable; кеш — явный cache-aside через порт в core/:

// 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
}
// adapters/out/cache/customer_cache.go
type RedisCustomerCache struct {
    redis *redis.Client
    cfg   config.CacheConfig
}

func NewRedisCustomerCache(rdb *redis.Client, cfg config.CacheConfig) *RedisCustomerCache {
    return &RedisCustomerCache{redis: rdb, cfg: cfg}
}

Порт живёт в core/ — domain-слой не зависит от go-redis. Реализация в adapters/out/cache/ подключается через DI в main.go.

Тесты — Testcontainers, не мок порта

R-CACHE-CFG-5: для проверки реального поведения TTL и eviction — Testcontainers Redis.

// adapters/out/cache/customer_cache_test.go
func TestSetAndGet_RoundTrip(t *testing.T) {
    ctx := context.Background()
    req := testcontainers.ContainerRequest{
        Image:        "redis:7-alpine",
        ExposedPorts: []string{"6379/tcp"},
        WaitingFor:   wait.ForListeningPort("6379/tcp"),
    }
    container, err := testcontainers.GenericContainer(ctx, testcontainers.GenericContainerRequest{
        ContainerRequest: req,
        Started:          true,
    })
    require.NoError(t, err)
    t.Cleanup(func() { _ = container.Terminate(ctx) })

    host, _ := container.Host(ctx)
    port, _ := container.MappedPort(ctx, "6379")
    rdb := redis.NewClient(&redis.Options{Addr: host + ":" + port.Port()})

    cfg := config.CacheConfig{CustomerSummaryTTL: 5 * time.Second}
    cache := NewRedisCustomerCache(rdb, cfg)

    summary := &CustomerSummary{
        CustomerID: "cust-sber-42",
        FullName:   "Иван Петров",
        Segment:    "premium",
    }
    require.NoError(t, cache.SetSummary(ctx, summary.CustomerID, summary, cfg.CustomerSummaryTTL))

    got, err := cache.GetSummary(ctx, summary.CustomerID)
    require.NoError(t, err)
    assert.Equal(t, summary.FullName, got.FullName)
}

func TestGetSummary_AfterTTL_ReturnsMiss(t *testing.T) {
    // аналогично, TTL=1s, time.Sleep(2s), GetSummary → nil
}

Мок CustomerCache-интерфейса проверяет только «был ли вызов», но не то, что значение действительно записалось в Redis и вернулось обратно через json.Unmarshal. TTL-тест с мок-интерфейсом бессмысленен — мок не умеет «забывать» значение.

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

АнтипаттернПравилоЧто взамен
encoding/gob для сериализацииR-CACHE-CFG-X1encoding/json
sync.Map / ristretto в multi-instance продеR-CACHE-CFG-X2redis/go-redis/v9
Единый DEFAULT_TTL для всех кешейR-CACHE-CFG-X3отдельное поле CacheConfig на каждый кеш
nil-имплементация порта без предупрежденияR-CACHE-CFG-X4явный NoopCache с slog.Warn при старте
c.redis.Set(ctx, key, val, 0)R-CACHE-TTL-X1передавать ttl из CacheConfig, никогда 0
TTL-константа прямо в методеR-CACHE-CFG-4поле CacheConfig, env-тег с default:
Мок CustomerCache-интерфейса в тестахR-CACHE-CFG-5Testcontainers Redis

Куда дальше

  • Где кешируем — какие данные кешировать, а какие нет.
  • Ключи — namespace-префикс, kebab-case, хеширование sensitive-данных.
  • TTL — типовые значения по характеру данных.
  • Invalidation — evict на write, доменные события как триггер инвалидации.
  • Паттерны — cache-aside, write-through, refresh-ahead на Go-идиомах.
  • Cache stampede — singleflight и SetNX-lock для защиты от stampede.
  • Observability — cache_hits_total, cache_misses_total, alert на hit rate.