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 на уровне маппинга — это сломает другие места, где эта связь не нужна. Загружайте именно там, где нужно. Четвёртое: поставьте лимит на количество запросов в тестах — это дешёвый способ отловить регрессии.
Пока нет комментариев
Будьте первым, кто оставит комментарий к этой статье