Антипаттерн: N+1 запросов — как заметить и починить

GuDron

dumpz.ws
Admin
Регистрация
28 Янв 2020
Сообщения
10,131
Реакции
1,573
Credits
36,282
Антипаттерн: N+1 запросов — как заметить и починить
Вы берёте список сущностей, а потом в цикле для каждой тянете связанные данные. В итоге - 1 запрос за «родителями» + N запросов за «детьми». Латентность растёт линейно от размера выборки.

Симптомы
- В логах много одинаковых коротких запросов.
- Кол-во запросов ≈ размеру списка.
- Страница/endpoint сильно «замедляется» при росте данных.

Плохой пример (SQL + псевдокод)
SQL:
-- Берём пользователей
SELECT id, name FROM users WHERE active = true;

-- Потом в цикле по каждому:
SELECT count(*) FROM orders WHERE user_id = :id;

Правильно (SQL, PostgreSQL) — сетевое мышление:
SQL:
SELECT u.id,
       u.name,
       count(o.*) AS orders_cnt
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.active = true
GROUP BY u.id, u.name;

Django ORM
Python:
# Плохо: в шаблоне/цикле обращаемся к user.orders -> N+1
users = User.objects.filter(active=True)

# Хорошо: подгрузим связи заранее
users = (User.objects
         .filter(active=True)
         .prefetch_related('orders'))          # для 1:N
# для 1:1 / ForeignKey используйте select_related('profile')

# Агрегация без цикла
from django.db.models import Count
users = (User.objects.filter(active=True)
         .annotate(orders_cnt=Count('orders')))

SQLAlchemy
Python:
from sqlalchemy.orm import selectinload, joinedload

# 1:N — безопаснее selectinload (батчирует IN (...))
users = (session.query(User)
         .options(selectinload(User.orders))
         .filter(User.active.is_(True))
         .all())

# 1:1 — joinedload
user = (session.query(User)
        .options(joinedload(User.profile))
        .get(user_id))

Практические советы
- Логируйте кол-во запросов на эндпойнт/страницу. В Django - django-debug-toolbar, assertNumQueries в тестах; в SQLAlchemy - echo/интеграция с логгером.
- Индексы: обязательно orders(user_id); если фильтруете по статусу - составной (user_id, status).
- Батчинг вместо циклов: тяните детей одним запросом WHERE user_id IN (...), затем мапьте в памяти.
- Осторожно с joinedload для 1:N на больших выборках - риск «взрыва» строк. Для 1:N чаще выбирайте selectinload.
- Колонки по делу: не тащите SELECT *, берите только нужные поля.
- Пагинация: уменьшает N и давление на сеть/память.
- EXPLAIN (ANALYZE, BUFFERS) - проверяйте планы и кардинальности.
Думайте наборами, а не циклами. Eager loading + агрегаты закрывают 90% случаев N+1. Настройте мониторинг количества запросов - и ловите проблему до продакшена.

А как вы ловите N+1 у себя?