
Кэш дает два прямых эффекта: быстрее ответ пользователю и меньше нагрузка на базу и внешние интеграции. Это превращается в рост конверсии, экономию на инфраструктуре и запас производительности для пиков.
Но кэш легко превращается в мину: устаревшие цены, неактуальные остатки, неверные статусы заказов. Ошибочная инвалидация в итоге бьет по SLA и деньгам. Цель статьи — показать, как строить кэш «без сюрпризов»: с предсказуемой согласованностью, контролируемой свежестью и измеримой пользой.
Кандидаты на кэш:
Лучше не кэшировать бездумно:
Правило отбора: ценность кэша = (частота чтений × стоимость запроса) − (риск устаревших данных × стоимость ошибки). Если ценность положительна — кэшируем, но спроектировав инвалидацию.
Выбор: для большинства B2C-витрин — cache-aside + write-through на критичных апдейтах (удаление/обновление ключа) и событийнaя инвалидация.
Способы держать кэш в актуальном состоянии:
Комбинируйте: явная инвалидация + короткий TTL как страховка.
Когда популярный ключ протухает, сотни запросов одновременно идут в базу. Что помогает:
Ниже — минимальный рабочий пример на Go: cache-aside + мягкий TTL + схлопывание запросов и блокировка, плюс явная инвалидация при изменении товара.
package main
import (
"context"
"encoding/json"
"errors"
"fmt"
"log"
"time"
"github.com/redis/go-redis/v9"
"golang.org/x/sync/singleflight"
)
type Product struct {
ID int64 `json:"id"`
Name string `json:"name"`
PriceCents int64 `json:"price_cents"`
UpdatedAt time.Time `json:"updated_at"`
}
type cachedProduct struct {
Data Product `json:"data"`
SoftUntil time.Time `json:"soft_until"` // после этого можно отдать устаревшее и обновить в фоне
}
var (
rdb = redis.NewClient(&redis.Options{Addr: "127.0.0.1:6379"})
sf singleflight.Group
)
const (
softTTL = 30 * time.Second
hardTTL = 5 * time.Minute
lockTTL = 10 * time.Second
)
func cacheKey(id int64) string { return fmt.Sprintf("product:%d", id) }
func lockKey(id int64) string { return fmt.Sprintf("lock:product:%d", id) }
// Заглушка: вместо этого используйте реальную БД
func loadFromDB(ctx context.Context, id int64) (Product, error) {
// Имитируем чтение из источника
return Product{ID: id, Name: fmt.Sprintf("Товар #%d", id), PriceCents: 12345, UpdatedAt: time.Now()}, nil
}
func saveToCache(ctx context.Context, key string, p Product) error {
val, _ := json.Marshal(cachedProduct{Data: p, SoftUntil: time.Now().Add(softTTL)})
return rdb.Set(ctx, key, val, hardTTL).Err()
}
func tryLock(ctx context.Context, key string) (bool, error) {
return rdb.SetNX(ctx, key, "1", lockTTL).Result()
}
func getProduct(ctx context.Context, id int64) (Product, error) {
key := cacheKey(id)
// Сначала пробуем из кэша
bs, err := rdb.Get(ctx, key).Bytes()
if err == nil {
var c cachedProduct
if jsonErr := json.Unmarshal(bs, &c); jsonErr == nil {
// Свежо
if time.Now().Before(c.SoftUntil) {
return c.Data, nil
}
// Мягко протухло: отдаем устаревшее, обновляем в фоне
go refreshProduct(context.Background(), id)
return c.Data, nil
}
}
// Промах: схлопываем запросы, чтобы только один ходил в источник
v, err, _ := sf.Do(key, func() (interface{}, error) {
p, e := loadFromDB(ctx, id)
if e != nil {
return nil, e
}
_ = saveToCache(ctx, key, p)
return p, nil
})
if err != nil {
return Product{}, err
}
return v.(Product), nil
}
func refreshProduct(ctx context.Context, id int64) {
lk := lockKey(id)
ok, err := tryLock(ctx, lk)
if err != nil || !ok {
return // кто-то уже обновляет
}
defer func() { _ = rdb.Del(ctx, lk).Err() }()
p, err := loadFromDB(ctx, id)
if err == nil {
_ = saveToCache(ctx, cacheKey(id), p)
}
}
// Явная инвалидация при изменении товара
func invalidateProduct(ctx context.Context, id int64) error {
return rdb.Del(ctx, cacheKey(id)).Err()
}
func main() {
ctx := context.Background()
p, err := getProduct(ctx, 42)
if err != nil {
log.Fatal(err)
}
log.Printf("Первый ответ: %+v", p)
// Обновление товара в источнике -> инвалидация кэша
if err := invalidateProduct(ctx, 42); err != nil {
log.Println("не смогли удалить ключ:", err)
}
p2, _ := getProduct(ctx, 42)
log.Printf("После инвалидации: %+v", p2)
}
Пояснения к примеру:
Чтобы гарантированно удалить/обновить кэш после изменения в базе, используем транзакционный outbox: в рамках той же транзакции, где меняем данные, пишем событие об инвалидации. Фоновый воркер читает outbox и удаляет ключи в Redis.
SQL-схема и триггер:
-- Таблица товаров (упрощенная)
CREATE TABLE IF NOT EXISTS products (
id BIGSERIAL PRIMARY KEY,
name TEXT NOT NULL,
price_cents BIGINT NOT NULL,
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
);
-- Outbox для инвалидации кэша
CREATE TABLE IF NOT EXISTS cache_outbox (
id BIGSERIAL PRIMARY KEY,
key TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ
);
-- Функция создает запись в outbox после апдейта товара
CREATE OR REPLACE FUNCTION notify_cache_invalidation() RETURNS trigger AS $$
BEGIN
INSERT INTO cache_outbox(key) VALUES (concat('product:', NEW.id));
RETURN NEW;
END;
$$ LANGUAGE plpgsql;
CREATE TRIGGER products_cache_invalidate
AFTER UPDATE ON products
FOR EACH ROW EXECUTE FUNCTION notify_cache_invalidation();
Простой воркер на Go, который обрабатывает outbox и удаляет ключи в Redis. Используем «выборку с блокировкой» (SKIP LOCKED), чтобы можно было запустить несколько воркеров параллельно.
package main
import (
"context"
"database/sql"
"log"
"time"
_ "github.com/jackc/pgx/v5/stdlib"
"github.com/redis/go-redis/v9"
)
type OutboxEvent struct {
ID int64
Key string
}
func runOutboxWorker(ctx context.Context, db *sql.DB, rdb *redis.Client) {
for {
select {
case <-ctx.Done():
return
default:
}
tx, err := db.BeginTx(ctx, &sql.TxOptions{})
if err != nil { time.Sleep(time.Second); continue }
rows, err := tx.QueryContext(ctx, `
SELECT id, key FROM cache_outbox
WHERE processed_at IS NULL
ORDER BY id
FOR UPDATE SKIP LOCKED LIMIT 100`)
if err != nil { _ = tx.Rollback(); time.Sleep(time.Second); continue }
var events []OutboxEvent
for rows.Next() {
var e OutboxEvent
if err := rows.Scan(&e.ID, &e.Key); err == nil {
events = append(events, e)
}
}
_ = rows.Close()
if len(events) == 0 {
_ = tx.Commit()
time.Sleep(500 * time.Millisecond)
continue
}
for _, e := range events {
if err := rdb.Del(ctx, e.Key).Err(); err != nil {
log.Println("DEL failed:", e.Key, err)
continue
}
_, _ = tx.ExecContext(ctx, `UPDATE cache_outbox SET processed_at = now() WHERE id = $1`, e.ID)
}
_ = tx.Commit()
}
}
Такой подход избавляет от гонок «сначала изменили базу — забыли удалить ключ», а также переживет кратковременные падения Redis или приложения.
Отслеживайте ежедневно:
Эффект: снижение нагрузки на базу в 2–10 раз на горячих эндпоинтах, ускорение ответа в 3–20 раз, рост конверсии за счет скорости, меньше аварий на распродажах за счет защиты от «набегов».
Чек‑лист:
Итог: кэш — это не просто «положили в Redis», а инженерная система со своими гарантиями. Если продумать инвалидацию, согласованность и метрики, вы получите быстрый, предсказуемый и дешевый в эксплуатации слой, который работает в плюс бизнесу, а не создает скрытые риски.