expire_on_commit в Django: как одна строка превращает N+1 в N² запросов
Представьте: вы оптимизировали код, убрали N+1 запросов с помощью предзагрузки, тесты зелёные, ревью пройдено. Но в проде база данных задыхается, а количество запросов выросло квадратично. Знакомая ситуация? Именно такую историю недавно описал на Хабре разработчик, который, решая классическую пробле

Представьте: вы оптимизировали код, убрали N+1 запросов с помощью предзагрузки, тесты зелёные, ревью пройдено. Но в проде база данных задыхается, а количество запросов выросло квадратично. Знакомая ситуация? Именно такую историю недавно описал на Хабре разработчик, который, решая классическую проблему N+1 в фоновом планировщике, чуть не выкатил версию с катастрофической нагрузкой. Виновата оказалась одна строка — параметр expireoncommit. В этой статье разберём, как работает этот параметр, почему он ломает предзагрузку в фоновых задачах и как избежать подобных граблей.
Как одна строка превращает оптимизацию в квадрат
Автор рассказал, что убрал N+1 запросов самым учебным способом — с помощью предзагрузки связанных объектов. Однако после включения кода в фоновом планировщике количество запросов к базе не уменьшилось, а выросло в геометрической прогрессии. Вместо N+1 запросов стало N². Причина — в том, что предзагрузка пачкой работает только в рамках одной транзакции. Если после неё происходит commit, Django по умолчанию сбрасывает кэш загруженных объектов (expireoncommit=True), и при следующем обращении к связанному объекту выполняется новый запрос.
В фоновом планировщике, где каждая задача выполняется в отдельной транзакции, предзагрузка просто не успевала дать эффект. Каждый цикл обращения к связанным объектам приводил к новому запросу, и в итоге получался квадрат от числа пользователей. Автор приводит замеры: с expireoncommit=True количество запросов росло как N², а с False — оставалось линейным.
Предыстория и контекст
Проблема N+1 — одна из самых известных в ORM. Она возникает, когда для каждого элемента списка выполняется отдельный запрос для получения связанных данных. Классическое решение — использовать selectrelated или prefetchrelated, чтобы загрузить все связанные объекты одним запросом. Однако в Django есть подводный камень: параметр expireoncommit в QuerySet. По умолчанию он равен True, и после commit все загруженные объекты помечаются как устаревшие. Это сделано для того, чтобы данные не были устаревшими после завершения транзакции, но в фоновых задачах, где транзакции короткие и частые, это приводит к тому, что предзагрузка теряет смысл.
Автор отмечает, что ошибка не видна ни на ревью, ни в тестах, потому что тесты обычно не замеряют количество запросов. Он советует использовать библиотеки типа django-test-migrations или просто считать запросы в тестах, чтобы ловить такие регрессии.
Почему expireoncommit так опасен?
Когда вы выполняете prefetchrelated, Django загружает связанные объекты и сохраняет их в кэше. Но если после этого происходит commit, кэш очищается. В веб-приложениях это обычно не проблема, потому что запрос выполняется в рамках одного HTTP-запроса и одной транзакции. Но в фоновых задачах, которые работают в цикле и делают commit после каждой итерации, предзагрузка становится бесполезной.
Например, у вас есть список пользователей, и для каждого нужно получить его заказы. Вы делаете prefetchrelated('orders'), и Django выполняет один запрос для всех заказов. Но если внутри цикла вы делаете commit, то при следующем обращении к user.orders Django выполнит новый запрос, потому что кэш сброшен. В итоге вместо одного запроса вы получите N запросов — по одному на каждого пользователя.
Технические подробности: как работает expireoncommit
Параметр expireoncommit появился в Django 2.0. Он управляет поведением QuerySet: если True, то после commit все загруженные объекты помечаются как устаревшие, и при следующем доступе к ним будет выполнен новый запрос. Если False, кэш сохраняется до конца жизненного цикла объекта.
Автор рекомендует использовать expireoncommit=False в фоновых задачах, если вы уверены, что данные не изменятся в течение выполнения задачи. Однако это может привести к устаревшим данным, поэтому нужно быть осторожным. Альтернатива — использовать selectrelated вместо prefetchrelated, но это работает только для связей ForeignKey и OneToOne, а не для ManyToMany.
Также можно использовать метод qs.iterator(), но он не решает проблему с expireoncommit. Лучший способ — отключить expireoncommit для конкретного QuerySet или для всей задачи.
Кого затронет и как
Эта проблема касается всех, кто использует Django ORM в фоновых задачах: Celery, RQ, или просто скриптах. Особенно актуально для высоконагруженных систем, где количество запросов к базе критично. Разработчики, которые полагаются на prefetchrelated, могут столкнуться с деградацией производительности после внедрения.
Для российских разработчиков эта тема тоже актуальна, так как Django широко используется в стартапах и enterprise-проектах. Важно понимать, что тесты, которые не проверяют количество запросов, не защищают от таких регрессий. Рекомендуется добавить в CI проверку на количество запросов, используя, например, django-assert-num-queries.
Как отключить expireoncommit в фоновых задачах?
Если вы столкнулись с этой проблемой, есть несколько способов её решения. Самый простой — установить expireoncommit=False для конкретного QuerySet. Например, вместо MyModel.objects.all() напишите MyModel.objects.all().expireoncommit(False). Это отключит сброс кэша после commit для этого набора объектов.
Другой способ — использовать контекстный менеджер transaction.atomic() с параметром duration, но это не совсем то же самое. Лучше всего обернуть выполнение задачи в блок, где expireoncommit=False, чтобы все запросы внутри задачи сохраняли кэш.
Важно помнить, что отключение expireoncommit может привести к устаревшим данным, если в течение задачи происходят изменения в базе. Поэтому применяйте это только тогда, когда вы точно знаете, что данные не изменятся.
Что будет дальше
Автор планирует добавить в свой проект тесты, которые замеряют количество запросов, и рекомендует всем делать то же самое. В Django сообществе уже обсуждают улучшения, которые могли бы сделать expireoncommit более предсказуемым, но пока это поведение остаётся по умолчанию.
В будущем, возможно, Django изменит значение по умолчанию или добавит предупреждения, но пока разработчикам приходится быть внимательными. Стоит следить за обновлениями Django и читать release notes, чтобы не пропустить изменения в этом поведении.
Итог
История с expireoncommit — отличный пример того, как неочевидные особенности ORM могут свести на нет усилия по оптимизации. Ключевой вывод: всегда проверяйте количество запросов в тестах, особенно в фоновых задачах. И помните, что предзагрузка пачкой — не панацея, если не учитывать контекст транзакций.