· 1 мин

Телеграм бот с игрой на aiogram: где хранить прогресс, FSM или база

Прогресс игрока в телеграм боте на aiogram храни в базе (PostgreSQL или SQLite через SQLAlchemy), а не в FSM. FSM помнит только шаг диалога, и дефолтное MemoryStorage теряет всё при рестарте. Чтобы не потерять ход при падении, пиши его в транзакции и делай идемпотентным по ключу хода.

Где хранить прогресс игрока в телеграм боте на aiogram: что умеет FSM, зачем база, как не потерять ход при рестарте и откатить сбой. На примере DnD-бота.

Телеграм бот с игрой на aiogram: где хранить прогресс, FSM или база
В статье · 9 разделовразвернуть

Сохранения в телеграм боте на aiogram - это не FSM, а таблица в базе. FSM в aiogram помнит, на каком шаге диалога стоит игрок, а прогресс игры (персонаж, инвентарь, ходы) должен лежать в PostgreSQL или SQLite и переживать рестарт. Я так держу DnD-бота "Мокрый Тортл": aiogram 3.x, SQLAlchemy 2.0 async и PostgreSQL 15, а дубли ходов отсекает ключ идемпотентности.

Дальше по шагам: что куда класть, как не потерять ход при падении и как откатить сломанную партию. Пример везде один - кампания в моём DnD-боте, где игрок делает ход, а мастер-нейросеть отвечает.

Где хранить прогресс игрока в телеграм боте на aiogram?

Прогресс игрока в телеграм боте на aiogram хранят в базе данных, а не в FSM-хранилище. В aiogram 3.31 (август 2026) четыре встроенных хранилища FSM, и дефолтное MemoryStorage теряет всё при рестарте. Для игры нужна таблица: персонаж, кампания, ход. FSM оставь шагам диалога, а память о процессе бота - временным маркерам.

Вот как я раскладываю, что куда:

Где хранитьПереживает рестарт ботаДля чего годитсяГде у меня
MemoryStorage (aiogram 3.31)нетпрототип, шаги короткого диалогабот канала: FSM на chat_id, автосброс через 10 мин
RedisStorage (aiogram 3.31)да, данные живут в RedisFSM на проде, несколько инстансовне использую, Redis в планах "если API разрастётся"
PyMongoStorage (aiogram 3.31)даFSM, если Mongo уже в стекене использую
SQLite через SQLAlchemyдатесты, маленький бот на одном процессетесты DnD-бота гоняются на реальной SQLite-сессии
PostgreSQL 15 через SQLAlchemy 2.0 asyncдапрогресс игры, деньги, журнал ходовDnD-бот: кампании, персонажи, состояния NPC
Словарь в коде (dict)нетмаркеры на один ход, кэшчетыре словаря памяти хода, потеря при рестарте допустима

Документация aiogram про MemoryStorage говорит прямо: не для прода, данные теряются при перезапуске бота (docs.aiogram.dev). Хранилищ там четыре: MemoryStorage, RedisStorage, PyMongoStorage и устаревшее MongoStorage на motor.

Что такое FSM в aiogram и для чего он нужен

FSM в aiogram - это конечный автомат диалога: бот помнит, в каком состоянии сейчас пользователь ("ждём имя", "ждём текст инцидента") и какие данные он успел ввести. Состояние лежит в хранилище по ключу из chat_id и user_id. FSM отвечает на вопрос "что бот ждёт от человека сейчас", а не "что у человека есть".

Ключ в RedisStorage собирается по формату prefix:bot_id:business_connection_id:chat_id:user_id:destiny:field, часть полей опциональна (docs.aiogram.dev). Есть state_ttl и data_ttl - время жизни состояния и данных. Уже из этого видно, что FSM про короткую жизнь: шаг диалога, который можно протухнуть.

Живой пример - мой бот для оформления постов в канал. Там FSM in-memory на каждый chat_id с автосбросом через 10 минут, и состояния вроде WAITING_INCIDENT ("пришли текст инцидента"). Потерял состояние при рестарте - человек нажал кнопку ещё раз. Неприятно, но не трагедия.

Хотя и тут прилетело. После рестарта бот забывал, что ждёт комментарий к кнопке "Перепиши", и коммент уходил в никуда. Починил тем, что пост ищется по reply на подсказку (коммит f443a30), а не по состоянию в памяти. Мораль простая: если состояние можно восстановить из самого сообщения - лучше восстанавливать оттуда.

Хватит ли FSM aiogram для игры или нужна база?

Для игры FSM aiogram не хватит: он хранит позицию в диалоге, а не мир. Персонаж, золото, раны NPC, история ходов - это данные, которые игрок копит часами, и они должны жить в базе с миграциями. FSM годится для мастера создания персонажа из 3-5 шагов, и то если потеря шага не обидна.

В DnD-боте я вообще не завёл FSM-хранилище aiogram под игру. Создание персонажа и сами ходы живут в React Mini App, бэкенд на aiogram 3.x с SQLAlchemy 2.0 asyncio, база - PostgreSQL 15+ на Railway. Доменный инвариант простой: один пользователь = один персонаж = одна кампания. Поэтому весь "сейв" - это строки в таблицах по campaign_id.

Как это выглядит на практике:

  • кампания и персонаж - в своих таблицах, герой хранит состояния и раны в characters;
  • состояния и раны NPC - в entity_states (миграция 069, коммит e864f7e3);
  • журнал хода turn_journal - миграция 067, тексты чистятся через 60 дней;
  • миграции Alembic накатываются при деплое: в логе Railway видно Running upgrade 055 -> 056.

Про выбор между ботом и mini app для игры я подробно писал в статье игра в Телеграм: бот или mini app. Тут важно одно: где бы ни шёл интерфейс, прогресс в базе.

Как не потерять состояние игрока при падении бота?

Чтобы не потерять состояние игрока при падении бота, пиши прогресс в базу внутри транзакции хода и делай ход идемпотентным: повтор того же запроса не должен списать ману второй раз. Telegram хранит необработанные апдейты до 24 часов и повторяет вебхук, так что после рестарта бот получит их снова. Значит, дубль - норма, а не редкость.

По документации Bot API, входящие апдейты хранятся на сервере до получения ботом, но не дольше 24 часов, а неудачный вебхук Telegram повторяет "разумное количество раз" (core.telegram.org). Там же update_id прямо назван инструментом, чтобы игнорировать повторы.

У меня дубль ходов в итоге пришёл не из сети, а от игроков. Двойной тап по варианту ответа или повторный показ модалки при возврате в Mini App отправлял один и тот же ход дважды. Решение - ключ client_message_id: Mini App генерит UUID один раз на логический ход, бэкенд хранит последний обработанный в поле Campaign.last_client_message_id (миграция 048). Кусок из плана задачи, по которому делали:

async with lock:
    if client_message_id:
        dup = await self._duplicate_result(campaign_id, client_message_id)
        if dup is not None:
            return dup
    result = await self.run(
        user_message=user_message,
        campaign_id=campaign_id,
        character_id=character_id,
        user_id=user_id,
        source=source,
        resolved_disambig=resolved_disambig,
    )
    if (
        client_message_id
        and not result.error
        and not result.locked_out
        and not result.needs_disambiguation
    ):
        await self._store_client_message_id(campaign_id, client_message_id)
    return result

Две детали, на которых легко споткнуться. Ключ пишется только после успешного хода: провалившийся ход повторяемый, иначе игрок застрянет. И на дубль бот не шлёт прозу в Telegram повторно, а просто отдаёт последнюю реплику мастера из базы. Если лок занят, игрок видит "DM обдумывает ваш ход... Подождите." вместо второго запуска пайплайна.

Сработало? Проверка 8 октября 2026: за 7 дней 3758 ходов игроков, 0 без ответа мастера, 0 дублей. Та же логика с event_id спасла кассу, это отдельная история про идемпотентность вебхуков и автопродление подписки.

Падает бот не только посреди хода, но и на старте. У меня он целиком не поднимался из-за транзиентного сбоя set_webhook ("Failed to resolve host"). Теперь там set_webhook_with_retry: 5 попыток с паузами 5, 10, 20 и 30 секунд.

Что теряется при рестарте, даже если всё в базе

Даже при прогрессе в базе рестарт теряет всё, что живёт в памяти процесса: локи, кэши, маркеры текущего хода. Это нормально, если потеря безопасна (fail-open) и игрок максимум повторит действие. Опасно, если в памяти лежит что-то, без чего ход применится дважды или не применится вовсе.

В DnD-боте есть четыре словаря памяти хода по campaign_id: _PENDING_ENGINE_FLAGS, _LAST_APPLIED_TRADE, _RECENT_MONEY_CREDITS, _RECENT_HANDOVER_REMOVALS. Ходы одной кампании сериализованы через run_with_lock, а в доке прямым текстом: "Рестарт теряет маркер - fail-open". Я осознанно на это пошёл, потому что настоящий сейв всё равно в таблицах.

Отдельно меня кусал деплой. Мерж тестового PR перезапускает сервис bot и рвёт ходы, которые сейчас идут у игроков. Лечится не кодом, а расписанием: выкатываю в тихие часы. Звучит тупо, работает отлично)

Есть и ограничение самой идемпотентности: в одном слоте лежит только последний ключ, так что поздний повтор старого хода после нового не отсечётся. А ход и ключ пишутся двумя транзакциями, и падение между ними переисполнит ход. Для двойного тапа этого хватает, для платежей - нет.

Как откатить прогресс игрока после сбоя?

Откатить прогресс игрока можно, только если до хода сохранено состояние, к которому возвращаться. Простой способ - слепок: дамп строк кампании до хода и после, плюс вызовы нейросети и броски кубов. Откат тогда - восстановление строк из "до", а не ручная хирургия в SQL на живой базе.

В DnD-боте слепки появились в задаче dnd-2g0e.1: модель TurnSnapshot и миграция 064 turn_snapshots. Рекордер пишет слепок на любой исход хода - успех, отказ, уточнение. Внутри state_before (все строки по campaign_id и user_id), memory_before, llm_calls, rng, state_after и result.

Изначально слепки делались не под откат, а под реплей: прогнать ход заново и сравнить результат. На первом срезе 66 слепков с прода прошли зелёными (69 тестов passed), и три поломки поймались ещё до релиза. Про то, как нейросеть и код делят работу внутри хода, я писал в разборе баланса игры с нейросетью.

А реальный откат у меня пока ручной. Когда мастер завёл ложный бой с мельником в кампании 1409, я написал разовый скрипт rollback_camp1409_erofey.py на 183 строки (коммит d03171fa). Дубль кузниц в кампании 1417 правил SQL-транзакцией 1 сентября 2026. Кнопки "откатить ход" для игрока нет, и честно говоря, на текущем объёме она не нужна.

Как сделать сохранения в телеграм боте на aiogram: пошагово

Порядок для нового игрового бота на aiogram 3.x, который я бы повторил с нуля. Если ты только собираешь первого бота на Python, сначала глянь мой разбор телеграм-бота с нейросетью на Python, там про старт и выбор фреймворка.

  1. Опиши инвариант: что такое "сейв" у тебя. У меня один пользователь = один персонаж = одна кампания, отсюда ключ всего - campaign_id.
  2. Заведи таблицы под прогресс через SQLAlchemy 2.0 async и Alembic, миграции накатывай при деплое.
  3. FSM оставь для коротких диалогов. MemoryStorage на прототипе, RedisStorage или PyMongoStorage на проде, state_ttl под длину шага.
  4. Сериализуй ходы одного игрока локом, чтобы два запроса не писали в одну кампанию одновременно.
  5. Добавь ключ идемпотентности хода и пиши его только после успеха.
  6. Пиши слепок состояния до и после хода, хотя бы для отладки и реплея.
  7. Всё, что держишь в памяти процесса, проверь вопросом: что будет, если это пропадёт посреди хода?

Когда база для бота не нужна

База для игрового бота не нужна, если партия короче нескольких минут и её не жалко начать заново: квиз из пяти вопросов, кости, угадайка. Тут хватит FSM aiogram на MemoryStorage. Граница простая: если потеря прогресса после рестарта стоит игроку меньше минуты - не заморачивайся, если больше - заводи таблицу.

Мой бот канала живёт на in-memory FSM с автосбросом через 10 минут, и это нормально: там нет прогресса, только "что я жду от автора прямо сейчас". А для DnD-бота, где игрок неделями копит персонажа, даже мысль держать что-то важное в MemoryStorage пугает)

Redis тоже не обязателен с первого дня. У меня в кассе DnD-бота лимит 10 запросов в минуту на пользователя считается in-memory, и в доке так и написано: переехать на Redis, если API разрастётся на несколько инстансов. Пока процесс один, лишняя инфраструктура - это лишняя точка отказа.

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

Можно ли хранить данные FSM aiogram в SQLite или PostgreSQL?

Встроенных SQL-хранилищ в aiogram 3.31 нет: есть MemoryStorage, RedisStorage, PyMongoStorage и устаревшее MongoStorage. Можно написать своё, унаследовав BaseStorage и реализовав set_state, get_state, set_data, get_data и close. Но прогресс игры проще держать в обычных таблицах, а FSM оставить для диалогов.

Что будет с состоянием FSM при перезапуске бота на aiogram?

С MemoryStorage состояние и данные FSM пропадут: это обычный словарь в памяти процесса, и документация aiogram прямо не советует его для прода. С RedisStorage или PyMongoStorage состояние переживёт рестарт бота, потому что лежит во внешнем хранилище.

Telegram пришлёт сообщения, которые пришли, пока бот лежал?

Да, если простой короче суток: по документации Bot API необработанные апдейты хранятся на сервере до 24 часов, а неудачный вебхук Telegram повторяет. Поэтому после рестарта бот может получить повторы, и ход стоит делать идемпотентным.

Как готовился материал: черновик собран моим AI-конвейером по темам и заметкам из практики, факты сверены с первоисточниками, финальный текст я прочитал, поправил и утвердил перед публикацией. Обложку к статье тоже рисует нейросеть. Про сам конвейер — в разделе обо мне.

Влад Новиков
Пишу про AI-разработку без глянца. Новые разборы — сначала в канале.
Подписаться