Загрузка
Все статьи

Doctrine ORM + Symfony: N+1 проблема, которая убила производительность

Страница каталога загружалась 8 секунд. В профайлере — 340 SQL-запросов для отображения 20 товаров. Классический N+1 через Doctrine ORM. Проблема усугублялась тем, что часть запросов шла через lazy loading, а часть — через явные вызовы в Twig-шаблонах. Рассказываю, как мы нашли все источники N+1 и сократили количество запросов до 4.

8 секунд загрузки страницы каталога с 20 товарами. Это не опечатка. Когда я открыл Symfony Profiler, увидел 340 SQL-запросов. Да, для страницы, которая показывает 20 товаров. Клиент был в ярости, менеджеры жаловались, а я смотрел на профайлер и не мог поверить, что это наш код.

Как мы до этого докатились

Контекст: Symfony 6.4, Doctrine ORM 3.x, каталог товаров для B2B-клиента. Каждый товар имеет категорию, бренд, цены в разных валютах, изображения и характеристики. Сущность Product связана с пятью другими сущностями через lazy loading (по умолчанию в Doctrine). Когда Twig рендерит шаблон, он дергает каждую связь отдельно — и вот вам 340 запросов.

Исходный репозиторий

// src/Repository/ProductRepository.php

class ProductRepository extends ServiceEntityRepository
{
    public function findForCatalog(int $page, int $limit): array
    {
        return $this->createQueryBuilder('p')
            ->where('p.isActive = true')
            ->orderBy('p.createdAt', 'DESC')
            ->setFirstResult(($page - 1) * $limit)
            ->setMaxResults($limit)
            ->getQuery()
            ->getResult();
    }
}

Выглядит невинно, правда? 1 запрос на выборку 20 товаров. Но потом Twig начинает рендерить:

{# templates/catalog/index.html.twig #}

{% for product in products %}
    

{{ product.name }}

{{ product.category.name }} {{ product.brand.name }} {% for image in product.images %} {% endfor %} {{ product.getPrice('RUB').amount }} {% for attr in product.attributes %} {{ attr.name }}: {{ attr.value }} {% endfor %}
{% endfor %}

20 товаров × (1 категория + 1 бренд + 3 изображения + 2 цены + 5 атрибутов) = 20 × 12 = 240 запросов. Плюс 1 основной запрос. Плюс отдельные запросы из Doctrine для вычисления каких-то lazy-коллекций. Итого — 340. Каждый запрос по 15–25 мс. 340 × 20 мс = 6.8 секунд только на базу. Плюс рендеринг. Плюс сеть.

Шаг 1: JOIN FETCH для основных связей

Первое, что я сделал — добавил JOIN FETCH дляToOne-связей (категория и бренд). Это самый простой и эффективный способ избавиться от N+1 для одиночных связей:

public function findForCatalog(int $page, int $limit): array
{
    return $this->createQueryBuilder('p')
        ->addSelect('category', 'brand')
        ->leftJoin('p.category', 'category')
        ->leftJoin('p.brand', 'brand')
        ->where('p.isActive = true')
        ->orderBy('p.createdAt', 'DESC')
        ->setFirstResult(($page - 1) * $limit)
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();
}

Один запрос вместо 41 (20 товаров + 20 категорий + 20 брендов + 1 основной). Уже лучше. Но у нас остались изображения, цены и атрибуты — это коллекции (ToMany-связи), и с ними JOIN FETCH работает плохо: дублирует строки в результате, и пагинация ломается.

Шаг 2: Eager loading для коллекций через отдельные запросы

Для ToMany-связей я использую паттерн: загрузить основные сущности, получить их ID, и загрузить связанные коллекции отдельными batch-запросами:

public function findForCatalogWithRelations(int $page, int $limit): array
{
    // 1 запрос: товары + категории + бренды
    $products = $this->createQueryBuilder('p')
        ->addSelect('category', 'brand')
        ->leftJoin('p.category', 'category')
        ->leftJoin('p.brand', 'brand')
        ->where('p.isActive = true')
        ->orderBy('p.createdAt', 'DESC')
        ->setFirstResult(($page - 1) * $limit)
        ->setMaxResults($limit)
        ->getQuery()
        ->getResult();

    if (empty($products)) {
        return [];
    }

    $productIds = array_map(fn(Product $p) => $p->getId(), $products);

    // 1 запрос: все изображения для этих товаров
    $images = $this->_em->createQueryBuilder()
        ->select('pi', 'PARTIAL p.{id}')
        ->from(ProductImage::class, 'pi')
        ->join('pi.product', 'p')
        ->where('p.id IN (:ids)')
        ->setParameter('ids', $productIds)
        ->getQuery()
        ->getResult();

    // 1 запрос: цены
    $prices = $this->_em->createQueryBuilder()
        ->select('pr', 'PARTIAL p.{id}')
        ->from(ProductPrice::class, 'pr')
        ->join('pr.product', 'p')
        ->where('p.id IN (:ids) AND pr.currency = :currency')
        ->setParameter('ids', $productIds)
        ->setParameter('currency', 'RUB')
        ->getQuery()
        ->getResult();

    return $products;
}

// Doctrine автоматически заполнит коллекции в сущностях Product
// благодаря identity map. Когда Twig дернёт $product->getImages(),
// данные уже будут в Unit of Work.

Итого: 4 запроса вместо 340. Время загрузки страницы упало с 8 секунд до 200 миллисекунд. Да, именно так — 40× ускорение.

Шаг 3: Профилирование и мониторинг

Чтобы не допустить возврата к N+1, я добавил в CI-пайплайн проверку количества запросов. Используем Doctrine Data Collectors и простой тест:

// tests/Integration/QueryCountTest.php

class CatalogQueryCountTest extends WebTestCase
{
    public function testCatalogPageDoesNotExceedQueryLimit(): void
    {
        $client = static::createClient();
        $client->enableProfiler();

        $client->request('GET', '/catalog?page=1');
        $this->assertResponseIsSuccessful();

        $profile = $client->getProfile();
        $queryCount = $profile->getCollector('db')
            ->getQueryCount();

        // Страница каталога не должна делать больше 10 запросов
        $this->assertLessThan(10, $queryCount,
            "Слишком много запросов: $queryCount. Возможен N+1."
        );
    }
}

Этот тест бежит на каждом деплое. Если кто-то добавит новый {{ product.something.name }} в Twig без JOIN FETCH — тест упадёт.

Практические советы

Первое: всегда проверяйте Symfony Profiler на dev-окружении. Количество SQL-запросов — первая метрика, на которую нужно смотреть. Второе: для ToOne-связей используйте JOIN FETCH, для ToMany — загружайте отдельными запросами через IN. Третье: никогда не меняйте fetch = EAGER на уровне маппинга — это сломает другие места, где эта связь не нужна. Загружайте именно там, где нужно. Четвёртое: поставьте лимит на количество запросов в тестах — это дешёвый способ отловить регрессии.

Поделиться статьёй

Комментарии

0
0 / 2000

Пока нет комментариев

Будьте первым, кто оставит комментарий к этой статье

Читайте также

PHP 8.4: новые фичи, которые реально полезны в продакшене

Каждый релиз PHP приносит новые фичи, но не все из них попадают в реальный код. В PHP 8.4 есть несколько изменений, которые мы сразу внедрили в проект: property hooks упростили геттеры/сеттеры, new MyClass() без скобок, и улучшенный array_find. Рассказываю, что реально пригодилось, а что оказалось маркетингом.

13 мая 2026 8 мин

PHP: типизирование насквозь через array, список и null — опыт реального проекта

Строгая типизация в PHP 8.2+ отличная, но реальный код завален mixed и array. Как я вёл постепенную миграцию большого PHP-монолита на строгие типы, какие инструменты использовал для автоматического анализа, и почему array — это худший тип, который можно прописать в сигнатуре метода.

6 мая 2026 7 мин

Сложности с разделами каталога в Битрикс24 через REST API: игра с деревом

Обновляете товар через API, передаете SECTION_ID, но он не появляется в нужной папке. В этой статье я объясняю разницу между простым SECTION_ID и сложным деревом инфоблоков, как правильно выстроить иерархию и почему метод crm.product.update не умеет создавать папки.

5 июля 2026 1 мин