Программирование с ИИ
ENRU
7.6

Запросы, которые тормозят

N+1 и индексы на измеренных числах

Что это

Две причины, по которым бэкенд начинает тормозить, и обе ИИ воспроизводит охотно: лишние запросы в цикле и отсутствие индексов.

Коварство в том, что на маленьких данных обе незаметны. В разработке с десятью пользователями всё летает; на двухстах начинает подтормаживать; на тысячах встаёт. Код при этом не менялся.

Как выглядит в коде

N+1 запрос

Классика. Берём пользователей, потом для каждого — его подписки:

users = conn.execute("SELECT id, name FROM users").fetchall()
for u in users:
    conn.execute("SELECT city FROM subs WHERE user_id = ?", (u["id"],))

Выглядит невинно. На деле это один запрос плюс по одному на каждого пользователя — отсюда и название.

То же самое одним запросом:

conn.execute("""
    SELECT u.name, s.city
    FROM users u
    LEFT JOIN subs s ON s.user_id = u.id
""")

Замер на 200 пользователях и 2000 подписках:

измерено
N+1:    201 запросов, 20.7 мс
JOIN:     1 запрос,    2.7 мс, строк 2000

В восемь раз быстрее, и это на локальной базе в памяти. Если база на другом сервере, каждый запрос — ещё и поход по сети: двести походов вместо одного. Там разница будет не в восемь раз, а в сотни.

Индексы

Индекс — служебная структура, которая позволяет базе находить строки, не перебирая все.

CREATE INDEX idx_subs_user ON subs(user_id);

Тот же набор запросов до и после:

измерено
без индекса: 15.4 мс
с индексом:   1.4 мс     — быстрее в 10,6 раза

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

Правило: индекс нужен на поля, по которым идёт поиск или связывание — то, что стоит в WHERE и в ON. В нашем случае subs.user_id участвует в обоих.

Как увидеть проблему

В SQL есть встроенный способ спросить у базы, как она собирается выполнять запрос:

EXPLAIN QUERY PLAN SELECT city FROM subs WHERE user_id = 7;

Если в ответе SCAN subs — база перебирает всю таблицу. Если SEARCH subs USING INDEX — пользуется индексом. В PostgreSQL то же самое называется EXPLAIN ANALYZE и печатает ещё и реальное время.

Как сказать ИИ

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

Последняя фраза важна: без неё INNER JOIN потеряет тех, у кого подписок нет (статья 7.5).

Запрос
Вот схема таблиц и запросы, которые выполняются чаще всего.
Какие индексы стоит добавить и почему? Для каждого — какой
запрос он ускорит.
Запрос
Этот обработчик делает сколько запросов к базе при одном
HTTP-запросе? Посчитай.

Последний — хороший способ проверки: ответ «один» или «два» нормален, «зависит от числа пользователей» означает N+1.

На что смотреть

Запрос внутри цикла. Главный признак. Ищи for и execute рядом.

Запрос внутри функции, которая вызывается в цикле. То же самое, но на уровень глубже и потому незаметнее. Признак: функция вида get_cities(user) вызывается в обходе списка.

ORM, которая делает запросы лениво. В коде обращение к полю, в базе — запрос. for u in users: print(u.subs) выглядит как работа с памятью, а является N+1.

Индексов нет вообще. Кроме PRIMARY KEY, который создаётся сам. Если в схеме ни одного CREATE INDEX, а поиск по полям идёт — они нужны.

Индекс на всё подряд. Обратная крайность: каждый индекс замедляет запись и занимает место.

«Работает же». На твоих тестовых двадцати записях — работает. Проверяй на объёме, похожем на реальный.

Шпаргалка

  • N+1: запрос в цикле вместо одного JOIN. 201 запрос против одного.

  • Проверка: «сколько запросов к базе делает этот обработчик?»

  • Индекс — на поля из WHERE и ON. Десятикратное ускорение одной строкой.

  • Индексы замедляют запись, поэтому не на всё подряд.

  • EXPLAIN QUERY PLAN: SCAN — перебор, SEARCH USING INDEX — хорошо.

  • На маленьких данных обе проблемы невидимы.