Zurück zum Blog
März 2026·3 Min. LesezeitKI-generiert
Sprache:DeutschEnglishFrançaisÖsterreichischKlingonisch Originalfassung: English

Das N+1-Query-Problem und wie man es vermeidet

#sql#databases#performance

Eine Seite, die 50 Blogposts samt Namen des jeweiligen Autors auflistet, fühlt sich in der Entwicklung mit ein paar Testzeilen sofort an und bricht in Produktion zusammen, sobald echte Daten auftauchen – nicht weil die Abfrage langsam ist, sondern weil es nicht eine Abfrage ist. Es sind 51: eine, um die Posts zu holen, und eine weitere pro Post, um dessen Autor zu laden. Das ist das N+1-Problem, und es ist einer der häufigsten Performance-Bugs in ORM-basierten Anwendungen, genau weil das ORM es unsichtbar macht, bis man gezielt danach sucht.

Wie es sich zeigt

const posts = await Post.findAll(); // 1 query

for (const post of posts) {
  post.author = await User.findByPk(post.authorId); // N queries, one per post
}

Nichts davon sieht falsch aus – es ist lesbar, es ist der naheliegende Weg, es zu schreiben, und mit 5 Test-Posts läuft es in Millisekunden. Mit 5.000 Posts in Produktion sind es 5.001 Roundtrips zur Datenbank, und die Roundtrip-Latenz (nicht die Ausführungszeit der Abfrage) dominiert. Selbst bei sehr schnellen 2 ms pro Roundtrip sind das 10 Sekunden, fast ausschließlich mit Warten aufs Netzwerk verbracht, für Daten, die eine einzige gut formulierte Abfrage in einem Roundtrip liefern könnte.

Es erkennen

Das Kennzeichen ist eine Abfrageanzahl, die mit der Größe einer Ergebnismenge skaliert. Die meisten ORMs und Frameworks haben eine Möglichkeit, jede pro Anfrage abgesetzte Abfrage zu protokollieren – schalte sie für eine verdächtige Seite ein und zähle. Löst das Laden einer Liste mit 10 Einträgen etwa 11 Abfragen aus und eine Liste mit 100 Einträgen etwa 101, ist das N+1.

// Sequelize example: log every query for the duration of one request
const posts = await Post.findAll({ logging: console.log });

Dass sich dieselbe Form SELECT * FROM users WHERE id = ? einmal pro Zeile wiederholt und nur der Parameter wechselt, ist das verräterische Zeichen.

Die Lösung: Eager Loading

Lade die zugehörigen Daten in derselben Abfrage oder in einer gebündelten Folgeabfrage, statt eine Abfrage pro Zeile abzusetzen.

Join-basiertes Eager Loading, mit dem eingebauten Beziehungs-Laden des ORMs:

// Sequelize
const posts = await Post.findAll({ include: User }); // 1 query, joined

// Prisma
const posts = await prisma.post.findMany({ include: { author: true } });

// Django ORM
posts = Post.objects.select_related('author')

Das setzt eine einzige Abfrage mit einem JOIN ab, sodass die Autorendaten im selben Ergebnis an jeder Post-Zeile hängen.

Gebündeltes Laden, wenn ein Join nicht gut passt (pro Zeile wird eine große verknüpfte Sammlung geholt, bei der ein Join die Zeilenzahlen vervielfachen würde):

// 2 queries total, not N+1
const posts = await Post.findAll();
const authorIds = [...new Set(posts.map((p) => p.authorId))];
const authors = await User.findAll({ where: { id: authorIds } });

const authorsById = new Map(authors.map((a) => [a.id, a]));
for (const post of posts) {
  post.author = authorsById.get(post.authorId);
}

Eine Abfrage für die Posts, eine Abfrage für alle von diesen Posts referenzierten Autoren (per Set dedupliziert), dann im Anwendungsspeicher zusammengeführt. Zwei Roundtrips, egal ob es 10 Posts sind oder 10.000.

In reinem SQL dieselbe Idee

Geht man gar nicht über ein ORM, ist das Äquivalent zum Eager Loading schlicht, den Join selbst zu schreiben, statt zu iterieren und abzufragen:

-- N+1: one query per post, issued from application code in a loop
SELECT * FROM posts;
SELECT * FROM users WHERE id = ?; -- repeated per post

-- one query
SELECT posts.*, users.name AS author_name
FROM posts
JOIN users ON users.id = posts.author_id;

Das N+1-Problem ist eigentlich kein ORM-Problem – ORMs machen das Muster „Schleife und Abfrage" nur leicht versehentlich schreibbar, weil post.author wie ein normaler Eigenschaftszugriff aussieht und die ausgelöste Abfrage verbirgt.

Der verschachtelte Fall: N+1 eine Ebene tiefer

Es potenziert sich, wenn es eine weitere Beziehung eine Ebene tiefer gibt – etwa Kommentare zu jedem Post:

const posts = await Post.findAll({ include: User }); // fixed the first N+1

for (const post of posts) {
  post.comments = await Comment.findAll({ where: { postId: post.id } }); // reintroduced it
}

Die Autoren-Abfrage zu reparieren hilft nicht, wenn Kommentare weiterhin in einer Schleife pro Post geholt werden. Verschachtelte Beziehungen lädt man genauso per Eager Loading:

const posts = await Post.findAll({ include: [User, Comment] });

Wenn alles per Eager Loading zu laden selbst das Problem ist

Auch der umgekehrte Fehler existiert: Jede Beziehung bei jeder Abfrage „sicherheitshalber" per Eager Loading zu laden, holt weit mehr Daten zurück, als die meisten Anfragen brauchen, und kann aus einem billigen Einzeltabellen-Lookup jedes Mal eine Abfrage mit Join über sechs Tabellen machen. Die Lösung ist nicht „immer eager laden" – sondern zu laden, was ein bestimmter Codepfad tatsächlich nutzt, was meist heißt, Includes pro Abfrage explizit zu benennen, statt einen pauschalen Standard zu setzen.

Fazit

N+1-Abfragen sind im Code-Review und in einem kleinen Entwicklungsdatensatz unsichtbar und werden erst zum echten Problem, wenn Daten im Produktionsmaßstab die Abfrageanzahl – nicht die Komplexität irgendeiner einzelnen Abfrage – zum Flaschenhals machen. Beobachte die tatsächlichen Abfrage-Logs gegen realistische Daten, nicht nur die Korrektheit der Abfrage gegen eine Handvoll Testzeilen, und greife zu Eager Loading (einem Join oder einer gebündelten Folgeabfrage), sobald eine Schleife pro Durchlauf eine Abfrage absetzt.

NeuerRate-Limiting-Algorithmen: Token Bucket vs. Sliding WindowÄlterGit Rebase vs. Merge: Wann man was verwendet