Проблема N+1 и EXPLAIN QUERY PLAN

Проблема N+1 запросов и идея EXPLAIN QUERY PLAN — как понять, что делает СУБД.

N+1 — антипаттерн, когда вместо одного запроса с JOIN приложение делает 1 запрос за списком и ещё N запросов по одному на каждый элемент.

Откуда берётся N+1

Типичный сценарий в коде с ORM: получили список из N пользователей (1 запрос), а потом в цикле для каждого тянем его заказы (ещё N запросов). Итого N+1 обращений к базе вместо одного. На 1000 пользователей это 1001 запрос — медленно из-за сетевых задержек на каждый.

# Псевдокод приложения — так делать НЕ надо:
users = db.query("SELECT * FROM users")          # 1 запрос
for u in users:
    # по запросу на каждого пользователя — это и есть +N
    orders = db.query("SELECT * FROM orders WHERE user_id = ?", u.id)

Лечение — заменить цикл одним запросом с JOIN (или загрузить все заказы одним WHERE user_id IN (...) и сгруппировать в коде). В ORM это называют «eager loading» / JOIN FETCH.

CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT);
INSERT INTO users (name) VALUES ('Аня'), ('Борис');

CREATE TABLE orders (id INTEGER PRIMARY KEY, user_id INTEGER, amount INTEGER);
INSERT INTO orders (user_id, amount) VALUES (1,500), (1,300), (2,900);

-- Один запрос вместо N+1: сразу пользователи с их заказами
SELECT u.name, o.amount
FROM users u
JOIN orders o ON o.user_id = u.id
ORDER BY u.name, o.amount;

Вывод:

Аня|300
Аня|500
Борис|900

Один JOIN вернул все нужные данные за одно обращение к базе. Это и есть решение N+1: переносим работу из цикла приложения в один SQL-запрос.

EXPLAIN QUERY PLAN: что делает СУБД

Чтобы понять, использует ли запрос индекс или сканирует всю таблицу, есть команда EXPLAIN (в SQLite — EXPLAIN QUERY PLAN). Она не выполняет запрос, а показывает план: как СУБД собирается его исполнять.

Например, до индекса план показывает SCAN TABLE users (полный скан), а после CREATE INDEXSEARCH TABLE users USING INDEX (поиск по индексу). Конкретный текст плана зависит от версии СУБД, поэтому показываем его как иллюстрацию, без запуска:

EXPLAIN QUERY PLAN
SELECT email FROM users WHERE city = 'Москва';

-- Без индекса:
--   SCAN users
-- После CREATE INDEX idx_users_city ON users(city):
--   SEARCH users USING INDEX idx_users_city (city=?)

Ключевые слова в плане: SCAN — читается вся таблица (часто плохо на больших данных), SEARCH ... USING INDEX — точечный поиск по индексу (хорошо). Анализ плана — главный инструмент оптимизации: сначала смотрим план, потом решаем, какой индекс добавить.

Итог

  • N+1 — это 1 запрос за списком плюс по запросу на каждый элемент; лечится одним JOIN или IN.
  • EXPLAIN QUERY PLAN показывает план без выполнения запроса.
  • SCAN = полный проход (часто медленно), SEARCH ... USING INDEX = быстрый поиск по индексу.
Проверьте себя
1. В чём суть проблемы N+1?
AОдин запрос работает слишком долго
BВместо одного JOIN делается 1 запрос за списком и ещё N по одному на элемент
CВ таблице N+1 колонок
DИндекс занимает слишком много места
2. Как обычно лечат N+1?
AДобавляют больше индексов
BЗаменяют цикл одним запросом с JOIN или WHERE ... IN (...)
CУвеличивают память сервера
DУдаляют связи между таблицами
3. Что показывает EXPLAIN QUERY PLAN?
AРезультат запроса
BПлан выполнения: сканирует ли СУБД таблицу или ищет по индексу
CСписок всех индексов в базе
DВремя в миллисекундах
Поддержать проект