Jeder, der länger mit Doctrine ORM gearbeitet hat, ist über das N+1-Problem gestolpert — oft ohne es zu merken. Die Anwendung läuft lokal flüssig, aber in Produktion mit echten Datenmengen wird sie plötzlich langsam. Der Profiler zeigt 300 Datenbankabfragen für eine einzige Seite. Das ist das N+1-Problem.
Wie das N+1-Problem entsteht
Man betrachte eine einfache Blog-Anwendung: Beiträge mit zugehörigen Autoren. Die Entities:
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Post
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 255)]
private string $title;
#[ORM\ManyToOne(targetEntity: Author::class)]
private Author $author;
public function getId(): int { return $this->id; }
public function getTitle(): string { return $this->title; }
public function getAuthor(): Author { return $this->author; }
}
<?php
declare(strict_types=1);
namespace App\Entity;
use Doctrine\ORM\Mapping as ORM;
#[ORM\Entity]
class Author
{
#[ORM\Id]
#[ORM\GeneratedValue]
#[ORM\Column]
private int $id;
#[ORM\Column(length: 100)]
private string $name;
public function getId(): int { return $this->id; }
public function getName(): string { return $this->name; }
}
Im Controller lädt man alle Posts:
$posts = $this->postRepository->findAll();
Im Template iteriert man über die Posts und greift auf den Autor zu:
{% for post in posts %}
<p>{{ post.title }} von {{ post.author.name }}</p>
{% endfor %}
Das Problem: Doctrine lädt zunächst alle Posts mit einer einzelnen Abfrage (SELECT * FROM post). Wenn man dann post.author.name aufruft, lädt Doctrine den Autor per Lazy Loading — für jeden Post eine separate Abfrage. Bei 100 Posts entstehen 101 Abfragen: 1 für die Posts, 100 für die Autoren. Das ist das N+1-Problem.
Das Problem erkennen
Symfony Profiler
Im Entwicklungsmodus zeigt der Symfony-Profiler unter "Doctrine" alle Abfragen. Ein Warnsignal ist, wenn ähnliche Abfragen sich wiederholen — nur mit unterschiedlichen IDs:
SELECT * FROM author WHERE id = 1
SELECT * FROM author WHERE id = 2
SELECT * FROM author WHERE id = 3
-- ... 97 weitere
Doctrine-Extensions: Logging Middleware
Für automatisierte Tests nutzt man die Doctrine\DBAL\Logging\Middleware, um Abfragen mitzuzählen. Die alte SQLLogger-Schnittstelle ist seit DBAL 3.2 veraltet und in DBAL 4 entfernt:
<?php
declare(strict_types=1);
namespace App\Tests;
use Doctrine\DBAL\Driver\Middleware\AbstractDriverMiddleware;
use Doctrine\DBAL\Driver as DriverInterface;
use Doctrine\DBAL\Driver\Connection as ConnectionInterface;
use Doctrine\ORM\EntityManagerInterface;
use Symfony\Bundle\FrameworkBundle\Test\KernelTestCase;
class PostRepositoryTest extends KernelTestCase
{
public function testNoNPlusOneOnPostList(): void
{
$entityManager = static::getContainer()->get(EntityManagerInterface::class);
$connection = $entityManager->getConnection();
// Enable debug mode to collect queries via the DebugStack
$configuration = $connection->getConfiguration();
$queryCount = 0;
$logger = new class ($queryCount) extends \Psr\Log\AbstractLogger {
public function __construct(private int &$count) {}
public function log($level, string|\Stringable $message, array $context = []): void
{
$this->count++;
}
};
$loggingMiddleware = new \Doctrine\DBAL\Logging\Middleware($logger);
$configuration->setMiddlewares([$loggingMiddleware]);
$posts = static::getContainer()->get(PostRepository::class)->findAllWithAuthor();
$this->assertLessThanOrEqual(2, $queryCount, 'Mehr als 2 Abfragen — N+1-Problem!');
}
}
Lösung 1: JOIN FETCH im DQL
Die sauberste Lösung ist ein expliziter JOIN im Repository:
<?php
declare(strict_types=1);
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 findAllWithAuthor(): array
{
return $this->createQueryBuilder('p')
->addSelect('a')
->join('p.author', 'a')
->orderBy('p.id', 'DESC')
->getQuery()
->getResult();
}
}
Das addSelect('a') weist Doctrine an, den Autor gleich mit zu laden. Das erzeugt eine einzige Abfrage mit JOIN:
SELECT p.*, a.* FROM post p INNER JOIN author a ON p.author_id = a.id ORDER BY p.id DESC
Lösung 2: fetch="EAGER" in der Entity (nicht empfohlen)
Man kann auch auf der Entity-Ebene fetch: 'EAGER' setzen:
#[ORM\ManyToOne(targetEntity: Author::class, fetch: 'EAGER')]
private Author $author;
Das führt dazu, dass Doctrine die Relation sofort mitlädt — auch wenn man den Autor gar nicht braucht. Dabei verwendet Doctrine je nach Assoziationstyp entweder einen JOIN oder eine separate Abfrage. Bei ManyToOne und OneToOne wird in der Regel ein JOIN verwendet, bei OneToMany und ManyToMany werden separate Abfragen ausgeführt. Das kann andere Queries unnötig langsamer machen. Die explizite Lösung im Repository ist flexibler.
Lösung 3: EXTRA_LAZY für große Collections
Wenn eine Collection sehr viele Elemente hat und man nicht alle gleichzeitig laden möchte, nutzt man EXTRA_LAZY. Damit vermeidet man die vollständige Hydration der Collection bei Operationen wie count(), contains() oder slice():
#[ORM\OneToMany(targetEntity: Comment::class, mappedBy: 'post', fetch: 'EXTRA_LAZY')]
private Collection $comments;
EXTRA_LAZY verhindert, dass beim ersten Zugriff auf count(), contains() oder slice() die gesamte Collection in den Speicher geladen wird. Stattdessen führt Doctrine für diese Operationen optimierte SQL-Abfragen aus (z.B. SELECT COUNT(*)). Bei vollem Zugriff auf die Collection (z.B. Iteration) wird sie jedoch wie gewohnt vollständig geladen.
Lösung 4: Native Query für komplexe Fälle
Bei sehr komplexen Queries nutzt man eine Native Query mit ResultSetMapping:
public function findRecentPostsWithStats(): array
{
$rsm = new \Doctrine\ORM\Query\ResultSetMappingBuilder($this->getEntityManager());
$rsm->addRootEntityFromClassMetadata(Post::class, 'p');
$rsm->addJoinedEntityFromClassMetadata(Author::class, 'a', 'p', 'author');
$sql = '
SELECT p.*, a.*
FROM post p
INNER JOIN author a ON p.author_id = a.id
WHERE p.created_at >= DATE_SUB(NOW(), INTERVAL 30 DAY)
ORDER BY p.id DESC
LIMIT 20
';
return $this->getEntityManager()
->createNativeQuery($sql, $rsm)
->getResult();
}
N+1 bei One-to-Many Relationen
Das Problem tritt auch bei One-to-Many auf, ist aber komplizierter zu lösen. Man hat Autoren mit vielen Posts:
// N+1: Für jeden Autor wird die Post-Collection einzeln geladen
foreach ($authors as $author) {
echo $author->getName() . ': ' . $author->getPosts()->count() . ' Posts';
}
Lösung: Einen eigenen Query mit GROUP BY:
public function findAuthorsWithPostCount(): array
{
return $this->createQueryBuilder('a')
->addSelect('COUNT(p.id) as postCount')
->leftJoin('a.posts', 'p')
->groupBy('a.id')
->orderBy('postCount', 'DESC')
->getQuery()
->getResult();
}
Checkliste gegen N+1
- Repository-Methoden mit JOIN FETCH schreiben statt
findAll()zu verwenden - Im Symfony-Profiler regelmäßig Abfragen prüfen (
dev-Modus) - Automatisierte Tests mit Query-Counter für kritische Endpunkte
fetch: 'EAGER'auf Entity-Ebene vermeiden — zu global- Bei Pagination:
LIMITundOFFSETin den Query einbeziehen, um keine unnötigen Objekte zu laden
Das N+1-Problem lässt sich immer lösen. Die Kunst liegt darin, es frühzeitig zu erkennen — am besten bevor die Produktionsdatenbank 100.000 Datensätze hat.
Kommentare
Kommentare werden von Remark42 bereitgestellt. Beim Laden werden Daten an unseren Kommentar-Server übertragen.