Раздел 10 · Databases

Redis и ClickHouse · спец-БД для кэша и аналитики

middle-senior~100 мин

без входаВойди, чтобы отмечать прогресс, вести заметки и решать задачи в редакторе. войти

Redis и ClickHouse · спец-БД для кэша и аналитики

Postgres закрывает 90% задач, и в уроке 01 мы договорились брать его по умолчанию. Этот урок про оставшиеся 10%. Когда дашборд BatSchool тормозит на агрегатах по time_pings за месяц, и EXPLAIN ANALYZE не лечит. Когда rate-limit на магические ссылки нужен в проде на десятке инстансов сразу, а не в локальной таблице. Когда воркер должен сделать ретраи и not lose jobs при рестарте. Тогда рядом с Postgres появляются Redis и ClickHouse.

Цель урока, понимать, когда и зачем тянуть каждую из этих систем, какие у них трейдоффы и какие классы багов они приносят с собой. Без глубокого погружения в ClickHouse (этим занимается отдельный раздел 26) и без энциклопедии Redis-команд (это уже Google). Только то, что Middle+ должен знать, чтобы спроектировать кэш, очередь, лидерборд или sliding-window-лимит, не выстрелив себе в ногу.

К концу занятия ты сможешь:

  • выбрать структуру данных Redis под задачу, понимать стоимость каждой команды по сложности;
  • реализовать cache-aside и осознанно решить, когда брать write-through или write-behind;
  • написать sliding-window rate limiter атомарно через Lua или transaction;
  • собрать очередь поверх Redis (LIST или Streams), понимать гарантии BullMQ;
  • объяснить, почему Redlock небезопасен в общем случае, и когда SET NX EX достаточно;
  • спроектировать MergeTree-таблицу под аналитику BatSchool, посчитать DAU через materialized view;
  • осознанно отказаться от ClickHouse там, где он сделает хуже.

0. Зачем рядом с Postgres что-то ещё

В уроке 01 мы зафиксировали Postgres-first принцип. Это не значит “никогда ничего другого”. Это значит, что каждое дополнительное хранилище в стеке добавляет операционных расходов: бэкапы, мониторинг, дежурный ночью, новый класс инцидентов, новый набор знаний у команды. Поэтому добавлять второе хранилище имеет смысл только когда есть доказанная причина.

Двух таких причин в типичном бэкенде ровно две.

Первая, скорость отклика на горячий ключ. Postgres держит горячие страницы в shared_buffers, и точечный SELECT по primary key стоит миллисекунды. Но даже миллисекунды дорого, если ты на каждом HTTP-запросе читаешь сессию, проверяешь rate limit, обновляешь last_seen и считаешь “сколько раз этот URL открыли за час”. На десятке таких операций к запросу прилипают десятки миллисекунд, плюс блокировки в Postgres под write-нагрузкой. Тут помогает Redis: 1 RTT по сети, 1 операция в памяти, 50 микросекунд на ответ.

Вторая, аналитика на широких таблицах. Когда менеджер открывает дашборд “сколько уникальных менти прошли урок X за неделю, в разбивке по разделу, с среднем временем”, Postgres начинает плакать. Запрос на 10 миллионов time_pings со сложной агрегацией легко улетает в 30 секунд. Не потому, что Postgres плохой, а потому что row-store с B-tree индексами архитектурно проигрывает column-store на таких паттернах. Тут помогает ClickHouse: те же 10 миллионов агрегируются за 200 миллисекунд.

Это два разных мира.

RedisClickHousePostgres
МодельKV в RAMКолоночная на дискеRow-store с буфером в RAM
Сильная сторонасверхбыстрый GET/SET, структуры данныхагрегаты на миллионах строкOLTP, ACID, JOIN’ы
Слабая сторонаразмер RAM, JOIN, аналитикаUPDATE, точечные запросы, ACIDагрегаты на 100M+ строк
Когда братьгорячие ключи, очереди, лимитыдашборды, аналитика, логивсё остальное

Дальше в уроке мы разберём по очереди обе системы. Redis сначала, потому что он чаще встречается; ClickHouse во второй половине, как введение в большой OLAP-мир, который раскрыт в разделе 26.

1. Redis: типы данных и почему он быстрый

Redis это in-memory key-value хранилище, заточенное под низкую латентность и богатые структуры данных. Это не “просто кэш”; это “кэш плюс структуры данных в памяти, доступные по сети”. Разница принципиальная: ты не сериализуешь хэш-таблицу в JSON для хранения, ты держишь хэш-таблицу прямо в Redis и атомарно меняешь её поля.

Почему он быстрый

Три причины:

  1. In-memory. Все данные живут в RAM. Диск нужен только для персистентности, не для обслуживания запросов. RAM в сотни раз быстрее SSD на random access.
  2. Single-threaded event loop. Один поток обслуживает все клиентские команды последовательно. Звучит контринтуитивно (“один поток это медленно”), но это убирает все блокировки и context switch’и. На современном CPU одно ядро легко выдаёт 100k+ простых команд в секунду. Многопоточность Redis включает только для I/O в новых версиях, не для логики команд.
  3. Простой протокол. RESP разбирается за наносекунды, без JSON-парсинга и схем.

Последствия single-threaded модели:

  • Любая команда блокирует все остальные на время её исполнения. Команда сложности O(N) на большой коллекции (например, KEYS * на миллионе ключей или SMEMBERS на огромном множестве) положит весь Redis на сотни миллисекунд.
  • Транзакции и Lua-скрипты выполняются атомарно относительно других команд, потому что между ними нет конкуренции. Это огромный плюс: для большинства многошаговых операций не нужны distributed-блокировки.
  • Запись в Redis не параллелится: один поток это потолок. Если упираешься в CPU одного ядра, единственный путь, шардирование через Redis Cluster или Sentinel/реплики.

Персистентность

Redis по умолчанию живёт в RAM, но умеет сбрасывать на диск двумя способами:

  • RDB (snapshot). Периодически (по триггеру “N изменений за M секунд”) сохраняет полный дамп памяти на диск. Быстро восстанавливается, маленький файл, но при падении теряешь всё с последнего снапшота.
  • AOF (append-only file). Каждая команда дописывается в журнал, при старте Redis проигрывает журнал заново. Можно настроить fsync: always (каждая запись на диск, медленно), everysec (раз в секунду, потеря до 1 секунды данных), no (доверяем ОС).

В проде обычно включают оба: RDB для быстрого старта, AOF для durability. Но даже с этим Redis это cache-like хранилище: не строй на нём single source of truth для критичных данных. Источник правды это Postgres; Redis это горячий слой поверх.

Типы данных, шесть основных

STRING

Строка до 512MB, но обычно короткая. Хранит число, JSON, бинарь, что угодно.

SET user:42:email vassiliy@example.com
GET user:42:email
SETEX session:abc 1800 user:42       -- автоудаление через 30 минут
INCR pageview:home                    -- атомарный счётчик
INCRBY balance:42 100
GETSET feature:flag:x 1               -- атомарный read+write

Стоимость каждой команды: O(1). INCR/DECR атомарны без блокировок, поэтому Redis это самый дешёвый способ сделать счётчик “сколько раз открыли страницу” между инстансами сервиса.

LIST

Двусторонняя очередь. Push/pop с обеих сторон, доступ по индексу.

LPUSH queue:emails '{"to":"a@b.c","subj":"hi"}'
RPOP  queue:emails
BLPOP queue:emails 5                  -- блокирующий pop с таймаутом
LRANGE recent:lessons:42 0 9          -- последние 10 уроков пользователя
LTRIM recent:lessons:42 0 49          -- держим только 50 последних

Стоимость: LPUSH/RPOP O(1), LINSERT и LRANGE O(N). Удобно для маленьких очередей и хвостов истории.

HASH

Хэш-таблица внутри одного ключа.

HSET user:42 email vassiliy@example.com last_seen 2026-05-15 level S
HGET user:42 email
HGETALL user:42
HINCRBY user:42 streak 1
HDEL user:42 last_seen

Стоимость: O(1) на поле. Это правильный способ хранить структурированный объект (профиль, settings) вместо JSON-строки: можно менять отдельные поля атомарно и без сериализации.

SET

Множество уникальных значений без порядка.

SADD followers:42 user:10 user:11 user:12
SISMEMBER followers:42 user:10        -- 1, если есть
SINTER followers:42 followers:43      -- общие подписчики
SCARD followers:42                    -- мощность

Стоимость: SADD/SREM/SISMEMBER O(1), SINTER/SUNION O(N+M). Полезно для unique-счётчиков, ACL, “видел ли пользователь этот урок”.

ZSET (sorted set)

Множество с числовым score, отсортированное по score.

ZADD leaderboard 42 user:10
ZADD leaderboard 87 user:11
ZINCRBY leaderboard 5 user:10
ZREVRANGEBYSCORE leaderboard +inf 0 WITHSCORES LIMIT 0 10  -- топ-10
ZRANK leaderboard user:10             -- позиция в рейтинге

Стоимость: O(log N) на вставку, O(log N + M) на выборку M элементов. Реализация: skip-list плюс хэш для поиска по member. Это основная структура для лидербордов, sliding window rate limiter, отложенных задач (score = unix timestamp выполнения).

STREAM

Append-only лог с консьюмер-группами. Появился в Redis 5.

XADD events * lesson 42 user 10 kind opened
XREAD COUNT 100 STREAMS events 0
XGROUP CREATE events workers $ MKSTREAM
XREADGROUP GROUP workers worker-1 COUNT 10 STREAMS events >
XACK events workers <id>

Структурно это append-only лог, очень похожий на Kafka. Каждое сообщение получает ID по времени (<ms>-<seq>), Redis помнит, какие ID каждая consumer group уже подтвердила. Это более правильная примитивная очередь, чем LPUSH/BRPOP, потому что:

  • сообщения не теряются при падении воркера (есть pending-list, можно re-claim);
  • сохраняется история, можно перечитать;
  • встроенные consumer groups делают round-robin;
  • ack явный.

Что мы сознательно опустили

Bitmap (BITCOUNT, BITOP), HyperLogLog (PFCOUNT для approximate cardinality), geospatial (GEOADD, GEOSEARCH), Pub/Sub. Они нужны точечно, и идут в документацию, когда столкнёшься с конкретной задачей. Знать про них стоит, но в типичном бэкенде BatSchool 80% работы укладывается в STRING, HASH, ZSET и STREAM.

Стоимость, главные правила

  • KEYS * запрещено в проде. О(N) по всему пространству ключей, блокирует Redis. Замена: SCAN cursor MATCH pattern COUNT 100 с пагинацией.
  • Большие коллекции это долгие команды. SMEMBERS на миллионе элементов, HGETALL на хэше с 10k полей, LRANGE 0 -1 это блокировка. Хочешь массовый обход, используй SCAN-семейство: HSCAN, SSCAN, ZSCAN.
  • DEL на огромном ключе тоже долгий. Алтернатива, UNLINK (асинхронное удаление, начиная с Redis 4).
  • Множественные операции группируй в pipeline: одна сетевая поездка вместо N. Это не атомарность, это просто batching.

2. Cache-aside, write-through, write-behind

Шаблонов кэширования вокруг Redis ровно несколько, и они отличаются тем, кто отвечает за согласованность между БД и кэшем.

Cache-aside (lazy loading)

Самый частый паттерн. Кэш живёт сбоку, приложение само про него знает.

read(key):
  v = redis.GET(key)
  if v: return v
  v = db.SELECT(...)
  redis.SETEX(key, TTL, v)
  return v

write(key, v):
  db.UPDATE(...)
  redis.DEL(key)             -- лучше DEL, чем SET с новым значением (см. ниже)

Логика приложения проста и понятна. Источник правды это БД, кэш это “может быть, есть, может, нет, проверь”.

Почему DEL, а не SET после write? Между db.UPDATE и redis.SET может вклиниться чтение другого инстанса со старыми данными из БД, и оно поверх запишет в кэш устаревшее. С DEL следующий read увидит miss, прочитает свежее из БД и положит в кэш правильное значение. Это read-through в чистом виде.

Подводные камни.

  • Cache stampede. Популярный ключ истёк, десять одновременных запросов увидели miss и побежали в БД. Решения: single-flight (один поток идёт в БД, остальные ждут на распределённом lock), probabilistic early expiration, async refresh.
  • TTL подбирать руками. Слишком короткий это много промахов, слишком длинный это устаревшие данные. Эмпирика: для горячих read-heavy ключей начинай с 5-15 минут, для редко меняющихся справочников несколько часов.
  • Negative caching. Если запрос вернул “нет такого пользователя”, это тоже стоит закэшировать (с коротким TTL), иначе DDoS на несуществующие ключи валит БД.

Write-through

Кэш стоит “перед” БД: приложение пишет в кэш, кэш пишет в БД.

write(key, v):
  redis.SET(key, v)
  db.UPDATE(...)

Преимущество: кэш всегда свежий. Недостаток: каждое write идёт в две системы синхронно, и при падении одной из них теряется согласованность. На практике в Redis-стеке этот паттерн делают вручную (нет встроенного механизма “Redis пишет в Postgres сам”), и он быстро превращается в “пишем в обе системы и надеемся”. В дистрибутивных кэшах (Hazelcast, Ignite) write-through бывает встроенным.

Write-behind (write-back)

Приложение пишет только в кэш, кэш асинхронно сбрасывает в БД.

write(key, v):
  redis.SET(key, v)
  queue.push(key)

worker:
  for key in queue: db.UPDATE(redis.GET(key))

Преимущество: write мгновенный, БД не нагружается на пиках. Недостаток: при падении кэша до сброса данные теряются. Этот паттерн годится только для не-критичных счётчиков: views на страницы, time_pings, аналитические события. Для платежей или прогресса по урокам никогда.

Cache invalidation на изменении в БД

Иногда DEL после write мало. Сценарий: один HTTP-запрос меняет данные, но рядом крутится фоновый job, или работает другой сервис. Тогда кэш надо инвалидировать на событие из БД, а не из кода приложения.

Способы:

  • Postgres LISTEN/NOTIFY. Триггер на таблице шлёт NOTIFY cache_invalidate, 'user:42', отдельный сервис слушает и делает redis.DEL. Просто, но at-most-once.
  • CDC через Debezium + Kafka. Меняешь строку в Postgres, Debezium ловит WAL-запись и публикует в Kafka, отдельный consumer инвалидирует кэш. Надёжно, but heavy. Это уже отдельная тема, разобрана в уроке 06.
  • Версионирование ключей. Вместо инвалидации меняешь номер версии в ключе: user:42:v17 вместо user:42. Старая версия дотухает по TTL. Удобно для документов с большой структурой.

Серверный кэш Redis 6.0+

В Redis 6 появился client-side caching: клиент держит локальный кэш, Redis уведомляет об инвалидации сам. Это убирает RTT для горячих ключей и упрощает invalidation. Поддерживают ioredis 5+, redis-rs, lettuce.

Что выбрать для BatSchool

Большая часть кейсов в BatSchool это cache-aside с TTL 5-60 минут.

КлючСтратегияTTL
Сессия (session:<id>)прямой STRING в Redis, без БД-копииTTL = время жизни сессии
Профиль (user:<id>:profile)cache-aside, DEL при UPDATE5 минут
Список секций для дашбордаcache-aside, refresh-ahead1 час
Топ-10 уроков по прохождениямcache-aside, scheduled refresh15 минут
Rate limitпрямой ZSET/STRING в Redis, без БДTTL = окно
Time-pings агрегат за сегодняwrite-behind, batch в Postgres каждые 5 минутday-end

3. Rate limit и sessions

Два самых частых сценария, где Redis нужен без БД-копии. Не “ускорить чтение”, а “это и есть хранилище”.

Sessions

В BatSchool у нас сессии в Postgres (таблица sessions). Это допустимо для маленького проекта: до 20 менти, нагрузки никакой. Но в большом проде сессионная таблица становится горячей точкой: каждый HTTP-запрос делает SELECT по session_id, и часто UPDATE last_seen. На тысячах RPS это пуляет в Postgres все cores.

Поэтому в большом проде сессии часто живут в Redis:

// логин
const sid = randomBytes(32).toString('hex');
await redis.setex(`session:${sid}`, SESSION_TTL_SEC, JSON.stringify({
  userId, roles, createdAt: Date.now()
}));
res.cookie('__Host-session', sid, { httpOnly: true, secure: true, sameSite: 'lax' });

// каждый запрос
const raw = await redis.get(`session:${sid}`);
if (!raw) throw new Unauthorized();
const sess = JSON.parse(raw);
// опционально продлеваем TTL
await redis.expire(`session:${sid}`, SESSION_TTL_SEC);

Что важно:

  • Не клади в сессию ничего, что меняется часто. Только user_id, roles, snapshot нужных claim’ов. Live-данные (баланс, последнее посещение) читай отдельно, иначе сессионный объект распухнет и его SET на каждом запросе станет дорогим.
  • Sliding expiration. EXPIRE на каждом запросе продлевает TTL, пока пользователь активен. Это правильное поведение для большинства веб-приложений.
  • Logout это DEL. Кладёшь revoke в логику кода: redis.del('session:' + sid). Не пиши вручную в Postgres, чтобы синхронизировать.
  • Mass logout (revoke all sessions for user) требует индекса. Либо держи set user:<id>:sessions со всеми session_id этого пользователя, либо клади в session объект “version”, и инкрементируй версию пользователя при logout-all (старые сессии при следующем чтении сверяют версию и инвалидируются).

В BatSchool сессии оставляем в Postgres для простоты деплоя. Перейти на Redis имеет смысл при росте до сотен тысяч активных пользователей.

Rate limit, четыре алгоритма

Rate limit это “не больше N запросов за окно W”. Алгоритмов несколько, у каждого свои свойства.

Fixed window

Простейший. Ключ это (user_id, текущая минута), инкрементируем счётчик.

-- key = rl:fixed:user42:202605151430  (минута)
-- ARGV[1] = limit, ARGV[2] = window_sec
local cur = redis.call('INCR', KEYS[1])
if cur == 1 then redis.call('EXPIRE', KEYS[1], ARGV[2]) end
if cur > tonumber(ARGV[1]) then return 0 end
return 1

Преимущество: O(1) память и время. Недостаток: бёрст на границе окна. Если лимит 100/мин, пользователь может за две секунды сделать 200 запросов (100 в конце одной минуты, 100 в начале следующей). Для типичного API некритично.

Sliding window log

Храним ZSET с timestamp каждого запроса, удаляем старые, считаем мощность.

-- key = rl:sw:user42
-- ARGV[1] = now_ms, ARGV[2] = window_ms, ARGV[3] = limit, ARGV[4] = id (uuid запроса)
redis.call('ZREMRANGEBYSCORE', KEYS[1], 0, ARGV[1] - ARGV[2])
local cur = redis.call('ZCARD', KEYS[1])
if cur >= tonumber(ARGV[3]) then return 0 end
redis.call('ZADD', KEYS[1], ARGV[1], ARGV[4])
redis.call('PEXPIRE', KEYS[1], ARGV[2])
return 1

Преимущество: точный sliding window, никаких бёрстов. Недостаток: память пропорциональна limit (каждый запрос это запись в ZSET). На лимите 1000/мин и миллионе активных пользователей это сотни МБ. Не для огромных RPS, но идеально для security-sensitive путей (login, magic-link request, password reset).

Sliding window counter (approximate)

Компромисс: два fixed window счётчика (текущая минута и предыдущая), интерполируем по тому, как далеко мы внутри текущей минуты.

allowed = current_count + prev_count * (1 - position_in_window) <= limit

Память O(1), точность приемлемая. Используется в Cloudflare и в большинстве production-rate-limiter-библиотек.

Token bucket

Бакет на K токенов, скорость пополнения R токенов/сек. Каждый запрос забирает 1 токен, если в бакете 0 это отказ.

-- key = rl:tb:user42 -> hash {tokens=N, last_refill=ts}
local data = redis.call('HMGET', KEYS[1], 'tokens', 'last_refill')
local tokens = tonumber(data[1]) or tonumber(ARGV[1])  -- ARGV[1] = capacity
local last = tonumber(data[2]) or 0
local now = tonumber(ARGV[2])
local rate = tonumber(ARGV[3])  -- tokens per second

tokens = math.min(tonumber(ARGV[1]), tokens + (now - last) / 1000 * rate)
if tokens < 1 then
  redis.call('HMSET', KEYS[1], 'tokens', tokens, 'last_refill', now)
  return 0
end
redis.call('HMSET', KEYS[1], 'tokens', tokens - 1, 'last_refill', now)
redis.call('PEXPIRE', KEYS[1], 60000)
return 1

Преимущество: позволяет бёрсты до capacity при низком среднем, что часто желательно для UX. AWS API Gateway работает на token bucket.

Атомарность

Главное правило: rate limit это многошаговая операция (читать, проверять, инкрементировать), и её надо делать атомарно. Иначе при гонке два инстанса пропустят больше, чем разрешено.

Способов два:

  • MULTI/EXEC. Группирует команды в транзакцию. Не идеально: между WATCH и EXEC могут быть retry, под нагрузкой нестабильно.
  • Lua-скрипт. Атомарен относительно других команд (помним, single-threaded). Все примеры выше написаны на Lua. Это правильный способ для rate limit.
const script = await redis.scriptLoad(LUA);
const ok = await redis.evalsha(script, 1, key, now, window, limit, reqId);

SCRIPT LOAD + EVALSHA это оптимизация: скрипт хранится в Redis по SHA, мы посылаем только хэш.

Что в BatSchool

В коде src/auth/rate-limit.ts сейчас rate-limit на magic-link через таблицу Postgres rate_limits (по email и по IP). На малой группе менти этого достаточно. При росте нагрузки переезжаем на Redis sliding window log: лимиты sensitive (10 запросов магической ссылки за час), точность важнее, чем память.

дальше после входа

Урок целиком открыт всем, кто вошёл. Войди по своему email, чтобы читать дальше.

Вход пока по приглашению. Если хочешь учиться, напиши Vassiliy.