
Кэш — это «быстрый снимок» данных, который позволяет отвечать клиенту за миллисекунды и разгружать дорогие ресурсы: базу, микросервисы, внешние платёжки. Правильно выстроенный кэш сокращает счёт за инфраструктуру, выдерживает трафиковые пики и уменьшает хвостовые задержки (p95/p99), что напрямую влияет на конверсию и выручку.
Но кэш, настроенный абы как, делает обратное: показывает клиенту старые цены, не учитывает отмены, портит отчёты и вызывает инциденты с откатами. Ключевая сложность — не «что кэшировать», а «как безопасно инвалидировать и обновлять».
Слои есть почти всегда — даже если вы о них не думаете:
Важно понимать, что любой слой может случайно кэшировать приватные данные, если вы не выставляете правильные заголовки. Слои должны работать согласованно.
Не удаляйте тысячу ключей — измените «версию пространства имён» и начните писать под новым префиксом. Старое само умрёт по TTL.
При изменении данных (создание/апдейт товара) публикуйте событие. Подписчики (приложение, воркер) точечно очищают или обновляют кэш. Это снижает TTL и даёт ближе к «read-your-writes» для критичных сценариев.
Начните с корректных заголовков. Пример для публичных GET-эндпоинтов каталога:
curl -i https://api.example.com/v1/catalog/popular
# Должно вернуться что-то вроде:
# Cache-Control: public, max-age=60, stale-while-revalidate=30, stale-if-error=300
# ETag: "a1b2c3d4"
# Vary: Accept-Encoding
Конфиг NGINX для кэша публичных GET без авторизации:
proxy_cache_path /var/cache/nginx keys_zone=api_cache:100m max_size=1g inactive=10m use_temp_path=off;
map $http_authorization $skip_cache {
default 1; # если есть Authorization — не кэшируем
"" 0; # без Authorization — можно кэшировать
}
server {
listen 80;
server_name api.example.com;
location /v1/catalog/ {
proxy_pass http://backend;
# Ключ кэша: метод+хост+URI, добавьте важные query-параметры через $arg_
proxy_cache_key "$scheme$request_method$host$request_uri";
proxy_cache api_cache;
proxy_cache_bypass $skip_cache;
proxy_no_cache $skip_cache;
proxy_cache_valid 200 60s; # базовый TTL
proxy_cache_use_stale error timeout updating http_500 http_502 http_503 http_504;
add_header Cache-Control 'public, max-age=60, stale-while-revalidate=30, stale-if-error=300' always;
add_header Vary 'Accept-Encoding' always;
}
}
Если у вас CDN, переносим эти правила туда, а на NGINX оставляем как fallback.
Ниже — минимальный, но рабочий пример на Go: cache-aside + мягкий/жёсткий TTL, коалесcинг запросов и версионирование ключей по tenant.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"os"
"strconv"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
// Доменные данные
type Product struct {
ID string `json:"id"`
Name string `json:"name"`
Price int64 `json:"price"`
UpdatedAt time.Time `json:"updated_at"`
}
// Обёртка значения в кэше с мягким/жёстким TTL и версией
type cachedItem struct {
Data Product `json:"data"`
SoftTTL int64 `json:"soft_ttl"` // unix seconds
HardTTL int64 `json:"hard_ttl"` // unix seconds
Version int64 `json:"version"`
}
var (
ctx = context.Background()
rdb *redis.Client
sfGroup singleflight.Group
)
func main() {
redisAddr := getenv("REDIS_ADDR", "127.0.0.1:6379")
rdb = redis.NewClient(&redis.Options{Addr: redisAddr})
if err := rdb.Ping(ctx).Err(); err != nil {
log.Fatalf("redis ping failed: %v", err)
}
defer rdb.Close()
tenant := "t1"
id := "p42"
// Пример: многократные запросы, демонстрация кэша и SWR
for i := 0; i < 5; i++ {
p, stale, err := GetProduct(ctx, tenant, id)
if err != nil {
log.Printf("get error: %v", err)
continue
}
log.Printf("got product: %+v (stale=%v)", p, stale)
time.Sleep(2 * time.Second)
}
// Пример инвалидации: bump версии tenant (массовая инвалидация пространства)
if err := BumpTenantVersion(ctx, tenant); err != nil {
log.Printf("bump version error: %v", err)
}
p, stale, err := GetProduct(ctx, tenant, id)
log.Printf("after bump: %+v (stale=%v) err=%v", p, stale, err)
}
func getenv(k, def string) string {
if v := os.Getenv(k); v != "" {
return v
}
return def
}
// --- БД-симуляция ---
func fetchProductFromDB(id string) (Product, error) {
// Здесь обращение к реальной БД. Мы симулируем задержку.
time.Sleep(50 * time.Millisecond)
if id == "" {
return Product{}, errors.New("empty id")
}
return Product{
ID: id,
Name: "Demo Product",
Price: 1990,
UpdatedAt: time.Now(),
}, nil
}
// --- Версионирование tenant ---
func tenantVersionKey(tenant string) string {
return fmt.Sprintf("tenant:%s:version", tenant)
}
func getTenantVersion(ctx context.Context, tenant string) (int64, error) {
vk := tenantVersionKey(tenant)
v, err := rdb.Get(ctx, vk).Result()
if err == redis.Nil {
// Инициализируем версию = 1
if err := rdb.Set(ctx, vk, 1, 0).Err(); err != nil {
return 0, err
}
return 1, nil
}
if err != nil {
return 0, err
}
iv, _ := strconv.ParseInt(v, 10, 64)
if iv == 0 {
iv = 1
}
return iv, nil
}
func BumpTenantVersion(ctx context.Context, tenant string) error {
vk := tenantVersionKey(tenant)
return rdb.Incr(ctx, vk).Err()
}
// --- Кэш ---
func productCacheKey(tenant string, version int64, id string) string {
return fmt.Sprintf("tenant:%s:product:v%d:%s", tenant, version, id)
}
// GetProduct: cache-aside + soft/hard TTL + singleflight
func GetProduct(ctx context.Context, tenant, id string) (p Product, stale bool, err error) {
ver, err := getTenantVersion(ctx, tenant)
if err != nil {
return p, false, err
}
key := productCacheKey(tenant, ver, id)
// Попытка чтения из кэша
raw, err := rdb.Get(ctx, key).Bytes()
if err == nil {
var ci cachedItem
if json.Unmarshal(raw, &ci) == nil {
now := time.Now().Unix()
// Проверим soft/hard TTL
if now <= ci.HardTTL {
stale = now > ci.SoftTTL
if stale {
// Фоновое обновление (коалесcинг), не блокируем выдачу
go func() { _, _ = sfGroup.Do(key+":refresh", func() (interface{}, error) {
return refreshAndSet(ctx, tenant, id)
}) }()
}
return ci.Data, stale, nil
}
}
}
if err != nil && err != redis.Nil {
log.Printf("redis get error: %v", err)
}
// Промах или hard TTL истёк — коалесcинг, чтобы не штормить БД
res, err, _ := sfGroup.Do(key+":miss", func() (interface{}, error) {
return refreshAndSet(ctx, tenant, id)
})
if err != nil {
return p, false, err
}
ci := res.(cachedItem)
return ci.Data, false, nil
}
func refreshAndSet(ctx context.Context, tenant, id string) (cachedItem, error) {
ver, err := getTenantVersion(ctx, tenant)
if err != nil {
return cachedItem{}, err
}
p, err := fetchProductFromDB(id)
if err != nil {
return cachedItem{}, err
}
softTTL := time.Now().Add(30 * time.Second).Unix() // отдаём stale после 30с
hardTTL := time.Now().Add(5 * time.Minute).Unix() // ключ живёт до 5 минут
ci := cachedItem{Data: p, SoftTTL: softTTL, HardTTL: hardTTL, Version: ver}
b, _ := json.Marshal(ci)
key := productCacheKey(tenant, ver, id)
// EX = hard TTL, Redis сам удалит ключ после.
err = rdb.Set(ctx, key, b, 5*time.Minute).Err()
return ci, err
}
Что здесь важно для продакшена:
Алерты:
Предположим, у вас 10 000 RPS на публичные GET, 60% из них кэшируемы. Стоимость обработки 1 запроса на бэкенде — 0,02 ₽ (CPU, БД, сеть). CDN+NGINX+Redis дают 80% hit на этой доле:
Даже если цифры скромнее, инвестиция окупается очень быстро.
Чек‑лист:
Анти‑паттерны:
Кэш — это про бизнес-результат: меньше затрат, быстрее ответы, выше конверсия. Чтобы он не ломал заказы и отчёты, используйте простые и надёжные практики: правильные заголовки на периметре, cache-aside в приложении, мягкие/жёсткие TTL, коалесcинг и версионирование ключей. С метриками и чек‑листом вы получите ускорение без сюрпризов и с контролируемой консистентностью.