Сессии и кэш-паттерны
Два самых частых способа применить 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-aside | Write-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 и ранним фоновым обновлением.
- Кэш — ускоритель, а не источник истины: он может исчезнуть, и система должна это пережить.