<? phpukraine СПІВБЕСІДИ
Пошук по платформі
SYMFONY · MIDDLE

Як уникнути N+1 у Doctrine: fetch join, EXTRA_LAZY, batch?

Lazy-проксі та PersistentCollection довантажують дані окремим запитом на кожну сутність; лікується fetch join (`addSelect` приєднаної асоціації), а для пагінації — Doctrine Paginator, який ріже сутності, а не рядки.

Чому сторінка з 20 постами робить 21 запит, хоча ми нічого не додавали?
Чим `join` у DQL відрізняється від fetch join?
Чому після `setMaxResults(20)` із JOIN на колекцію повертається 7 записів?
Що дає `fetch: EXTRA_LAZY` і чи рятує воно від N+1?
Doctrine N+1 fetch join Paginator EXTRA_LAZY

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();
Що N+1 у Doctrine породжує ліниве завантаження: to-one асоціація підмінюється проксі, to-many — PersistentCollection, і перший же геттер робить SELECT.
Що звичайний `leftJoin('p.author', 'a')` не рятує: без `addSelect('a')` асоціація не гідрується, JOIN лише фільтрує й сортує.
Що fetch join колекції ламає `setFirstResult`/`setMaxResults`, бо LIMIT застосовується до рядків після JOIN, і саме тому існує `Doctrine\ORM\Tools\Pagination\Paginator`.
Що EXTRA_LAZY це не ліки від N+1, а здешевлення одного звернення: `count()`, `slice()`, `contains()` перестають завантажувати всю колекцію.
Що для масової обробки правильна відповідь — `toIterable()` з `flush()`/`clear()` батчами, і що з fetch join колекцій `toIterable()` не працює.
Писати `->join('p.author', 'a')` без `->addSelect('a')` і дивуватись, що кількість запитів не змінилась.
Ставити `fetch: 'EAGER'` в мапінгу як глобальний фікс: асоціація тягнеться при кожному завантаженні сутності, зокрема там, де вона не потрібна.
Вважати EXTRA_LAZY розвʼязанням N+1: запитів залишається стільки ж, вони просто легші.
Пагінувати fetch join на колекцію через `setMaxResults` без `Paginator` і отримувати неповну сторінку через дублікати рядків.
Лікувати N+1 індексом на зовнішньому ключі або кешем на рівні HTTP: кількість round-trip до бази від цього не змінюється.
Робити три-чотири fetch join колекцій в одному запиті й отримувати декартів добуток рядків замість пришвидшення.
ПОРАДА

Назвіть діагностику до рецепта: панель Doctrine у Symfony Profiler групує однакові запити й показує «executed N times» — саме там N+1 видно за секунду. І окремо згадайте, що fetch join на дві колекції одночасно множить рядки, тому другу колекцію краще догрузити другим запитом.

оновлено 4 вересня 2026 · ліцензія CC-BY-SA-4.0 Знайшли неточність? Напишіть →
ПЕРЕВІРТЕ СЕБЕ

Fetch join — це JOIN плюс приєднаний аліас у SELECT. Без addSelect у результат потрапляють лише поля головної сутності, тому проксі залишається неініціалізованим. Індекс пришвидшує кожен із N запитів, але не зменшує їх кількість, а EXTRA_LAZY взагалі стосується лише колекцій.