Эндпоинт со списком заказов на локальной базе отвечает за 80 мс. На стейдже с живыми данными он уже думает две секунды, а в профилировщике вместо одного SQL-запроса висит сотня почти одинаковых. Код при этом выглядит невинно: цикл по заказам и обращение к order.customer.name.
Это классический случай N+1 запросов ORM: один запрос забирает список, а потом на каждую запись уходит ещё по одному запросу за связанными данными. Библиотека прячет их за обычным обращением к атрибуту, поэтому на ревью они не видны, а на тестовых пяти записях не чувствуются.
Ниже разберём, как заметить проблему по симптомам, как поймать её в логах и профилировщике пяти популярных ORM, как написать тест, который не даст ей вернуться, и как выбрать способ исправления. Отдельно поговорим о побочных эффектах самих исправлений: раздутых выборках, декартовом произведении и ловушках с пагинацией.
Коротко:
- Симптом: время ответа растёт вместе с размером страницы, а в slow query log пусто, потому что каждый отдельный запрос быстрый.
- Поймать эффект проще всего подсчётом SQL-запросов на одном обращении к эндпоинту, а не чтением кода.
- Чаще всего хватает одной правки в месте выборки: подгрузка связей заранее, без изменения моделей и бизнес-логики.
- Выбор между join, отдельным запросом по списку id и батчингом зависит от типа связи и от того, нужна ли пагинация.
- Лучшая защита после исправления: тест, который сравнивает число запросов на малом и большом наборе данных.
- Кэш здесь не лечение. Он прячет лишние обращения, но не убирает их.
Как выглядит эффект и почему ORM его скрывает
Возьмём Django. Выбираем сто заказов и в цикле печатаем имя клиента: for o in Order.objects.all()[:100]: print(o.customer.name). Первый запрос достаёт заказы. Затем при каждом обращении к o.customer библиотека видит, что связанный объект ещё не загружен, и делает отдельный SELECT по внешнему ключу. Итого 101 запрос вместо одного или двух.
Такое поведение называется ленивой загрузкой: связанные данные подтягиваются в момент первого обращения. Само по себе оно полезно. Если связь нужна для одной записи, тащить её заранее незачем. Беда начинается, когда обращение случается внутри цикла по набору.
Вложенность усиливает эффект. Заказы, в них позиции, у позиций товары: получаем 1 запрос на заказы, N на позиции и ещё N*M на товары. На странице из 50 заказов по 4 позиции это уже 251 запрос, хотя в коде всё те же три безобидные точки.
Есть и скрытая разновидность. В JPA связь @ManyToOne по умолчанию загружается жадно, то есть сразу. Запрос на список через JPQL или Criteria не учитывает эту настройку при построении SQL и потом догружает связанные сущности по одной. Разработчик свято верит, что ленивой загрузки у него нет, а запросы всё равно сыплются.
Симптомы: как заподозрить проблему до профилировщика
Профилировщик нужен для подтверждения, но у проблемы есть узнаваемый почерк. Если нашли два-три признака из списка, можно уверенно открывать логи.
- Время ответа линейно растёт с размером страницы: 10 элементов отдаются за 100 мс, 100 элементов за секунду.
- Нагрузка на процессор базы низкая, зато счётчик запросов в секунду зашкаливает, а число соединений в пуле растёт.
- В slow query log тишина. Каждый запрос по первичному ключу занимает миллисекунду, и порог медленных их не ловит. Медленным становится обработчик целиком.
- В APM или трассировке виден «гребешок»: десятки коротких одинаковых спанов подряд в одном запросе.
- Эндпоинт нормально работает на небольших данных и ломается после импорта или роста клиентской базы.
Если жалуются на медленный API из-за запросов к БД, а индексы на месте и планы выполнения выглядят чисто, эту гипотезу стоит проверять первой. Типичная ошибка здесь в том, чтобы тюнить сами запросы, хотя проблема не в скорости одного запроса, а в их количестве. Если же тормозит один тяжёлый запрос, разберите его план: как это делать, мы показали в статье о медленных SQL-запросах и EXPLAIN ANALYZE.
Логи и профилировщики: что включить в каждом стеке
Принцип один: включить вывод SQL, прогнать один запрос к эндпоинту и посчитать, сколько раз повторяется шаблон с разными параметрами. Инструменты отличаются только названиями настроек.
| Стек | Что включить | На что смотреть |
|---|---|---|
| Django | Логгер django.db.backends на уровне DEBUG, django-debug-toolbar, connection.queries | Панель SQL показывает дубли и подсвечивает похожие запросы |
| Hibernate / JPA | Логгер org.hibernate.SQL, hibernate.generate_statistics=true | В статистике растёт число подготовленных запросов и загрузок сущностей |
| SQLAlchemy | create_engine(..., echo=True) или событие before_cursor_execute | Серия одинаковых SELECT ... WHERE id = ? после основного запроса |
| Eloquent | DB::listen, Laravel Debugbar, Telescope | Количество запросов на страницу и дубли шаблонов |
| ActiveRecord | Лог разработки, gem Bullet, подписка на sql.active_record | Bullet прямо пишет, какую связь пора подгрузить заранее |
Читать логи удобнее не глазами, а через группировку. Нормализуйте текст запроса, заменив параметры на плейсхолдеры, и посчитайте частоты: запрос, который встречается 100 раз на один вызов эндпоинта, и есть подозреваемый. Эту работу часто делают сами APM-системы. Распределённая трассировка тоже поможет, но только как один из способов поиска, и локально её хватает не всегда.
Чтобы такие проблемы были видны без ручного поиска, нужны метрики, логи и трейсы с числом запросов на вызов. Как это выстроить, мы разобрали в статье про observability в backend.
Тест, который считает запросы и не даёт проблеме вернуться
Исправление без теста живёт до первого рефакторинга. Кто-то уберёт select_related или добавит в сериализатор новое поле, и всё вернётся. Тест на количество запросов стоит нескольких строк.
- Django:
assertNumQueries(2)в тесте илиCaptureQueriesContextдля просмотра самих запросов. - Hibernate: сбросить статистику перед вызовом и проверить
getPrepareStatementCount(). Есть и готовые прокси-библиотеки, которые умеют падать при превышении лимита. - SQLAlchemy: слушатель события
before_cursor_execute, который складывает запросы в список, и проверка длины списка. - Eloquent:
DB::enableQueryLog()в начале теста иcount(DB::getQueryLog())в конце. - ActiveRecord: подписка на
sql.active_recordчерезActiveSupport::Notifications, исключая служебные запросы схемы и транзакций.
Самый сильный вариант проверки не требует знать точную цифру. Создайте в фикстурах 3 записи, замерьте число запросов, потом создайте 30 и замерьте снова. Если числа различаются, где-то в цикле прячется лишнее обращение к базе. Такой тест не ломается, когда вы добавляете новое необходимое поле, и сразу ловит регрессию.
Фиксируйте в тесте точное число запросов только для горячих эндпоинтов. На остальных лучше сравнение малого и большого набора, иначе тесты начнут падать при каждой безобидной правке.
Исправление без переписывания: что включить в каждом ORM
Лечение почти всегда локально: меняется место, где формируется выборка, а модели, сервисы и шаблоны остаются нетронутыми. Идея одна: сказать библиотеке заранее, какие связи понадобятся.
Django: select_related и prefetch_related
Связи вида «многие к одному» и «один к одному» закрывает select_related('customer'): библиотека делает один запрос с JOIN. Для обратных внешних ключей и «многие ко многим» подходит prefetch_related('items'): выполняются два запроса, второй с условием IN по списку id из первого. Если нужна фильтрация вложенного набора, передайте объект Prefetch('items', queryset=Item.objects.filter(active=True)). Правило выбора простое: JOIN для одиночных связей, отдельный запрос для коллекций.
Hibernate и JPA: join fetch, EntityGraph, BatchSize
Самый прямой путь в Hibernate: переписать запрос как select o from Order o join fetch o.customer. В Spring Data то же достигается аннотацией @EntityGraph(attributePaths = {"customer"}) на методе репозитория, и текст запроса менять не придётся. Если связей много и join становится тяжёлым, включите батчинг: @BatchSize(size = 50) на связи или глобальное hibernate.default_batch_fetch_size. Тогда вместо N запросов будет N/50, и модель менять не нужно. Заодно пересмотрите @ManyToOne, который по умолчанию жадный: переведите его в FetchType.LAZY, а нужные связи подтягивайте явно.
SQLAlchemy: joinedload и selectinload
Для одиночных связей берите joinedload(Order.customer), для коллекций selectinload(Order.items): он делает второй запрос с IN по первичным ключам и не размножает строки. Для защиты укажите в связи lazy='raise' или добавьте в запрос raiseload('*'): любое незаявленное ленивое обращение тогда падает с ошибкой, и проблема всплывает на тестах, а не в продакшене.
Eloquent: with и preventLazyLoading
Заранее загрузить связи можно через Order::with(['customer', 'items']), а для уже полученной коллекции через $orders->load('customer'). Если нужны только счётчики, используйте withCount('items'), и сами позиции в память не попадут. Чтобы ловить промахи в разработке, вызовите Model::preventLazyLoading(! app()->isProduction()): в локальной среде ленивое обращение к связи бросит исключение.
ActiveRecord: includes, preload, eager_load
Тут три варианта. preload всегда делает отдельные запросы, eager_load всегда строит LEFT OUTER JOIN, а includes сам выбирает между ними и переключается на JOIN, если в условиях участвует таблица связи. Для защиты есть strict_loading: при включении ленивая загрузка вызывает ошибку или запись в лог.
Join, отдельный запрос по списку id или батчинг: как выбрать
Главный вопрос в том, что вы забираете: одиночную связь или коллекцию, и нужна ли пагинация. От этого зависит, какая цена окажется ниже.
| Способ | Когда подходит | Чем рискуем |
|---|---|---|
| Join в одном запросе | Связь «многие к одному», нужны поля соседней таблицы, страница небольшая | Дублирование строк при коллекциях, широкие строки |
| Второй запрос с IN по id | Коллекции, несколько связей одновременно, пагинация родителей | Длинный список id при очень больших выборках, лишний сетевой заход |
| Батчинг ленивой загрузки | Нельзя заранее знать, какие связи понадобятся, правок в коде нужно минимум | Запросы всё равно остаются, просто группируются |
| Отдельный запрос с агрегатом | Нужен счётчик или сумма, а не сами записи | Нужно аккуратно написать группировку, чтобы не потерять родителей без детей |
Рабочее правило: одиночные связи через join, коллекции через второй запрос. Так вы получаете постоянное число обращений и не раздуваете результат. Батчинг хорош как страховочная сетка на уровне конфигурации: он не заменяет явную подгрузку на горячих путях, но смягчает последствия забытых мест.
Слепая гонка за минимальным числом запросов тоже не цель. Три запроса на экран, которые отдают то, что нужно, лучше одного гигантского с JOIN на пять таблиц, который тащит мегабайты лишних колонок.
Побочные эффекты исправления: декартово произведение, раздутые выборки, пагинация
Исправление по принципу «подгрузим всё сразу» создаёт собственные проблемы. Вот три самых частых.
Декартово произведение. Если в одном запросе сделать join по двум коллекциям, например заказ с 10 позициями и 5 платежами, база вернёт 50 строк на заказ. Для 100 заказов это 5000 строк, и все они придут с дублями колонок заказа. В Hibernate при попытке жадно загрузить две коллекции типа List вы вообще получите MultipleBagFetchException. Лекарство: одну коллекцию тянуть join-ом, другую отдельным запросом, либо использовать Set и батчинг, либо в SQLAlchemy и Django брать второй запрос для каждой коллекции.
Пагинация с коллекциями. Join по коллекции плюс LIMIT режет не родителей, а строки результата. Hibernate в такой ситуации пишет предупреждение про применение firstResult и maxResults в памяти и грузит всю выборку в приложение, а потом обрезает. Безопасная схема из двух шагов: сначала постранично выбрать id родителей, затем одним запросом достать родителей вместе со связями по этим id. Prefetch в Django и selectinload в SQLAlchemy делают то же самое автоматически.
Раздутые выборки. Подгруженная связь приносит все колонки, включая тяжёлые: тексты, JSON, блобы. Если в ответе нужны только имя и статус клиента, ограничьте поля: only('id', 'name') в Django, проекцию или DTO-запрос в JPA, load_only в SQLAlchemy, select() внутри with() в Eloquent. Заодно уменьшится и расход памяти приложения.
Представим экран заказов на 100 строк. У каждого заказа 8 позиций и 3 платежа. Join по обеим коллекциям вернёт 2400 строк. Два отдельных запроса с IN вернут 100 заказов, 800 позиций и 300 платежей, то есть 1200 строк суммарно, без дублирования колонок заказа.
Сериализаторы и GraphQL-резолверы: где проблема прячется глубже
В ревью выборку обычно смотрят внимательно, а вот сериализацию нет. Именно там чаще всего и рождаются скрытые обращения к базе: вложенный сериализатор в Django REST Framework, поле SerializerMethodField, которое что-то считает по связи, Jackson, обходящий ленивые коллекции сущности, as_json с include в Rails, ресурс Laravel, который читает $this->author->name для каждого элемента.
Практическое правило: перечень связей, нужных ответу, должен жить рядом с сериализатором. В DRF это метод get_queryset вьюсета, где подгружаются поля, вложенные в сериализатор. В Laravel это whenLoaded в ресурсе вместе с with в контроллере. И тест, который считает запросы на уровне HTTP, а не на уровне менеджера, поймает рассинхрон между ними.
В GraphQL ситуация хитрее, потому что сервер не знает заранее, какие поля запросит клиент. Резолвер автора для каждого поста в списке превращается в тот самый цикл. Стандартное решение называется DataLoader: резолверы не ходят в базу сразу, а складывают ключи в очередь, а загрузчик в конце такта собирает их в один пакет и делает один запрос с IN. Оригинальную реализацию выпустила команда Facebook в виде библиотеки graphql/dataloader. Аналоги есть везде: встроенный загрузчик в Strawberry для Python, java-dataloader вместе с graphql-java и аннотация @BatchMapping в Spring for GraphQL.
Важный нюанс для DataLoader: создавайте экземпляр загрузчика на каждый входящий запрос. Если сделать его глобальным, внутренний кэш начнёт отдавать данные одного пользователя другому и будет устаревать. Функция пакетной загрузки обязана возвращать результаты в том же порядке, что и ключи, и подставлять пустое значение для ненайденных, иначе ответы разъедутся по полям.
Когда лечить не нужно и где обычно ошибаются
Не каждое повторение запроса требует исправления. Если на странице всегда три элемента, лишние два запроса за связями ничего не стоят, а усложнённый код стоит. Не трогайте и редкие админские выгрузки, где время не критично. Оптимизация оправдана там, где число повторов зависит от данных, а эндпоинт горячий.
Кэширование тоже не выход: оно убирает обращение к базе, но сам цикл остаётся, и при промахе или сбросе кэша вся нагрузка возвращается разом. Сначала исправляют обращение к данным, затем при необходимости кэшируют результат.
Типичные ошибки при исправлении выглядят так:
- Включить жадную загрузку на уровне модели для всех сценариев. Редкие запросы начнут тащить связи, которые им не нужны, и общая нагрузка вырастет.
- Чинить симптом в сериализаторе, оставляя выборку прежней: загруженные в сериализаторе объекты снова уходят в ленивые запросы по каждой строке.
- Вызвать
.count()или.exists()по связи внутри цикла, хотя связь уже подгружена. Это порождает новый запрос на каждую строку, хотя данные лежат в памяти. - Фильтровать подгруженную коллекцию в Python или применять
filter()к менеджеру связи, из-за чего предзагруженный набор игнорируется и запрос уходит заново. - Обложиться
join fetchна всех коллекциях, не проверив число строк в результате. - Считать работу законченной, не добавив тест. Через месяц новое поле в ответе снова запустит цикл запросов.
Чеклист
- Включите вывод SQL в локальной среде и откройте проблемный эндпоинт с реалистичным набором данных.
- Сгруппируйте запросы по шаблону и найдите тот, что повторяется десятки раз.
- Определите по стеку вызовов, где именно происходит обращение: в представлении, шаблоне, сериализаторе или резолвере.
- Для одиночных связей подключите join-загрузку, для коллекций загрузку вторым запросом с
IN. - Если есть пагинация и коллекции, сначала выбирайте id родителей, потом подгружайте детей.
- Ограничьте набор полей там, где связь нужна ради пары колонок.
- Проверьте число строк в результате на запросах с несколькими коллекциями.
- Добавьте тест, который сравнивает число запросов на 3 и на 30 записях.
- Включите в разработке запрет или предупреждение на ленивую загрузку, где ORM это поддерживает.
- Для GraphQL перейдите на загрузчики с пакетной выборкой и создавайте их на каждый запрос.