Главная задача Redis в этом проекте — разгрузить PostgreSQL. Не просто «добавить кеш», а выстроить осмысленную стратегию: что кешируем, на сколько, что инвалидируем немедленно. Мы разработали многоуровневый подход с разными TTL для разных типов данных.
Мы выделили три уровня кеша по критичности и частоте обновления:
# Уровень 1: Горячий кеш — меню ресторанов (TTL: 5 минут)
# Самые частые запросы, данные меняются редко
# Паттерн: Cache-Aside с фоновым обновлением
SET menu:restaurant:142 '{"items":[...]}' EX 300
# Уровень 2: Тёплый кеш — рейтинги, отзывы (TTL: 15 минут)
# Умеренная частота, обновляются при новом отзыве
SET ratings:restaurant:142 '{"avg":4.7,"count":1284}' EX 900
# Уровень 3: Сессионный кеш — корзины, геолокация (TTL: 30 минут)
# Привязаны к пользователю, теряют актуальность при закрытии приложения
SET cart:user:98765 '{"items":[{"dish_id":42,"qty":2}]}' EX 1800
# Геолокация курьеров — TTL 10 секунд (почти real-time)
GEOADD couriers:active 49.1234 55.7891 "courier:501"
SET courier:501:meta '{"name":"Ильдар","eta":12}' EX 10
Есть классическая ловушка — cache stampede, она же thundering herd. TTL популярного ключа истёк, и сотни запросов одновременно ломятся в PostgreSQL за одними и теми же данными. На нагруженном сервисе это моментально роняет базу. Мы закрыли проблему паттерном с мьютексом:
# Псевдокод на Go (пакет go-redis v9)
func GetRestaurantMenu(ctx context.Context, restaurantID int) (*Menu, error) {
key := fmt.Sprintf("menu:restaurant:%d", restaurantID)
// 1. Пытаемся получить из кеша
cached, err := rdb.Get(ctx, key).Result()
if err == nil {
var menu Menu
json.Unmarshal([]byte(cached), &menu)
return &menu, nil
}
// 2. Кеша нет — пробуем взять мьютекс
lockKey := fmt.Sprintf("lock:%s", key)
locked, err := rdb.SetNX(ctx, lockKey, 1, 10*time.Second).Result()
if locked {
// 3. Мы получили лок — грузим из PostgreSQL
defer rdb.Del(ctx, lockKey)
menu, err := db.LoadMenu(ctx, restaurantID)
if err != nil {
return nil, err
}
data, _ := json.Marshal(menu)
// 4. Сохраняем в Redis с TTL + jitter (240-360 сек)
jitter := time.Duration(rand.Intn(120)) * time.Second
rdb.Set(ctx, key, data, 240*time.Second+jitter)
return menu, nil
}
// 5. Другой горутине не удалось взять лок — ждём и повторяем
time.Sleep(100 * time.Millisecond)
return GetRestaurantMenu(ctx, restaurantID)
}
Jitter — случайный разброс TTL — не даёт всем ключам истекать одновременно. Вместо фиксированных 300 секунд каждый ключ живёт от 240 до 360 секунд. Мелкая деталь, которая на практике снимает целый класс проблем с пиковой нагрузкой.
Когда ресторан обновляет меню через панель управления, ждать истечения TTL нельзя — пользователи увидят устаревшие данные. Мы решили это через Pub/Sub + инвалидация: обновление в панели тут же публикует событие, подписчики на всех серверах приложений немедленно сбрасывают соответствующие ключи.
# При обновлении меню ресторатором:
# 1. Обновляем PostgreSQL
# 2. Удаляем кеш
DEL menu:restaurant:142
# 3. Публикуем событие для всех инстансов API
PUBLISH cache:invalidate '{"type":"menu","restaurant_id":142}'
# На каждом инстансе API подписчик слушает канал:
# SUBSCRIBE cache:invalidate
# При получении — удаляет локальный in-memory кеш (если есть)
# Для массовой инвалидации (например, изменение цен поставщика)
# используем Lua-скрипт для атомарного удаления по паттерну:
redis-cli -a 'S3cur3R3d1s!Pr0d2026' --eval /opt/redis/scripts/invalidate.lua
-- /opt/redis/scripts/invalidate.lua
local cursor = "0"
local deleted = 0
repeat
local result = redis.call('SCAN', cursor, 'MATCH', KEYS[1], 'COUNT', 100)
cursor = result[1]
local keys = result[2]
if #keys > 0 then
deleted = deleted + redis.call('UNLINK', unpack(keys))
end
until cursor == "0"
return deleted
-- Вызов: redis-cli EVAL "$(cat invalidate.lua)" 1 "menu:restaurant:*"