Разработка · 7 мин чтения

Бот с базой данных PostgreSQL на Aiogram: архитектура и код

Когда бот на aiogram начинает хранить не пару chat_id, а реальные данные - заказы, лиды, историю переписки, статусы подписки, - встаёт вопрос хранения. Я закладываю бот с базой данных PostgreSQL в архитектуру сразу, даже если на старте нужно хранить десяток полей: переезд с SQLite или json-файла на Postgres посреди продакшена обходится дороже, чем правильная схема с нуля. Ниже - рабочая архитектура, которую использую в проектах на aiogram 3.x: слои, подключение через SQLAlchemy 2.0 и asyncpg, миграции Alembic, хранение FSM и деплой в Docker.

Зачем telegram-боту на aiogram база PostgreSQL, а не SQLite

SQLite подкупает простотой - один файл, ноль настройки, годится для MVP и тестов. Но aiogram работает асинхронно, и на потоке от 150-200 сообщений в минуту SQLite начинает отдавать database is locked при параллельной записи: движок блокирует файл целиком на время транзакции. Упирался в это на боте для приёма заявок, где вебхуки от Т‑Банка про оплату и сообщения пользователей прилетали почти одновременно - часть записей терялась без внятной ошибки в логах.

Postgres держит десятки параллельных подключений через пул, поддерживает транзакции с изоляцией на уровне строк и не разваливается при конкурентной записи. Для бота это критично, если он:

  • принимает оплату (эквайринг Т‑Банка, ЮKassa) и должен зафиксировать статус заказа без потерь;
  • работает как приёмник заявок с интеграцией в СДЭК или CRM;
  • хранит историю диалога для чат-бота с базой знаний на RAG;
  • растёт за 500+ активных пользователей в день.

Для бота-опросника на 20 вопросов без интеграций хватит и SQLite. Но если в планах хоть одна внешняя интеграция - закладываю Postgres сразу, чтобы не переписывать слой хранения на 300 активных пользователях.

Архитектура проекта: слои и модули

Плоский bot.py с десятком хендлеров и прямыми SQL-запросами внутри живёт до первой сложной фичи. Дальше правки в схему занимают часы вместо минут, потому что SQL-запрос к таблице users раскидан по семи файлам. Использую слоистую структуру:

  • handlers/ - только aiogram-логика: разбор апдейтов, клавиатуры, состояния FSM;
  • services/ - бизнес-логика: расчёт цены заказа, проверка лимитов, вызов внешних API;
  • repositories/ - весь SQL и работа с сессией SQLAlchemy, ничего про aiogram не знает;
  • models/ - декларативные модели SQLAlchemy;
  • db/ - engine, sessionmaker, middleware, конфиг подключения.

Хендлер не видит SQL напрямую - он вызывает OrderRepository.create(session, user_id, items), а репозиторий не знает про Message или CallbackQuery. Такое разделение экономит время на тестах: репозитории проверяются на отдельной тестовой базе без поднятия бота целиком, а бизнес-логику в services можно гонять через pytest с моками репозитория.

Подключение к PostgreSQL: asyncpg, SQLAlchemy 2.0 и пул соединений

Для aiogram 3.x беру связку asyncpg как драйвер и SQLAlchemy 2.0 в async-режиме поверх него - asyncpg сам по себе быстрее, но без ORM SQL обрастает дублированием уже на 5-6 таблицах.

from sqlalchemy.ext.asyncio import create_async_engine, async_sessionmaker
from aiogram import BaseMiddleware

engine = create_async_engine(
    "postgresql+asyncpg://bot:secret@localhost:5432/botdb",
    pool_size=10,
    max_overflow=5,
    pool_pre_ping=True,
)
async_session = async_sessionmaker(engine, expire_on_commit=False)


class DbSessionMiddleware(BaseMiddleware):
    async def __call__(self, handler, event, data):
        async with async_session() as session:
            data["session"] = session
            return await handler(event, data)

pool_pre_ping=True спасает от протухших соединений после простоя Postgres в контейнере - без него бот раз в сутки падал на первом запросе после ночного затишья с OperationalError. pool_size держу на 10-15 для бота с нагрузкой до 1000 пользователей в день, для более крупных проектов поднимаю до 20-30 и слежу за max_connections в postgresql.conf, чтобы не упереться в лимит инстанса.

Сессию прокидываю через middleware - на каждый апдейт открывается новая сессия и закрывается после хендлера, без утечек соединений.

Вариант Скорость Миграции Порог входа
asyncpg напрямую Высокая Руками Нужно писать SQL
SQLAlchemy 2.0 async Средняя-высокая Alembic Средний
Tortoise ORM Средняя Aerich Низкий

Для бота с 3-4 таблицами разница в скорости между чистым asyncpg и SQLAlchemy почти не ощущается - на нагрузке 50-100 запросов в секунду оверхед ORM в пределах 5-10%. Беру SQLAlchemy почти всегда: миграции и типизация моделей экономят время при росте схемы.

Бесплатный материал

🎁 Полезный скрипт в подарок

Подпишитесь на Telegram - пришлю готовый скрипт по этой теме.

Без спама. Отписка в 1 клик.

Модели и миграции: Alembic для эволюции схемы

from datetime import datetime
from sqlalchemy import BigInteger, String, ForeignKey, func
from sqlalchemy.orm import Mapped, mapped_column, relationship, DeclarativeBase


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"

    id: Mapped[int] = mapped_column(BigInteger, primary_key=True, autoincrement=False)
    username: Mapped[str | None] = mapped_column(String(64))
    created_at: Mapped[datetime] = mapped_column(server_default=func.now())

    orders: Mapped[list["Order"]] = relationship(back_populates="user")


class Order(Base):
    __tablename__ = "orders"

    id: Mapped[int] = mapped_column(primary_key=True)
    user_id: Mapped[int] = mapped_column(ForeignKey("users.id"))
    status: Mapped[str] = mapped_column(String(32), default="new")
    amount: Mapped[int] = mapped_column()

    user: Mapped["User"] = relationship(back_populates="orders")

Схема бота меняется постоянно: сегодня нужно поле phone у заказа, через месяц - статус доставки от СДЭК, через два - таблица под эмбеддинги для чат-бота с базой знаний. Без миграций правки в проде - это либо ALTER TABLE руками через psql с риском забыть применить его на бэкапе, либо потеря данных при пересоздании таблиц.

Alembic генерирует миграцию по diff моделей и применяет её одной командой.

alembic init migrations
alembic revision --autogenerate -m "add orders table"
alembic upgrade head

revision - autogenerate не идеален - на переименовании колонки он предложит удалить старую и создать новую, что уронит данные. Каждую автосгенерированную миграцию читаю перед применением, особенно на боевой базе с заказами: цена ошибки - потерянные платежи.

Хранилище FSM-состояний и пользовательские данные в Postgres

FSM в aiogram по умолчанию живёт в MemoryStorage - рестарт контейнера обнуляет все состояния, и пользователь на середине заполнения заявки (например, многошаговая форма доставки с адресом и телефоном для интеграции с СДЭК) откатывается на старт. Для бота, который деплоится через CI на каждый пуш, это раздражает пользователей на третьем шаге формы.

Варианты:

  • RedisStorage - быстро, штатно поддерживается aiogram, но добавляет ещё один сервис в инфраструктуру;
  • собственное хранилище на Postgres через кастомный класс BaseStorage - состояние и data пишутся в ту же базу, что и остальные данные бота, без Redis в docker-compose.

Для проектов, где Redis уже есть под кэш или очереди - беру RedisStorage. Для небольших ботов без Redis пишу состояние в отдельную таблицу fsm_state (user_id, chat_id, state, data jsonb) - на 5-10 полей формы работает без проблем, а бэкап состояний идёт вместе с бэкапом основной базы.

Шаблон репозитория и middleware для такой связки выкладываю в библиотеке готовых скриптов - можно взять как основу и адаптировать под свою схему.

Docker Compose: бот, Postgres и миграции при деплое

Локально и на проде запускаю бота, Postgres и применение миграций одним docker-compose.yml - миграции идут отдельным сервисом, который стартует перед ботом и завершается после успешного upgrade head.

version: "3.9"

services:
  bot:
    build: .
    depends_on:
      migrate:
        condition: service_completed_successfully
    env_file: .env
    restart: unless-stopped

  migrate:
    build: .
    command: alembic upgrade head
    env_file: .env
    depends_on:
      - db

  db:
    image: postgres:16
    environment:
      POSTGRES_DB: botdb
      POSTGRES_USER: bot
      POSTGRES_PASSWORD: secret
    volumes:
      - pgdata:/var/lib/postgresql/data

volumes:
  pgdata:

На проде добавляю pg_dump в cron контейнера раз в сутки с ротацией на 7 дней - обходится дешевле, чем восстанавливать заказы за неделю вручную из логов. Если бот встроен в цепочку с n8n - например, уведомляет менеджера в отдельный чат при новом заказе или дёргает вебхук на смену статуса - держу n8n в том же docker-compose на соседнем порту, чтобы не гонять запросы между сервисами через интернет, когда оба крутятся на одном сервере.

Автоматизация в мессенджере

Telegram-бот / Mini App

от 30 000 ₽

Подробнее →

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

Нужна ли отдельная база для простого бота-опросника?

Если бот без интеграций и меньше 10 полей данных на пользователя, обычно хватает SQLite или даже json-файла на первых порах. Postgres закладываю, когда планируются платежи, CRM, СДЭК или рост аудитории за пару сотен активных пользователей в день - переезд на большую базу позже стоит дороже, чем сразу спроектировать схему под Postgres.

Чем плоха SQLite для aiogram-бота в проде?

Основная проблема - блокировка файла на запись: при параллельных апдейтах от нескольких пользователей часть транзакций падает с database is locked. На потоке от 150-200 сообщений в минуту это проявляется регулярно, а не как редкий случай.

Как переносить данные бота при переезде на новый сервер?

pg_dump в кастомном формате (-Fc) на старом сервере, pg_restore на новом - миграции Alembic на новой базе не трогаю, схема приезжает вместе с данными. Для бота с 10-15 таблицами и парой миллионов строк перенос занимает 10-20 минут в зависимости от канала между серверами.

Сколько стоит разработка бота с базой Postgres под ключ?

У меня разработка telegram-бота с базой Postgres начинается от 30 000 ₽ - цена растёт от количества интеграций: приём оплаты, CRM, СДЭК, многошаговые FSM-формы. На рынке у студий похожий бот может стоить в разы дороже за счёт менеджмента и допродажи часов, у фрилансеров - сопоставимо по цене, но без гарантии архитектуры, которая переживёт рост нагрузки.

Есть задача?

Обсудим в мессенджере

Расскажите, что нужно сделать — отвечу в течение 4 часов в рабочее время. Первая консультация бесплатно.

Самозанятый Калинкин Н. А. · работаю с физлицами и юрлицами

Продолжая пользование настоящим сайтом Вы выражаете своё согласие на обработку Ваших персональных данных (файлов куки) с использованием Yandex.Metrika.
Понятно