← все задачи

SQL · задача 10 из 10

N+1 и как её увидеть в SQL

Средний 15 минут JOINORMагрегация

Условие

Страница со списком ста заказов показывает имя клиента и число позиций в каждом заказе. В логах — 201 запрос к базе. Объясните, откуда они берутся, и напишите один запрос, который вернёт всё нужное.

Что требуется

  • Один запрос вместо 201
  • Заказы без позиций не должны пропасть
  • Объяснить, как это же чинится на стороне ORM

Пример

order_id  customer_name  items_count
1         Иван                    3
2         Пётр                    0   <- заказ без позиций

Сначала уточните

Вопросы до кода — половина оценки. Молча начать печатать хуже, чем задать два вопроса.

  • Нужны ли сами позиции или только их количество?
  • Есть ли пагинация — сто заказов на странице или все сразу?
  • Какая ORM: от этого зависит рецепт (select_related, prefetch_related, joinedload)
Показать решение Скрыть решение

Решение

SELECT
    o.id AS order_id,
    c.name AS customer_name,
    COUNT(i.id) AS items_count
FROM orders o
JOIN customers c ON c.id = o.customer_id
LEFT JOIN order_items i ON i.order_id = o.id
GROUP BY o.id, c.name
ORDER BY o.id
LIMIT 100;

-- То же самое в Django ORM:
-- (Order.objects
--     .select_related('customer')
--     .annotate(items_count=Count('items'))[:100])

Почему так

Откуда берётся 201 запрос

  • Один запрос достаёт сто заказов, дальше в цикле по каждому дёргается клиент (100) и количество позиций (100)
  • В ORM это выглядит как обычное обращение к атрибуту, поэтому проблему не видно в коде — её видно в логе запросов
  • Время растёт линейно по числу строк на странице, и на проде это заметно сразу, а на тестовых данных — нет

Почему COUNT(i.id), а не COUNT(*)

  • После LEFT JOIN у заказа без позиций есть одна строка с NULL в колонках позиций
  • COUNT(*) посчитал бы её и выдал 1 вместо 0
  • COUNT по конкретной колонке пропускает NULL — ровно то поведение, которое нужно

Почему select_related и prefetch_related — разные вещи

  • select_related делает JOIN и подходит для «многие к одному»: клиент у заказа один
  • prefetch_related делает второй запрос и склеивает в Python — это для «одного ко многим», где JOIN размножил бы строки
  • Умение объяснить, почему нельзя всё решить одним JOIN, — ровно то, что проверяют этим вопросом

Что спросят дальше

  • Спросят про две агрегации сразу (количество позиций и сумма): JOIN по двум таблицам умножит строки — нужны подзапросы или FILTER
  • Спросят, как ловить N+1 в проде: django-debug-toolbar, логирование числа запросов на запрос, тесты с assertNumQueries
  • Спросят про LIMIT с JOIN: ограничение применяется после соединения, поэтому пагинацию делают по подзапросу с заказами

Тема пройдена

Это была последняя задача темы «SQL». Возьмите следующую тему или вернитесь к разобранным через неделю — на собеседовании важно вспомнить, а не узнать.