N+1 у Doctrine — прямий наслідок лінивого завантаження за замовчуванням. Коли гідратор будує сутність Post, для to-one асоціації він підставляє проксі-обʼєкт із заповненим лише ідентифікатором, а для to-many — PersistentCollection у неініціалізованому стані. Обидва виглядають як звичайні обʼєкти, аж поки хтось не викличе геттер: тоді проксі йде в базу за своїм рядком, а колекція — за всім своїм вмістом. Один SELECT на список плюс по одному на кожен елемент у циклі шаблону і дає ті самі «21 запит на 20 постів». Помітно це не в коді репозиторію, а в панелі Doctrine у Symfony Profiler, яка групує ідентичні запити й підписує, скільки разів кожен виконався.
Базовий інструмент — fetch join. У DQL він відрізняється від звичайного JOIN лише тим, що приєднаний аліас потрапляє в SELECT: ->select('p', 'a')->leftJoin('p.author', 'a') або еквівалентний ->addSelect('a'). Тоді гідратор бачить колонки автора, створює повну сутність, кладе її в identity map і привʼязує до поста — жодних додаткових запитів. Саме тут ламаються найчастіше: JOIN без addSelect дає ті самі N+1, бо він служить лише для WHERE й ORDER BY. leftJoin замість innerJoin варто брати свідомо: inner join мовчки викине з видачі всі пости без автора.
З колекціями fetch join має свою ціну — множення рядків. JOIN на p.tags перетворює один пост на стільки рядків, скільки в нього тегів, і setMaxResults(20) після цього обмежує рядки, а не сутності: сторінка отримає 7 постів замість 20. Для цього й існує Doctrine\ORM\Tools\Pagination\Paginator з fetchJoinCollection: true — він спершу вибирає DISTINCT ідентифікатори з LIMIT, а потім виконує основний запит із WHERE id IN (...) уже без обмеження. Із тієї ж причини два fetch join на дві різні колекції в одному запиті дають декартів добуток: другу колекцію дешевше догрузити окремим запитом.
fetch: 'EXTRA_LAZY' в мапінгу розвʼязує іншу задачу і його регулярно плутають із лікуванням N+1. Він змушує PersistentCollection виконувати count(), contains(), containsKey() і slice() цільовим SQL, не ініціалізуючи колекцію: замість гідрації 5000 коментарів — один SELECT COUNT(*). Кількість запитів при цьому не змінюється, змінюється їхня вартість і памʼять. Протилежність, fetch: 'EAGER', теж рідко буває доброю ідеєю як глобальне налаштування: асоціація тягнеться при кожному завантаженні сутності, зокрема в тих сценаріях, де вона не потрібна взагалі, тож рішення про eager краще ухвалювати на рівні конкретного запиту.
Для фонової обробки картина інша: там проблема не в кількості запитів, а в памʼяті, і відповіддю є Query::toIterable() (замінив задепрекейчений iterate()) із flush() і clear() кожні кількасот записів. Обмеження варто назвати самому: toIterable() не працює з fetch join колекцій, бо не може зібрати сутність із кількох рядків. Якщо ж дані потрібні лише для читання, найдешевший шлях — узагалі не гідрувати сутності: DQL-оператор NEW віддає готові DTO, getArrayResult() — масиви, і в обох випадках Unit of Work не тримає копій оригінальних даних. Партіальні обʼєкти для цього більше не варіант — вони задепрекейчені в ORM 2.x і прибрані в 3.0.
// Погано: 1 запит на список + по одному на автора кожного поста
foreach ($repo->findBy(['status' => 'published']) as $post) {
echo $post->getAuthor()->getName(); // ініціалізація проксі => SELECT ... WHERE id = ?
}
// Добре: fetch join. Ключове тут addSelect — без нього JOIN лише фільтрує
$qb = $em->createQueryBuilder()
->select('p', 'a') // 'a' у SELECT: автор гідрується разом із постом
->from(Post::class, 'p')
->leftJoin('p.author', 'a') // leftJoin, щоб пости без автора не зникли
->where('p.status = :status')
->setParameter('status', 'published');
// Пагінація з fetch join колекції: LIMIT ріже РЯДКИ, а один пост дає рядок на кожен тег
$qb->leftJoin('p.tags', 't')->addSelect('t')
->setFirstResult(0)
->setMaxResults(20);
// Paginator робить SELECT DISTINCT p.id ... LIMIT 20, потім основний запит з WHERE p.id IN (...)
$paginator = new Paginator($qb->getQuery(), fetchJoinCollection: true);
foreach ($paginator as $post) { // рівно 20 сутностей, теги вже в памʼяті
echo $post->getTitle(), count($post->getTags());
}
// EXTRA_LAZY: count() не завантажує 5000 коментарів, але це все одно запит на кожен пост
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')]
private Collection $comments; // $post->getComments()->count() => SELECT COUNT(*)
// Масова обробка: toIterable() не тримає весь результат, clear() чистить identity map
$query = $em->createQuery('SELECT p FROM App\Entity\Post p'); // без fetch join колекцій!
foreach ($query->toIterable() as $i => $post) {
$post->recalculateStats();
if (($i + 1) % 500 === 0) {
$em->flush();
$em->clear();
}
}
$em->flush();
Назвіть діагностику до рецепта: панель Doctrine у Symfony Profiler групує однакові запити й показує «executed N times» — саме там N+1 видно за секунду. І окремо згадайте, що fetch join на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.