Бывают ошибки, которые не видит ни ревью, ни тесты. Код отрабатывает правильно, прогон зелёный, а запросов к базе он делает в сто раз больше прежнего. Я убрал из фонового планировщика классический N+1, причём самым учебным способом, и чуть не выкатил в прод версию, где нагрузка росла как квадрат числа пользователей.

Самое интересное вскрылось потом. Виновата была не та строка, на которую я думал, и объяснение, которое я себе придумал, оказалось неверным.

Контекст

Я делаю GymDesk — приложение для персональных фитнес-тренеров: расписание, журнал тренировок, напоминания перед занятием. Внутри FastAPI, SQLAlchemy 2.0, PostgreSQL, бот на aiogram. Крутится на скромной виртуалке в пол-ядра и гигабайт памяти.

Насторожил меня не сбой, а арифметика. В сервисе есть фоновый планировщик: раз в минуту он проверяет, кому пора отправить напоминание о тренировке, у кого заканчивается абонемент, кто дошёл до конца программы. Внутри каждого прохода стоял запрос на каждого пользователя.

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

Как было и что я сделал

Упрощённо:

users = db.query(User).filter(...).all()

for u in users:
    slots = db.query(Slot).filter(Slot.user_id == u.id, ...).all()   # запрос на каждого
    for s in slots:
        if pora(s, u):
            send(u, s)
            s.notified = True
            db.commit()

Лечится по учебнику: поднять всё одной пачкой и разложить в словарь.

by_user = {}
for chunk in _chunks([u.id for u in users]):
    for s in db.query(Slot).filter(Slot.user_id.in_(chunk), ...).all():
        by_user.setdefault(s.user_id, []).append(s)

_chunks режет IN (...) кусками по 500, потому что у PostgreSQL есть предел числа параметров, и упереться в него на большой базе проще, чем кажется.

Написал, прогнал тесты — зелено. Поведение не изменилось: уходят те же уведомления, в базе встают те же флаги.

Замер

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

from sqlalchemy import event

@event.listens_for(engine, "before_cursor_execute")
def _count(conn, cursor, statement, params, context, executemany):
    global _sql
    _sql += 1

На базе в двести пользователей тик выдал около сорока тысяч запросов. Против нескольких сотен у той версии, которую я «оптимизировал».

Тесты при этом были зелёные, и они не врали. Уведомления уходили ровно те же самые.

Почему: expire_on_commit

Первая зацепка нашлась в строке, которую я до этого читал десяток раз:

SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)

Здесь нет expire_on_commit=False, а значение по умолчанию — True. Это значит: после каждого db.commit() все объекты сессии помечаются протухшими. Не портятся и не исчезают, просто SQLAlchemy считает, что данные могли устареть, и при следующем обращении к любому полю честно идёт за свежими. Отдельным SELECT на объект.

А в моём цикле commit есть: я отмечаю отправленное.

Казалось бы, объяснение найдено: предзагрузил пачку, первый же коммит её обнулил, дальше всё читается заново. Логично. И — неверно.

Что показал эксперимент

Я собрал отдельный маленький бенчмарк, чтобы проверить объяснение, а не поверить ему. Он ничего не знает про мой проект, работает на SQLite в памяти и запускается одним файлом:

from sqlalchemy import Integer, String, Boolean, create_engine, event, select, update
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, sessionmaker


class Base(DeclarativeBase):
    pass


class User(Base):
    __tablename__ = "users"
    id: Mapped[int] = mapped_column(Integer, primary_key=True)
    name: Mapped[str] = mapped_column(String(50))
    tz: Mapped[str] = mapped_column(String(50))
    lead: Mapped[int] = mapped_column(Integer)
    chat_id: Mapped[int] = mapped_column(Integer)
    notified: Mapped[bool] = mapped_column(Boolean, default=False)


engine = create_engine("sqlite://")
Session = sessionmaker(bind=engine)      # expire_on_commit не указан -> True

_sql = 0


@event.listens_for(engine, "before_cursor_execute")
def _count(conn, cursor, statement, params, context, executemany):
    global _sql
    _sql += 1

И четыре варианта одного и того же цикла «прочитать поля → отправить → отметить → закоммитить»:

def naive_per_item(db, ids):
    """Классический N+1."""
    for uid in ids:
        u = db.get(User, uid)
        send(u.chat_id, u.name, u.tz, u.lead)
        u.notified = True
        db.commit()


def batch_orm(db, ids):
    """Пачкой, держим ORM-объекты."""
    users = list(db.scalars(select(User).where(User.id.in_(ids))))
    for u in users:
        send(u.chat_id, u.name, u.tz, u.lead)
        u.notified = True
        db.commit()


def batch_orm_rescan(db, ids):
    """То же самое плюс ОДНА строка: обращение ко всей пачке внутри цикла."""
    users = list(db.scalars(select(User).where(User.id.in_(ids))))
    for u in users:
        send(u.chat_id, u.name, u.tz, u.lead)
        u.notified = True
        db.commit()
        _ = [x.id for x in users]        # выглядит как перебор списка


def batch_tuples(db, ids):
    """Пачкой + значения сняты в кортежи ДО цикла."""
    users = list(db.scalars(select(User).where(User.id.in_(ids))))
    plan = [(u, u.chat_id, u.name, u.tz, u.lead) for u in users]
    for u, chat_id, name, tz, lead in plan:
        send(chat_id, name, tz, lead)
        u.notified = True
        db.commit()


def batch_bulk_mark(db, ids):
    """Кортежи + отметка одним UPDATE вместо коммита на каждого."""
    rows = db.execute(select(User.id, User.chat_id, User.name, User.tz, User.lead)
                      .where(User.id.in_(ids))).all()
    done = []
    for uid, chat_id, name, tz, lead in rows:
        send(chat_id, name, tz, lead)
        done.append(uid)
    db.execute(update(User).where(User.id.in_(done)).values(notified=True))
    db.commit()

Результат:

N

naive_per_item

batch_orm

batch_orm_rescan

batch_tuples

batch_bulk_mark

100

200

200

10 101

200

2

300

600

600

90 301

600

2

1000

2000

2000

1 001 001

2000

2

Тут меня ждали два сюрприза, и оба оказались важнее моего красивого объяснения.

Первый: сама по себе предзагрузка пачкой ничего не ломает. batch_orm даёт ровно столько же запросов, сколько наивный N+1, то есть 2N. Протухший объект перечитывается одним SELECT целиком, а не по запросу на каждое поле. За db.get() в наивной версии и за неявный рефреш в пачечной вы платите одинаково. Оптимизация просто не работает, но и хуже от неё не становится.

Значит версия «коммит обнулил пачку, поэтому всё подорожало» с замером не сходится. Красиво звучало, а объясняло не то.

Второй сюрприз: всю катастрофу устраивает одна строка, где происходит обращение ко ВСЕЙ пачке после коммита. В бенчмарке это [x.id for x in users]. В боевом коде это был _chunks([u.id for u in users]), спрятанный внутри вспомогательной функции. Глазами читается как перебор списка, операция вообще без базы. А на деле после каждого коммита протухли все N объектов, поэтому перебор стоит N запросов, и цикл из N итераций делает N × N обращений.

Формула ровно такая: N² + N + 1. Отсюда и мои сорок тысяч: двести пользователей дают 200² = 40 000.

Поэтому на маленьких данных проблему и не видно. Десять записей — сотня запросов, ничего подозрительного. Тысяча записей — миллион.

Лечение

Правило, к которому я пришёл: если в цикле есть commit, не держи в нём ORM-объекты ради чтения.

Всё нужное для решения снимается в обычные значения (кортежи, словари чисел) ещё до цикла. Кортежу протухать нечем, коммитов он не замечает:

plan = [(u, u.id, u.tz, u.lead, u.chat_id) for u in users]
uids = [u.id for u in users]     # тоже заранее: перебор ПОСЛЕ коммита стоил бы N запросов

ORM-объект остаётся первым элементом кортежа только потому, что нужен для записи. Для чтения используются соседние элементы, а не его поля.

Второе касается отметок. Вместо коммита на каждой итерации:

db.execute(update(Slot).where(Slot.id.in_(done)).values(notified=True))
db.commit()

Один UPDATE ... WHERE id IN (...) вместо сотен мелких коммитов. В бенчмарке это даёт две строки вместо двух тысяч. И дело не только в запросах: сотни коммитов в минуту это ещё и сотни дисковых синхронизаций.

В боевом коде после переделки тихий тик планировщика (а это 99% минут в сутках) укладывается в единицы запросов вместо тысяч.

Глобально выключать expire_on_commit я не стал: в той же сессии работают денежные проходы, где свежесть данных после коммита — свойство, на которое я осознанно опираюсь. Знать про поведение и писать циклы с его учётом дешевле, чем менять семантику всему приложению ради одного модуля.

Что я из этого вынес

Юнит-тесты такое не ловят в принципе. Поведение-то верное: отправилось нужное, флаги встали, тест абсолютно прав. Деградирует только число запросов, а его никто не утверждает. Если хотите такое ловить, нужен счётчик на before_cursor_execute и замер на данных, сравнимых с боевыми. У меня после этого случая замер лежит рядом с обычными тестами.

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

И мелочь для тех, кто пойдёт мерить: смотрите на второй прогон, а не на первый. Первый разово раскачивает привязки и ленивые загрузки, поэтому стабильно завышает. Я на нём один раз успел обрадоваться зря.

Комментарии (1)


  1. MonkAlex
    01.08.2026 09:42

    ORM с сущностями в целом часто скрывает подобные проблемы. Работать просто с SQL в таких сценариях выходит проще - запрос пишется точечно под сценарий, данные простые на входе и на выходе.