Сессии и кэш-паттерны

Два самых частых способа применить Redis в вебе — держать пользовательские сессии и разгружать базу кэшем — и грабли, на которые наступают, когда кэш «протухает» у всех сразу.

Кэш-паттерн — это договорённость о том, кто и когда кладёт данные в кэш и убирает их оттуда. От выбранного паттерна (cache-aside, write-through и др.) зависит, насколько свежи данные, как ведёт себя система при промахах и что произойдёт под нагрузкой.

Зачем это нужно на практике

Сессии и кэш — это база почти любого веб-бэкенда. Сессии нужно хранить вне процесса, чтобы пользователь не «разлогинивался» при попадании на другой инстанс за балансировщиком и при перезапуске приложения. Кэш нужен, чтобы не дёргать медленную основную БД на каждый одинаковый запрос. Redis закрывает обе задачи: он быстрый, общий для всех инстансов и умеет сам удалять устаревшее по TTL.

Хранение сессий с TTL

Сессию удобно держать в хеше: ключ — идентификатор сессии (он же в куке пользователя), поля — данные сессии. TTL делает «протухание» неактивных сессий автоматическим — отдельный сборщик мусора не нужен.

HSET session:abc123 user_id 42 role admin   # данные сессии
EXPIRE session:abc123 1800                   # жить 30 минут без активности
HGETALL session:abc123                        # прочитать сессию на каждом запросе

Частое требование — «скользящая сессия»: окно бездействия отсчитывается заново при каждом запросе. Для этого на каждом обращении продлевают срок ключа, сдвигая момент истечения в будущее.

EXPIRE session:abc123 1800   # каждый активный запрос отодвигает протухание на 30 минут вперёд

Так активный пользователь остаётся в системе сколько угодно, а забытая вкладка автоматически выйдет через 30 минут тишины. Для «выхода со всех устройств» достаточно удалить ключи сессий пользователя.

Cache-aside: ленивое наполнение из приложения

Самый распространённый паттерн. Кэш живёт «сбоку»: приложение само спрашивает кэш, при промахе идёт в БД, кладёт результат в кэш и отдаёт ответ. Redis о существовании БД не знает.

# Cache-aside: при промахе читаем из «БД», кладём в кэш, дальше отдаём из кэша.
cache = {}                       # имитация Redis
db = {1: "Анна", 2: "Борис"}     # имитация базы
db_calls = 0                     # счётчик обращений к БД

def get_user(uid):
    global db_calls
    if uid in cache:                 # 1) пробуем кэш
        return cache[uid], "cache"
    db_calls += 1                    # 2) промах — идём в БД
    value = db[uid]
    cache[uid] = value               # 3) кладём в кэш на будущее
    return value, "db"

# Первый запрос — промах, второй того же id — попадание
print(get_user(1))   # из БД
print(get_user(1))   # уже из кэша
print(get_user(2))   # из БД
print(get_user(2))   # из кэша
print("Обращений к БД:", db_calls)

Вывод:

('Анна', 'db')
('Анна', 'cache')
('Борис', 'db')
('Борис', 'cache')
Обращений к БД: 2

Четыре запроса — но в БД сходили лишь дважды: повторные чтения обслужил кэш. В реальном Redis вместо словаря был бы GET, а при промахе — SET key value EX 300, чтобы запись жила ограниченное время. Плюсы cache-aside: простота, кэшируется только то, что реально читают, падение Redis не ломает запись (приложение просто чаще ходит в БД). Минус — данные в кэше могут устареть относительно БД.

Cache-aside против write-through

Разница в том, кто и когда обновляет кэш при записи. В cache-aside кэш наполняется лениво при чтении, а при изменении данных запись в кэше инвалидируют (удаляют). В write-through приложение при каждой записи синхронно обновляет и БД, и кэш — кэш всегда «горячий» и свежий ценой более медленной записи.

СвойствоCache-asideWrite-through
Кто пишет в кэшПриложение при промахе чтенияПриложение при каждой записи
Свежесть данныхМожет отставать от БДКэш всегда актуален
Что кэшируетсяТолько реально читаемоеВсё записанное, даже если не читают
Скорость записиБыстрее (кэш не трогаем синхронно)Медленнее (две записи)
При изменении данныхИнвалидировать (удалить) ключПерезаписать ключ

На практике cache-aside выбирают по умолчанию для чтения-преобладающих нагрузок; write-through уместен, когда нельзя показывать устаревшие данные и записи относительно редки. Отдельно стоит грамотная инвалидация: при изменении сущности проще и надёжнее удалить её ключ из кэша (DEL user:42), чем пытаться его аккуратно обновить — следующее чтение само наполнит кэш свежим значением.

Защита от cache stampede

Cache stampede (он же «эффект толпы», thundering herd) — это когда у популярного ключа истекает TTL, и сотни одновременных запросов разом промахиваются мимо кэша и все вместе бьют по БД, пересчитывая одно и то же. БД может не выдержать этого синхронного всплеска. Есть несколько приёмов смягчения.

  • Блокировка пересчёта. При промахе только один запрос берёт распределённый лок (как в первом уроке раздела) и пересчитывает значение, кладя его в кэш. Остальные ждут или коротко отдают прежнее/пустое значение, не дублируя тяжёлый запрос к БД.
  • Джиттер в TTL. Не ставьте всем ключам одинаковый ровный срок — добавьте случайный разброс (например, 300 секунд ± случайные 0–60). Тогда массово созданные записи не протухнут одновременно, и нагрузка на пересчёт размажется во времени.
  • Раннее обновление (probabilistic early expiration). Иногда значение обновляют чуть раньше истечения TTL фоном, чтобы к моменту реального протухания свежая версия уже лежала в кэше.
# джиттер: вместо ровного EX 300 — случайный разброс, чтобы ключи не протухли разом
SET page:home <html> EX 327   # 300 + случайные 0..60 секунд, считается в приложении

Как это работает под капотом

И сессии, и кэш опираются на механизм истечения ключей Redis: у ключа есть срок, по достижении которого Redis удаляет его (по таймеру или лениво при обращении). EXPIRE на каждом запросе просто переносит этот момент в будущее — так получается скользящее окно сессии. При нехватке памяти включается политика вытеснения (eviction): с подходящей настройкой (например, allkeys-lru) Redis сам выбрасывает давно не используемые ключи, что естественно подходит кэшу — холодные записи уходят первыми. Антистампедная блокировка работает ровно как лок из первого урока: атомарный SET ... NX пропускает к пересчёту лишь одного, а остальные не дублируют дорогой поход в БД.

Частые ошибки

  • Сессии без TTL. Они будут копиться вечно и засорять память; протухание должно быть автоматическим.
  • Кэш без срока жизни. Запись без EX рискует устареть навсегда и занимать память; почти всегда кэш-ключам нужен TTL.
  • Обновлять кэш вместо инвалидации. При изменении данных надёжнее удалить ключ — следующее чтение наполнит его свежим; ручное обновление легко рассинхронизировать.
  • Одинаковый ровный TTL у всех ключей. Прямой путь к cache stampede; добавляйте джиттер.
  • Считать кэш источником истины. Кэш можно потерять в любой момент (рестарт, вытеснение); правда живёт в основной БД, кэш лишь ускоряет чтение.

Итоги

  • Сессии в Redis (хеш + TTL) переживают рестарты и работают одинаково на всех инстансах; продление EXPIRE даёт скользящее окно.
  • Cache-aside: приложение лениво наполняет кэш при промахе и инвалидирует ключ при изменении данных — простой выбор по умолчанию.
  • Write-through держит кэш всегда свежим, синхронно обновляя его при каждой записи, ценой более медленной записи.
  • Cache stampede лечат блокировкой пересчёта, джиттером TTL и ранним фоновым обновлением.
  • Кэш — ускоритель, а не источник истины: он может исчезнуть, и система должна это пережить.
Проверьте себя
1. Чем паттерн cache-aside отличается от write-through?
AВ cache-aside кэш наполняется лениво при промахе чтения, а при изменении данных ключ инвалидируют; в write-through приложение синхронно обновляет кэш при каждой записи
BCache-aside работает только с сессиями, а write-through — только с кэшем страниц
CВ cache-aside Redis сам ходит в базу данных, а в write-through — нет
DЭто два названия одного и того же подхода
2. Что такое cache stampede и какой приём помогает его смягчить?
AЭто переполнение памяти Redis; помогает увеличить RAM
BЭто когда у популярного ключа истекает TTL и множество запросов разом промахиваются и бьют по БД; помогает блокировка пересчёта и джиттер (разброс) в TTL
CЭто потеря соединения с Redis; помогает реконнект
DЭто когда сессия истекает слишком рано; помогает убрать TTL