Jump to content
View in the app

A better way to browse. Learn more.

T.M.I IThub

A full-screen app on your home screen with push notifications, badges and more.

To install this app on iOS and iPadOS
  1. Tap the Share icon in Safari
  2. Scroll the menu and tap Add to Home Screen.
  3. Tap Add in the top-right corner.
To install this app on Android
  1. Tap the 3-dot menu (⋮) in the top-right corner of the browser.
  2. Tap Add to Home screen or Install app.
  3. Confirm by tapping Install.

N+1 запросов: убийца производительности, которого все знают и никто не замечает

(0 reviews)

Есть проблемы, о которых говорят на каждой конференции, пишут в каждом учебнике и которые всё равно продолжают жить в каждом втором продакшн-проекте. 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() внутри цикла — это потенциальная бомба.

Удачи вашим базам данных. 🗄️


0 Comments

Recommended Comments

There are no comments to display.

Configure browser push notifications

Chrome (Android)
  1. Tap the lock icon next to the address bar.
  2. Tap Permissions → Notifications.
  3. Adjust your preference.
Chrome (Desktop)
  1. Click the padlock icon in the address bar.
  2. Select Site settings.
  3. Find Notifications and adjust your preference.