Legan Studio
Все статьи
~ 14 мин чтения

Redis в Telegram-боте: кэш, FSM, очереди

Зачем Redis в Telegram-боте: хранение FSM, кэш горячих данных, очереди задач, rate limit. Практические схемы и оценка нагрузки.

  • Telegram
  • архитектура
  • разработка

Redis — это не «база данных в памяти», а универсальный швейцарский нож для бота: FSM, кэш, очередь, rate limit, временные ссылки, distributed lock, pub/sub, аналитика в реальном времени. Под капотом — однопоточный event loop с микросекундной задержкой и десятком встроенных структур данных, каждая из которых решает отдельную задачу. Разберём, под какие сценарии его подключают, какие структуры выбирать и где он становится узким местом.

Зачем Redis в боте

Бот — это процесс с большим количеством мелких быстрых операций. Каждое сообщение требует чтения состояния, кэша горячих данных, проверки лимитов. SQL-база справляется, но добавляет 10–50 ms задержки на каждую операцию. Redis в этом сценарии — на порядок быстрее (под микросекунды) и выживает на одной машине под десятками тысяч RPS.

Поэтому в типовой архитектуре бота Redis закрывает «горячий путь», а Postgres — «холодное» хранение пользовательских данных, заказов, истории. Список задач, которые Redis закрывает в боте:

  • FSM storage — состояния диалога между апдейтами.
  • Rate limit — антифлуд и квоты на платные действия.
  • Кеш — каталог, прайс, профили, ответы LLM.
  • Очередь — фоновые задачи (рассылки, генерация PDF, тяжёлые AI-вызовы).
  • Sessions — временные сессии после OAuth, magic-link, оплаты.
  • Distributed lock — критичные операции (бронирование слота, списание баллов).
  • Pub/Sub — broadcasting между инстансами бота и сервисами.
  • Аналитика real-time — DAU, уникальные посетители, лидерборды.

Структуры данных и где их применять

Redis — не «key-value», а многоструктурное хранилище. Для каждой задачи в боте есть подходящая структура. Таблица:

СтруктураКомандыПрименение в боте
StringGET / SET / INCRКеш ответов, счётчики, флаги, JWT-сессии
HashHSET / HGETALLПрофили пользователя (name, lang, premium)
ListLPUSH / BRPOPОчередь рассылки, FIFO-задачи
SetSADD / SISMEMBERКто видел акцию, реферальные множества
Sorted SetZADD / ZREVRANGEЛидерборды, рейтинги, sliding-window лимиты
StreamsXADD / XREADGROUPЖурнал событий, replay, durable очередь
BitmapSETBIT / BITCOUNTDAU/MAU, флаги по user_id
HyperLogLogPFADD / PFCOUNTУникальные посетители (≈12 КБ на счётчик)
GeoGEOADD / GEOSEARCHПоиск ближайших точек, доставки

Главное правило: не лепите всё в String через JSON. Если объект многократно частично читается — это Hash. Если нужна сортировка по очкам — Sorted Set. Если уникальность важнее счёта — Set или HyperLogLog.

FSM-стейт

Самый частый сценарий. У всех популярных фреймворков (aiogram, grammY, python-telegram-bot) есть готовый Redis Storage для FSM. Состояние и временные данные диалога хранятся в Redis с TTL.

Ключи у aiogram имеют формат `fsm:{bot_id}:{chat_id}:{user_id}:state` и `fsm:{bot_id}:{chat_id}:{user_id}:data`. TTL — 24–72 часа для обычных воронок. Память — ничтожная (десятки байт на пользователя), даже миллион активных пользователей укладывается в гигабайт.

Подключение в aiogram 3:

from aiogram import Bot, Dispatcher
from aiogram.fsm.storage.redis import RedisStorage
from redis.asyncio import Redis

redis = Redis(host="redis", port=6379, db=0, decode_responses=True)
storage = RedisStorage(redis=redis, state_ttl=86400, data_ttl=86400)
dp = Dispatcher(storage=storage)

Если у вас несколько ботов в одном Redis-инстансе — разнесите их по db (0..15) или используйте key prefix. Иначе при сбросе FSM одного бота легко зацепить чужие ключи.

Rate limit: token bucket на Lua

Telegram ограничивает: 30 сообщений в секунду на бота, 1 сообщение в секунду на чат, 20 в минуту в группу. Свой rate limit — отдельная история: защита от DoS, ограничение «бесплатных» AI-запросов, антифлуд.

Простейший вариант — INCR ключа `ratelimit:{user_id}:{bucket}` и EXPIRE на окно. Но этот подход страдает от race condition между INCR и EXPIRE (если процесс упал между ними — ключ останется без TTL). Решение — атомарный Lua-скрипт:

-- token_bucket.lua
-- KEYS[1] = ключ ведра, ARGV[1] = capacity, ARGV[2] = window_sec
local current = redis.call("INCR", KEYS[1])
if current == 1 then
  redis.call("EXPIRE", KEYS[1], tonumber(ARGV[2]))
end
if current > tonumber(ARGV[1]) then
  return 0
end
return 1

Вызов из Python:

ALLOW = await redis.eval(SCRIPT, 1, f"rl:user:{user_id}:min", 30, 60)
if not ALLOW:
    await message.answer("Слишком часто, подождите минуту")
    return

Для sliding window используйте Sorted Set: ZADD с score=now(), затем ZREMRANGEBYSCORE старше окна, ZCARD для подсчёта. Точнее, чем фиксированное окно, но дороже (O(log N) на запись).

Distributed lock

Бронирование слота, списание бонусов, выдача промокода — операции, которые не должны исполниться дважды. Когда у вас несколько инстансов бота за nginx, без блокировки два пользователя одновременно «купят» последний билет.

Базовый паттерн — SET key value NX EX 10:

import secrets

lock_token = secrets.token_hex(16)
acquired = await redis.set(
    f"lock:slot:{slot_id}", lock_token,
    nx=True, ex=10,
)
if not acquired:
    await message.answer("Слот занимают, попробуйте через секунду")
    return
try:
    await book_slot(slot_id, user_id)
finally:
    # снимаем только свой лок (Lua, чтобы атомарно)
    await redis.eval(
        "if redis.call('get', KEYS[1]) == ARGV[1] "
        "then return redis.call('del', KEYS[1]) else return 0 end",
        1, f"lock:slot:{slot_id}", lock_token,
    )

Ключевые моменты: NX — взять только если ключа нет, EX 10 — TTL чтобы лок не повис при падении процесса, value — случайный токен чтобы не снять чужой лок при истечении своего. Для критичных систем — RedLock на 5 нод, но для бота обычно избыточно.

Кеш горячих данных

Каталог товаров, прайс-лист, настройки тарифов — часто читаются, редко меняются. Класть их в Postgres каждый раз — расточительство. Кэшируем в Redis с TTL 5–15 минут или с явной инвалидацией при обновлении.

Паттерны:

  1. Cache-aside: смотрим в Redis, если нет — читаем БД и кладём в Redis.
  2. Write-through: запись идёт сразу и в БД, и в Redis.
  3. Write-behind: запись сначала в Redis, потом фоном в БД.

Для бота 95% случаев — cache-aside. Это просто и предсказуемо.

Кеш ответов LLM

Если бот ходит в OpenAI/Anthropic/YandexGPT — каждый запрос стоит денег и занимает 2–10 секунд. На повторяющихся промптах (FAQ, стандартные команды) кеш экономит 60–90% бюджета.

Ключ — SHA-256 от нормализованного промпта (model + temperature + system + user):

import hashlib, json

def llm_cache_key(model: str, system: str, user: str, temp: float) -> str:
    payload = json.dumps([model, system, user, temp], ensure_ascii=False)
    digest = hashlib.sha256(payload.encode()).hexdigest()[:32]
    return f"llm:{model}:{digest}"

async def ask_llm(prompt: str) -> str:
    key = llm_cache_key("gpt-4o-mini", SYSTEM, prompt, 0.0)
    cached = await redis.get(key)
    if cached:
        return cached
    answer = await openai_call(prompt)
    await redis.set(key, answer, ex=86400 * 7)
    return answer

Кешировать имеет смысл только при temperature=0 (детерминированный ответ). Для творческих режимов (temp > 0.5) кеш бессмысленен.

Очереди задач

Тяжёлые операции (генерация PDF, отправка уведомлений всему списку, вызов медленного AI) нельзя делать в обработчике — Telegram отвалит webhook за 60 секунд. Решение — фоновая очередь.

Варианты на Redis:

  • RQ (Python) — простая очередь, быстро ставится, минимум зависимостей.
  • Celery с Redis backend — более тяжёлый, но богат фичами (расписания, retry, цепочки).
  • BullMQ (Node) — отраслевой стандарт для TypeScript-ботов.
  • Redis Streams + свой воркер — если нужна полная гибкость и at-least-once.

Обработчик кладёт задачу в очередь, мгновенно отвечает Telegram «принято», воркер выполняет в фоне и присылает ответ пользователю отдельным сообщением.

Минимальный пример на List:

# producer внутри хендлера
await redis.lpush("queue:broadcast", json.dumps({
    "user_id": user_id, "text": text, "ts": time.time(),
}))

# worker.py
while True:
    _, payload = await redis.brpop("queue:broadcast", timeout=0)
    job = json.loads(payload)
    await bot.send_message(job["user_id"], job["text"])

BRPOP блокирующий — воркер не молотит CPU вхолостую. Для надёжности (at-least-once) переходите на Streams с XREADGROUP и явным XACK.

Pub/Sub и broadcasting

Redis Pub/Sub — лёгкий способ распределить событие между инстансами бота или сервисами. Например: оплата прошла → платёжный сервис публикует order:paid → бот подписан на этот канал → отправляет пользователю чек.

Альтернатива — Streams (надёжнее, поддерживает consumer groups, replay). Pub/Sub проще, но не гарантирует доставку при простое подписчика — это «fire and forget».

Используйте Pub/Sub для: cache invalidation между инстансами, сигналов «обнови конфиг», live-обновления админки. Для бизнес-событий (оплата, регистрация) — Streams.

Sessions и одноразовые токены

В оплате через сторонние сервисы часто нужен временный токен «вернуть пользователя в правильное место». Redis с TTL 10 минут — идеальное место: ключ `payment:{token}`, значение — payload с ID пользователя и заказа.

То же с одноразовыми ссылками («скачать файл по ссылке в течение часа»), кодами подтверждения, magic-links, OAuth state-параметром.

Хранение больших callback_data

Telegram ограничивает callback_data 64 байтами. Если у вас сложный callback (например, «купить товар X в количестве Y по цене Z с доставкой W»), 64 байт не хватит.

Решение — хранить полный payload в Redis и в callback_data класть короткий ключ:

import secrets

token = secrets.token_urlsafe(8)  # 11 символов
await redis.set(f"cb:{token}", json.dumps(payload), ex=3600)
button = InlineKeyboardButton(text="Купить", callback_data=f"buy:{token}")

# в хендлере
@dp.callback_query(F.data.startswith("buy:"))
async def buy(call: CallbackQuery):
    token = call.data.split(":", 1)[1]
    raw = await redis.get(f"cb:{token}")
    if not raw:
        await call.answer("Кнопка устарела", show_alert=True)
        return
    payload = json.loads(raw)

Тот же приём — для reply_to связей в длинных диалогах с LLM (хранить ID родительского сообщения и контекст).

Persistence: RDB vs AOF

По умолчанию Redis — в памяти. При рестарте всё пропадает. Для FSM это допустимо (TTL пересчитается, диалог потеряется), для критичных данных — нет.

  • RDB — снапшот всей памяти на диск (например, каждые 5 минут). Компактный, быстрый рестарт. Минус — теряете данные между снапшотами.
  • AOF — append-only log всех команд. Надёжнее (потеря максимум 1 секунды при appendfsync everysec), но файл больше и рестарт дольше.
  • Гибрид — RDB + AOF одновременно (aof-use-rdb-preamble yes). Рекомендуется в большинстве случаев.

Для бота: RDB каждые 15 минут + AOF с appendfsync everysec — хороший дефолт. Если в Redis только кеш — можно вообще без persistence.

Кластер vs Sentinel vs single

  • Single — один инстанс. До 10–50 тысяч активных пользователей хватает. Простая настройка, никакого оверхеда.
  • Sentinel — один master + 1–2 replica + 3 sentinel-ноды для failover. HA, но не масштабирует запись. Подходит, когда важна доступность, но объём данных умещается на одной машине.
  • Cluster — шардирование на 3+ master + replicas. Нужен, когда не хватает RAM или write-нагрузки одной ноды. Сложнее: multi-key операции работают только в пределах одного слота (используйте {hashtag} в ключах).

99% Telegram-ботов живут на single instance. Sentinel — когда uptime критичен (платёжный, банковский). Cluster — почти никогда.

Eviction policy

Redis может быть «безграничным» (вылетит по OOM) или принудительно вытеснять старые ключи при заполнении maxmemory. Политики:

  • noeviction — отказ записи при переполнении. Дефолт для FSM: лучше упасть, чем потерять состояния.
  • allkeys-lru — выкидывать давно не использованные. Дефолт для кеша.
  • volatile-lru — то же, но только среди ключей с TTL.
  • allkeys-lfu — по частоте использования (точнее LRU для длинных хвостов).

Если в одном Redis и FSM, и кеш — разнесите по разным db или инстансам с разными политиками. Иначе кеш сожрёт FSM.

Мониторинг

Минимум, что должно быть:

  • redis-cli INFO memoryused_memory, mem_fragmentation_ratio. Если фрагментация > 1.5 — стоит включить activedefrag yes.
  • redis-cli INFO statsinstantaneous_ops_per_sec, keyspace_hits / keyspace_misses (hit ratio).
  • SLOWLOG GET 10 — медленные команды (порог slowlog-log-slower-than 10000 микросекунд).
  • LATENCY DOCTOR — диагностика всплесков задержки.
  • Экспорт в Prometheus через redis_exporter, дашборд в Grafana.

Алерты: память > 80%, hit ratio < 70%, evicted_keys растёт, replication lag > 1s.

Подводные камни в продакшене

  • KEYS * — никогда. Блокирует Redis на всё время сканирования (минуты на больших базах). Используйте SCAN с курсором.
  • Большие MGET / HGETALL. Команда исполняется атомарно, блокирует event loop. Если в hash 100 тысяч полей — это десятки миллисекунд блокировки. Бейте на чанки.
  • Hot keys. Один ключ, в который ломятся все, — bottleneck. Шардируйте (counter:{user_id % 16}) или кешируйте локально с коротким TTL.
  • Большие values. Redis любит мелкие значения. JSON > 1 МБ — повод подумать о другой БД или сжатии.
  • FLUSHDB в продакшене. Закройте redis-cli доступ паролем (requirepass) и переименуйте опасные команды через rename-command FLUSHDB "".
  • Pub/Sub без consumer. Сообщения теряются молча. Для надёжной доставки — Streams.
  • TLS и requirepass. Если Redis выставлен в интернет (не делайте так) — обязательно. Лучше держать в private network.

Альтернативы Redis

В 2024–2026 экосистема разделилась после смены лицензии Redis на SSPL/RSAL:

  • Valkey — форк Redis от Linux Foundation (BSD), API-совместим. Дефолт для большинства Linux-дистрибутивов. Если ставите «Redis» в Ubuntu 24.04+ — это Valkey.
  • KeyDB — мульти-тредовый форк, выше throughput на жирных серверах. Активность снизилась после покупки Snap.
  • DragonflyDB — переписан с нуля на C++, до 25× быстрее на benchmark. API-совместим. Подходит, когда упёрлись в одно ядро Redis.
  • Garnet от Microsoft — на .NET, экзотика для бота.

Для типового Telegram-бота переезжать с Redis/Valkey смысла нет. Думать об альтернативах — только когда упёрлись в потолок одной ноды.

Когда Redis избыточен

Не каждому боту нужен Redis. Если:

  • Меньше 100 пользователей в день.
  • Один инстанс, нет горизонтального масштабирования.
  • FSM-стейт можно держать в памяти (короткие диалоги, перезапуски редки).

…тогда in-memory storage достаточно. Redis — это уровень, на который вы переходите при первых признаках роста: несколько инстансов, рестарты теряют состояние, нужны очереди и rate limit.

Итого

Redis в Telegram-боте — это FSM, кэш, очереди, rate limit, distributed lock, pub/sub и одноразовые токены в одном инструменте. Под каждую задачу — своя структура: Hash для профилей, Sorted Set для лидербордов и sliding window, Streams для надёжных очередей, Bitmap и HyperLogLog для аналитики. Один инстанс с 1–2 ГБ памяти закрывает потребности бота на сотни тысяч пользователей. Подключать его стоит на этапе перехода с MVP в продакшен — не раньше, но и не сильно позже.

Частые вопросы

Зачем нужен Redis в Telegram-боте?

Бот — это процесс с большим количеством мелких быстрых операций. Каждое сообщение требует чтения состояния, кэша горячих данных, проверки лимитов. SQL-база справляется, но добавляет 10–50 ms задержки на каждую операцию. Redis в этом сценарии — на порядок быстрее (под микросекунды) и выживает на одной машине под десятками тысяч RPS. В типовой архитектуре бота Redis закрывает «горячий путь» (FSM, кэш, лимиты, очереди, locks, pub/sub), а Postgres — «холодное» хранение пользовательских данных, заказов, истории.

Как использовать Redis для FSM Telegram-бота?

У всех популярных фреймворков (aiogram, grammY, python-telegram-bot) есть готовый Redis Storage для FSM. У aiogram 3 ключи имеют формат fsm:bot_id:chat_id:user_id:state и :data, TTL — 24–72 часа для обычных воронок. Память — десятки байт на пользователя, миллион активных пользователей укладывается в гигабайт. При рестарте бота состояние сохраняется (если включён persistence), и пользователи продолжают диалог с того же шага. Eviction policy для FSM — обязательно noeviction, иначе кеш в том же инстансе вытеснит состояния.

Какие структуры данных Redis применять в боте?

String — кеш ответов, счётчики, флаги. Hash — профили пользователя (имя, язык, premium-статус). List — простые очереди через LPUSH/BRPOP. Set — множества (кто видел акцию, рефералы). Sorted Set — лидерборды и sliding-window rate limit. Streams — надёжные очереди с at-least-once и replay. Bitmap — DAU/MAU с экономной памятью. HyperLogLog — уникальные посетители (≈12 КБ на счётчик). Главное правило: не лепите всё в String через JSON — выбирайте структуру под операции.

Как сделать rate limit и distributed lock на Redis?

Rate limit — Lua-скрипт с INCR + EXPIRE атомарно (фиксированное окно), либо Sorted Set с ZADD score=now и ZREMRANGEBYSCORE для sliding window. Distributed lock — команда SET key token NX EX 10: NX берёт ключ только если нет, EX даёт TTL чтобы лок не повис, token (случайный) нужен чтобы при освобождении не снять чужой лок (через Lua-сравнение). Для критичных систем — RedLock на 5 нод, но для типового бота избыточно. Lock нужен для бронирования слотов, выдачи промокодов, списания баллов в многоинстансовом боте.

Как кешировать ответы LLM через Redis?

Ключ — SHA-256 от нормализованного промпта (model + temperature + system + user), значение — текст ответа, TTL — 1–7 дней. Имеет смысл только при temperature=0 (детерминированный ответ); для творческих режимов кеш бессмысленен. На повторяющихся запросах (FAQ, стандартные команды) экономия 60–90% бюджета OpenAI/Anthropic. Тем же приёмом храните callback_data больше 64 байт: в callback_data кладёте короткий токен, полный payload — в Redis с TTL час.

RDB vs AOF — какой persistence выбрать для бота?

RDB — снапшот всей памяти на диск (каждые 5–15 минут). Компактный, быстрый рестарт, но теряете данные между снапшотами. AOF — append-only log всех команд, надёжнее (потеря максимум 1 секунды при appendfsync everysec), но файл больше. Гибрид RDB + AOF (aof-use-rdb-preamble yes) — рекомендуется в большинстве случаев. Для бота: RDB каждые 15 минут + AOF everysec — хороший дефолт. Если в Redis только кеш — можно вообще без persistence.

Какие есть подводные камни Redis в продакшене?

KEYS * — никогда, блокирует Redis на минуты, используйте SCAN. Большие MGET/HGETALL блокируют event loop — бейте на чанки. Hot keys (один ключ, куда ломятся все) — шардируйте по user_id % N. Большие values (>1 МБ JSON) — повод подумать о другой БД или сжатии. FLUSHDB закройте через rename-command. Pub/Sub теряет сообщения молча — для надёжной доставки используйте Streams. Мониторьте hit ratio, evicted_keys, slowlog, latency через redis_exporter и Grafana.