SymfonySeniorTechnical
Что такое проблема N+1 в Doctrine и как её решать?
N+1 — это 1 запрос на список + N запросов для lazy-загрузки связей каждого элемента. Решается через JOIN FETCH в DQL/QueryBuilder, fetch: EAGER для постоянно нужных связей, или нативным SQL через DBAL.
Что такое проблема N+1
Проблема N+1 возникает, когда для вывода коллекции из N объектов ORM выполняет 1 запрос на получение списка плюс N отдельных запросов для загрузки связанных данных каждого объекта. При N=1000 это 1001 SQL-запрос вместо 1–2.
В Doctrine это происходит из-за lazy loading — стратегии по умолчанию для ассоциаций: связанная коллекция загружается только при первом обращении к ней.
Демонстрация проблемы
<?php
// Плохо: N+1 запросов
$posts = $em->getRepository(Post::class)->findAll(); // 1 SELECT
foreach ($posts as $post) {
// Каждое обращение к $post->getAuthor() — отдельный SELECT!
echo $post->getAuthor()->getName();
}
// Итого: 1 + N запросов
Решение 1: JOIN FETCH через DQL
<?php
// src/Repository/PostRepository.php
namespace App\Repository;
use App\Entity\Post;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
class PostRepository extends ServiceEntityRepository
{
public function __construct(ManagerRegistry $registry)
{
parent::__construct($registry, Post::class);
}
/** @return Post[] */
public function findAllWithAuthors(): array
{
return $this->createQueryBuilder('p')
->addSelect('a') // JOIN FETCH
->join('p.author', 'a')
->getQuery()
->getResult();
// Итого: 1 SELECT с JOIN вместо 1+N
}
/** @return Post[] */
public function findAllWithTagsAndAuthor(): array
{
return $this->createQueryBuilder('p')
->addSelect('a', 't')
->join('p.author', 'a')
->leftJoin('p.tags', 't')
->getQuery()
->getResult();
}
}
Решение 2: Fetch Join через аннотацию/атрибут
<?php
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Post
{
// Всегда загружать автора вместе с Post (не lazy)
#[ORM\ManyToOne(fetch: 'EAGER')]
private ?User $author = null;
}
Осторожно: fetch: 'EAGER' применяется глобально — даже когда автор не нужен.
Решение 3: Batch loading через IN-запрос
<?php
// Для коллекций (OneToMany/ManyToMany) DQL JOIN вызывает дубликаты строк.
// Лучше использовать отдельный IN-запрос:
public function findPostsWithCommentCounts(array $postIds): array
{
return $this->createQueryBuilder('p')
->addSelect('COUNT(c.id) AS commentCount')
->leftJoin('p.comments', 'c')
->where('p.id IN (:ids)')
->setParameter('ids', $postIds)
->groupBy('p.id')
->getQuery()
->getResult();
}
Обнаружение через Symfony Profiler
# Включите профилировщик в dev-среде (включён по умолчанию)
# Откройте /_profiler/latest
# Вкладка "Doctrine" показывает все SQL-запросы с трассировкой
# Или через логи:
# config/packages/dev/doctrine.yaml
# doctrine:
# dbal:
# logging: true
Решение для API: нативный SQL / DBAL
<?php
use Doctrine\DBAL\Connection;
class PostStatsRepository
{
public function __construct(private readonly Connection $connection) {}
public function getPostsWithStats(): array
{
$sql = <<<SQL
SELECT p.id, p.title, u.name AS author_name,
COUNT(c.id) AS comment_count
FROM posts p
JOIN users u ON u.id = p.author_id
LEFT JOIN comments c ON c.post_id = p.id
GROUP BY p.id, u.name
ORDER BY p.created_at DESC
SQL;
return $this->connection->fetchAllAssociative($sql);
}
}
Подводные камни
- JOIN с коллекциями и дубликаты — JOIN FETCH для OneToMany умножает строки; используйте
DISTINCTв DQL или->distinct(true)в QueryBuilder, либо делайте два отдельных запроса. - Пагинация с JOIN —
setMaxResults()+ JOIN вернёт не N постов, а N строк результата; Doctrine предупреждает об этом, но не блокирует; используйте Paginator из Doctrine ORM с$fetchJoinCollection = true. - fetch: EAGER глобально — применяется при каждой загрузке сущности, даже когда связь не нужна; предпочитайте явный JOIN в Repository.
- Extra Lazy для больших коллекций —
fetch: 'EXTRA_LAZY'позволяет делатьcount()иcontains()без загрузки всей коллекции, но не решает N+1 при итерации. - Symfony Profiler в prod — не забудьте убедиться, что логирование SQL отключено в prod (
logging: false), иначе память и диск будут заполняться быстро. - Кеширование запросов — Doctrine Query Cache кеширует разбор DQL, но не результаты; для кеширования результатов нужен Result Cache (
->enableResultCache(ttl: 60)). - N+1 в сериализаторе — Symfony Serializer с группами сериализации также может вызвать lazy-загрузку при обходе графа объектов; проверяйте через Profiler перед деплоем.
Common mistakes
- Сводить doctrine n plus one к названию метода без lifecycle и failure path.
- Игнорировать модель runtime: Symfony 7/8 строит request lifecycle вокруг HttpKernel, events, routing, controller resolver и response listeners.
- Не отделять validation, authorization, transaction boundary и business logic.
What the interviewer is testing
- Объясняет doctrine n plus one через конкретную точку lifecycle в Symfony.
- Приводит корректный минимальный пример без вымышленных методов или callbacks.
- Называет edge cases: пустые значения, ошибки, транзакции, безопасность или concurrency.