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

В статье · 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) | да, данные живут в Redis | FSM на проде, несколько инстансов | не использую, 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, там про старт и выбор фреймворка.
- Опиши инвариант: что такое "сейв" у тебя. У меня один пользователь = один персонаж = одна кампания, отсюда ключ всего - campaign_id.
- Заведи таблицы под прогресс через SQLAlchemy 2.0 async и Alembic, миграции накатывай при деплое.
- FSM оставь для коротких диалогов. MemoryStorage на прототипе, RedisStorage или PyMongoStorage на проде,
state_ttlпод длину шага. - Сериализуй ходы одного игрока локом, чтобы два запроса не писали в одну кампанию одновременно.
- Добавь ключ идемпотентности хода и пиши его только после успеха.
- Пиши слепок состояния до и после хода, хотя бы для отладки и реплея.
- Всё, что держишь в памяти процесса, проверь вопросом: что будет, если это пропадёт посреди хода?
Когда база для бота не нужна
База для игрового бота не нужна, если партия короче нескольких минут и её не жалко начать заново: квиз из пяти вопросов, кости, угадайка. Тут хватит 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-конвейером по темам и заметкам из практики, факты сверены с первоисточниками, финальный текст я прочитал, поправил и утвердил перед публикацией. Обложку к статье тоже рисует нейросеть. Про сам конвейер — в разделе обо мне.
