Опирается на правила:
R-CACHE-CFG-1…R-CACHE-CFG-5иR-CACHE-CFG-X1…R-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:
- Хрупкость. Добавили поле в
OrderSummary→ старыеgob-значения в Redis не читаются (gob: type mismatch).encoding/jsonигнорирует неизвестные поля по умолчанию. - Go-only.
gobчитает только Go. JSON читает любой инструмент:redis-cli, Python-скрипт для миграции данных, дебажный запрос. - Читаемость.
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-X1 | encoding/json |
sync.Map / ristretto в multi-instance проде | R-CACHE-CFG-X2 | redis/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-5 | Testcontainers 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.