N+1 запросов: убийца производительности, которого все знают и никто не замечает
Есть проблемы, о которых говорят на каждой конференции, пишут в каждом учебнике и которые всё равно продолжают жить в каждом втором продакшн-проекте. N+1 — именно такая. Это как тараканы: знаешь о них, ведёшь с ними борьбу, думаешь что победил — а потом открываешь новый модуль и привет.
Сегодня расскажу про реальный кейс из нашего highload-проекта на PHP 8.2 + CodeIgniter 4. И покажу, как мы с этим боролись системно, а не точечными заплатками.
Что такое N+1 на практике
Теория все знают. Загружаешь список из N объектов, потом для каждого делаешь ещё один запрос. Итого 1 + N запросов вместо 1-2. При N=100 это 101 запрос вместо 2. Ничего страшного, да? Нет.
Вот реальный пример из нашего кода. Страница со списком заказов:
// OrderModel.php — выглядит невинно
public function getOrdersList(int $page = 1): array
{
$orders = $this->paginate(50);
foreach ($orders as &$order) {
// Запрос #1 ... #50: грузим пользователя
$order['user'] = model('UserModel')->find($order['user_id']);
// Запрос #51 ... #100: грузим товары заказа
$order['items'] = model('OrderItemModel')
->where('order_id', $order['id'])
->findAll();
// Запрос #101 ... #150: грузим статус доставки
$order['delivery'] = model('DeliveryModel')
->where('order_id', $order['id'])
->first();
}
return $orders;
}
50 заказов на странице. Итого: 1 (список) + 50 (пользователи) + 50 (товары) + 50 (доставка) = 151 запрос. На страницу. Которую открывают 500 раз в минуту. Итого 75,500 запросов в минуту только на эту одну страницу.
MySQL рыдал. Тихо, но рыдал.
Обнаружение: логирование запросов в CI4
Первым шагом была инструментация. CI4 позволяет логировать все запросы через Toolbar, но в highload нам нужно что-то более production-ready.
Мы написали EventSubscriber, который считает запросы на request:
<?php
namespace App\Subscribers;
use CodeIgniter\Events\Events;
class QueryCounterSubscriber
{
private static int $queryCount = 0;
private static array $slowQueries = [];
public static function register(): void
{
Events::on('DBQuery', [self::class, 'onQuery']);
}
public static function onQuery(\CodeIgniter\Database\Query $query): void
{
self::$queryCount++;
$duration = $query->getDuration(6);
if ($duration > 0.1) { // 100ms threshold
self::$slowQueries[] = [
'sql' => $query->getQuery(),
'duration' => $duration,
'trace' => debug_backtrace(DEBUG_BACKTRACE_IGNORE_ARGS, 10),
];
}
if (self::$queryCount > 50) {
log_message('warning', sprintf(
'N+1 suspicion: %d queries for %s %s',
self::$queryCount,
service('request')->getMethod(),
service('request')->getUri()->getPath()
));
}
}
public static function getReport(): array
{
return [
'total' => self::$queryCount,
'slow_queries' => self::$slowQueries,
];
}
}
Через неделю мы нашли 23 endpoint'а с N+1. Некоторые делали до 800 запросов за один HTTP request. Один из них — статистическая страница для администратора — делал 1,247 запросов. Нет, это не опечатка.
Решение 1: Eager Loading через Query Builder
В CI4 нет встроенного ORM с eager loading как в Laravel. Но это не повод делать N+1. Пишем вручную — это даже лучше, потому что контролируешь каждый запрос:
<?php
namespace App\Models;
class OrderModel extends Model
{
public function getOrdersWithRelations(int $page = 1): array
{
// Запрос 1: список заказов
$orders = $this->paginate(50);
if (empty($orders)) {
return [];
}
$orderIds = array_column($orders, 'id');
$userIds = array_unique(array_column($orders, 'user_id'));
// Запрос 2: все пользователи одним запросом
$users = model('UserModel')
->whereIn('id', $userIds)
->findAll();
$usersMap = array_column($users, null, 'id');
// Запрос 3: все товары заказов одним запросом
$items = model('OrderItemModel')
->whereIn('order_id', $orderIds)
->findAll();
$itemsMap = [];
foreach ($items as $item) {
$itemsMap[$item['order_id']][] = $item;
}
// Запрос 4: вся доставка одним запросом
$deliveries = model('DeliveryModel')
->whereIn('order_id', $orderIds)
->findAll();
$deliveriesMap = array_column($deliveries, null, 'order_id');
// Собираем результат в памяти
foreach ($orders as &$order) {
$order['user'] = $usersMap[$order['user_id']] ?? null;
$order['items'] = $itemsMap[$order['id']] ?? [];
$order['delivery'] = $deliveriesMap[$order['id']] ?? null;
}
return $orders;
}
}
151 запрос → 4 запроса. Время ответа страницы: с 3.2 секунды до 87 миллисекунд. Разница в 37 раз. Буквально изменением подхода к написанию одного метода.
Решение 2: Автоматическое обнаружение в CI4 Filter
Чтобы N+1 не возвращались незаметно, добавили Filter для development/staging:
<?php
namespace App\Filters;
use CodeIgniter\Filters\FilterInterface;
use CodeIgniter\HTTP\RequestInterface;
use CodeIgniter\HTTP\ResponseInterface;
class QueryAnalyzerFilter implements FilterInterface
{
private const WARNING_THRESHOLD = 30;
private const ERROR_THRESHOLD = 100;
public function before(RequestInterface $request, $arguments = null): void
{
QueryCounterSubscriber::reset();
}
public function after(
RequestInterface $request,
ResponseInterface $response,
$arguments = null
): ResponseInterface {
$report = QueryCounterSubscriber::getReport();
$count = $report['total'];
if ($count >= self::WARNING_THRESHOLD) {
$level = $count >= self::ERROR_THRESHOLD ? 'error' : 'warning';
log_message($level, sprintf(
'[QueryAnalyzer] %s %s: %d queries',
$request->getMethod(),
$request->getUri()->getPath(),
$count
));
if (ENVIRONMENT === 'development') {
$response->setHeader('X-Query-Count', (string) $count);
$response->setHeader('X-Query-Warning', $count >= self::ERROR_THRESHOLD ? 'N+1_DETECTED' : 'HIGH_QUERIES');
}
}
return $response;
}
}
Теперь любой новый endpoint с N+1 автоматически логируется с уровнем error. Это попадает в наш ELK стек, алертинг срабатывает, приходит уведомление. Разработчик узнаёт о проблеме ещё на code review этапе, а не когда MySQL упал в прод.
Цифры до и после
Endpoint | Запросов ДО | Запросов ПОСЛЕ | Время ответа ДО | Время ПОСЛЕ |
|---|---|---|---|---|
Список заказов | 151 | 4 | 3200ms | 87ms |
Профиль пользователя | 89 | 3 | 1800ms | 45ms |
Каталог товаров | 347 | 5 | 8900ms | 210ms |
Статистика | 1247 | 12 | 31000ms | 890ms |
Суммарная нагрузка на MySQL упала на 78%. Не шучу. Просто убрали N+1 — и почти вдвое освободили ресурсы БД.
Главный вывод
N+1 — это не ошибка джуниоров. Это системная проблема, которая возникает когда нет инструментов для её обнаружения и нет культуры её предотвращения. Добавьте автоматическое логирование числа запросов. Сделайте Code Review check на паттерны N+1. И помните: каждый .find() внутри цикла — это потенциальная бомба.
Удачи вашим базам данных. 🗄️
Recommended Comments