Zum Inhalt
wannacode

ORM und das N+1-Problem

Kurz gesagt: Ein ORM bildet Tabellenzeilen auf Objekte ab und schreibt das SQL für dich, und das N+1-Problem entsteht, wenn er für jede Zeile eine zusätzliche Abfrage schickt statt einer einzigen.

Problem

Die langsame Bestellseite

Die Seite „Bestellungen“ im Admin-Bereich zeigt die Bestellnummer, das Datum und den Namen des Kunden. Der Shop hat 60 Bestellungen, und die Seite öffnet sich langsam. Das Log der Datenbank zeigt 61 Abfragen für eine Seitenansicht. Woher kommen sie, wenn im Code keine Schleife mit SQL steht?

Die Idee

ORM: eine Klasse statt der Tabelle

Ein ORM (Object-Relational Mapping) ist eine Bibliothek, die eine Klasse im Programm mit einer Tabelle in der Datenbank verbindet. Du beschreibst die Klasse Order und welche Tabelle sie ist, und der ORM schreibt das SQL für dich, wenn du die Bestellungen anforderst:

@Entity
@Table(name = "orders")
class Order {
@Id
private Long id;
@ManyToOne(fetch = FetchType.LAZY)
@JoinColumn(name = "customer_id")
private Customer customer;
@Column(name = "ordered_at")
private LocalDate orderedAt;
}
List<Order> orders = em.createQuery("SELECT o FROM Order o ORDER BY o.id", Order.class)
.getResultList();

Das sind Hibernate/JPA in Java, Entity Framework Core in C#, SQLAlchemy in Python und Prisma in JavaScript. Du gewinnst weniger wiederholten Code, Objekte statt Zeilen und dasselbe Programm für verschiedene Datenbanken. Du bezahlst mit SQL, das du nicht mehr siehst: Der ORM entscheidet, wie viele Abfragen er schickt.

Genauer, für Neugierige

Die ORM-Beispiele werden in den Prüfungen der Seite nicht ausgeführt, weil ihre Pakete dort nicht vorhanden sind. Ein echtes Programm braucht für sie außerdem etwas Konfiguration: die Adresse der Datenbank, die Klasse Customer und den Kontext (EntityManager, DbContext, Session, PrismaClient).

Sieh es dir an

N+1

Hier ist dasselbe ohne ORM, aber mit demselben Fehler: erst die Bestellungen, dann der Kunde jeder Bestellung. Das Programm zählt die Abfragen, die es schickt:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
int queries = 0;
List<int[]> orders = new ArrayList<>();
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery("SELECT id, customer_id FROM orders ORDER BY id")) {
queries++;
while (rs.next()) {
orders.add(new int[] { rs.getInt("id"), rs.getInt("customer_id") });
}
}
for (int[] order : orders) {
try (PreparedStatement ps = conn.prepareStatement("SELECT name FROM customers WHERE id = ?")) {
ps.setInt(1, order[1]);
queries++;
try (ResultSet rs = ps.executeQuery()) {
rs.next();
System.out.println("Bestellung " + order[0] + ": " + rs.getString("name"));
}
}
}
System.out.println("Abfragen: " + queries);
}
Ausgabe
Bestellung 1: Radost Hristova
Bestellung 2: Georgi Petrov
Bestellung 3: Maria Ivanova
Bestellung 4: Ivana Koleva
Bestellung 5: Radost Hristova
Bestellung 6: Maria Ivanova
Abfragen: 7

Sechs Bestellungen, sieben Abfragen: eine für die Bestellungen (1) und eine für den Kunden jeder Bestellung (N). Bei 60 Bestellungen sind es 61. Mit einem ORM passiert dasselbe, nur ist die Schleife unsichtbar: o.getCustomer().getName() sieht aus wie das Lesen eines Feldes, ist aber in Wirklichkeit eine Abfrage.

for (Order o : orders) {
System.out.println(o.getId() + ": " + o.getCustomer().getName()); // eine Abfrage für jeden Kunden
}
// Die Lösung: Die Kunden kommen in derselben Abfrage mit.
em.createQuery("SELECT o FROM Order o JOIN FETCH o.customer ORDER BY o.id", Order.class)

Die Idee

Eine Abfrage

Die Datenbank kann die Bestellungen und die Kunden selbst verbinden und alles auf einmal zurückgeben:

try (Connection conn = DriverManager.getConnection("jdbc:sqlite::memory:")) {
setup(conn);
String sql = """
SELECT o.id, c.name
FROM orders o
JOIN customers c ON c.id = o.customer_id
ORDER BY o.id
""";
try (Statement st = conn.createStatement();
ResultSet rs = st.executeQuery(sql)) {
while (rs.next()) {
System.out.println("Bestellung " + rs.getInt("id") + ": " + rs.getString("name"));
}
}
System.out.println("Abfragen: 1");
}
Ausgabe
Bestellung 1: Radost Hristova
Bestellung 2: Georgi Petrov
Bestellung 3: Maria Ivanova
Bestellung 4: Ivana Koleva
Bestellung 5: Radost Hristova
Bestellung 6: Maria Ivanova
Abfragen: 1

Dieselben Zeilen mit einer Abfrage. Für die Seite des Shops reicht das:

SQLDatenbank: Shop

Der Unterschied liegt nicht im SQL jeder Abfrage, sondern im Weg zur Datenbank. Jede Abfrage ist ein Hin und Zurück übers Netzwerk, in einem Rechenzentrum meist unter einer Millisekunde und, wenn die Datenbank weit weg ist, einige zehn Millisekunden. 61 davon merkt man, 6001 bringen die Seite zum Stillstand.

Probier es aus

Die neuesten Bestellungen

Das Dashboard zeigt die neuesten 5 Bestellungen: Nummer, Datum, Name und E-Mail-Adresse des Kunden, die neueste zuerst (bei gleichem Datum die höhere Nummer oben). Mit einer Abfrage.

SQLDatenbank: Shop

Übung

Wie viele Bestellungen jeder Kunde hat

Die Seite „Kunden“ zeigt jeden Kunden mit der Zahl seiner Bestellungen (Spalte orders), in alphabetischer Reihenfolge. Ohne N+1, also ohne eine Abfrage pro Kunde, und ohne die Kunden auszulassen, die keine Bestellungen haben.

SQLDatenbank: Shop

Test

Teste dich

1. Eine Seite zeigt 50 Bestellungen mit dem Namen des Kunden. Das Programm liest die Bestellungen mit einer Abfrage und danach den Kunden jeder Bestellung mit einer eigenen. Wie viele Abfragen sind das?
2. Warum fällt N+1 beim Schreiben des Programms nicht auf?
3. Was behebt N+1 in einem ORM?
4. Ein ORM schreibt das SQL für dich. Musst du trotzdem SQL können?

Erstellt von Ivo Gergov im Rahmen der Kampagne von Evo Tech für zugängliche Programmierausbildung ·