Der Nested-Loops-Join ist der einfachste Join-Algorithmus. Er verwendet zwei verschachtelte Abfragen: eine äußere oder treibende Abfrage und eine innere Abfrage, die für jede Zeile der äußeren ausgeführt wird, um die zugehörigen Daten aus der anderen Tabelle zu laden.
Den Nested-Loop-Algorithmus kann man auch selbst umsetzen, indem man „verschachtelte Abfragen“ verwendet. Das ist allerdings ein problematischer Ansatz, da dabei zusätzlich zu den Festplatten-Latenzen noch Netzwerk-Latenzen anfallen. Dennoch sind verschachtelte Abfragen weit verbreitet – vermutlich, weil man sie ohne es zu wissen implementieren kann. Objekt-relationale Mapper (ORM) sind dabei besonders „hilfreich“ – dort hat das sogenannte „N+1 selects Problem“ eine traurige Berühmtheit erlangt.
Die folgenden Beispiele zeigen verschachtelte Abfragen, die mit verschiedenen ORM-Werkzeugen „versehentlich“ umgesetzt wurden. Die Beispiele suchen Mitarbeiter, deren Nachnamen mit 'WIN'
beginnen, und laden alle Verkäufe dieser Mitarbeiter.
- Java
Mit dem
CriteriaBuilder
-Interface von JPA.CriteriaBuilder queryBuilder = em.getCriteriaBuilder(); CriteriaQuery<Employees> query = queryBuilder.createQuery(Employees.class); Root<Employees> r = query.from(Employees.class); query.where( queryBuilder.like( queryBuilder.upper(r.get(Employees_.lastName)), "WIN%" ) ); List<Employees> emp = em.createQuery(query).getResultList(); for (Employees e: emp) { // process Employee for (Sales s: e.getSales()) { // process sale for Employee } }
Hibernate 3.6.0 generiert dafür N+1
select
-Abfragen:select employees0_.subsidiary_id as subsidiary1_0_ -- MORE COLUMNS from employees employees0_ where upper(employees0_.last_name) like ?
select sales0_.subsidiary_id as subsidiary4_0_1_ -- MORE COLUMNS from sales sales0_ where sales0_.subsidiary_id=? and sales0_.employee_id=?
select sales0_.subsidiary_id as subsidiary4_0_1_ -- MORE COLUMNS from sales sales0_ where sales0_.subsidiary_id=? and sales0_.employee_id=?
- Perl
Mit dem DBIx::Class-Framework für Perl:
my @employees = $schema->resultset('Employees') ->search({'UPPER(last_name)' => {-like=>'WIN%'}}); foreach my $employee (@employees) { # process Employee foreach my $sale ($employee->sales) { # process Sale for Employee } }
DBIx::Class 0.08192 generiert dafür N+1
select
-Abfragen:SELECT me.employee_id, me.subsidiary_id , me.last_name, me.first_name, me.date_of_birth FROM employees me WHERE ( UPPER(last_name) LIKE ? )
SELECT me.sale_id, me.employee_id, me.subsidiary_id , me.sale_date, me.eur_value FROM sales me WHERE ( ( me.employee_id = ? AND me.subsidiary_id = ? ) )
SELECT me.sale_id, me.employee_id, me.subsidiary_id , me.sale_date, me.eur_value FROM sales me WHERE ( ( me.employee_id = ? AND me.subsidiary_id = ? ) )
- PHP
Mit dem
QueryBuilder
-Interface des Doctrine-Frameworks:$qb = $em->createQueryBuilder(); $qb->select('e') ->from('Employees', 'e') ->where("upper(e.last_name) like :last_name") ->setParameter('last_name', 'WIN%'); $r = $qb->getQuery()->getResult(); foreach ($r as $row) { // process Employee foreach ($row->getSales() as $sale) { // process Sale for Employee } }
Doctrine 2.0.5 generiert dafür N+1
select
-Abfragen:SELECT e0_.employee_id AS employee_id0 -- MORE COLUMNS FROM employees e0_ WHERE UPPER(e0_.last_name) LIKE ?
SELECT t0.sale_id AS SALE_ID1 -- MORE COLUMNS FROM sales t0 WHERE t0.subsidiary_id = ? AND t0.employee_id = ?
SELECT t0.sale_id AS SALE_ID1 -- MORE COLUMNS FROM sales t0 WHERE t0.subsidiary_id = ? AND t0.employee_id = ?
Die ORM-Werkzeuge generieren keine SQL-Joins – sie fragen die SALES
-Tabelle gesondert ab. Dieser Effekt ist als „N+1 selects Problem“ oder kurz „N+1 Problem“ bekannt geworden, weil die Anzahl der select
-Abfragen insgesamt N+1 ist, wenn die treibende Abfrage N Zeilen liefert.
Tipp: SQL-Logging aktivieren
Aktiviere das SQL-Logging während der Entwicklung und überprüfe die generierten SQL-Anweisungen.
- DBIx::Class
export DBIC_TRACE=1
in der Shell.- Doctrine
Nur programmatisch – vergiss nicht, es für die Produktion zu deaktivieren!
$logger = new \Doctrine\DBAL\Logging\EchoSqlLogger; $config->setSQLLogger($logger);
- Hibernate (native)
<property name="show_sql">true</property>
inApp.config
oderhibernate.cfg.xml
.- JPA
In
persistence.xml
, aber abhängig vom verwendeten Tool – z. B. für eclipselink, Hibernate und OpenJPA:<property name="eclipselink.logging.level" value="FINE"/> <property name="hibernate.show_sql" value="TRUE"/> <property name="openjpa.Log" value="SQL=TRACE"/>
Die meisten ORM-Werkzeuge bieten auch einen programmatischen Weg an, das SQL-Logging zu aktivieren. Das erhöht aber das Risiko, das Logging versehentlich auch in Produktion zu aktivieren.
Verschachtelte Abfragen sollte man zwar vermeiden, sie beschreiben den Nested-Loops-Algorithmus aber sehr gut. Die Datenbank führt einen Nested-Loops-Join genauso durch, wie es die ORM-Werkzeuge oben machen. Für die Indizierung eines Nested-Loops-Join bedeutet das, dass man Indizes für die oberen Abfragen benötigt. Das ist also ein Funktions-basierter Index auf der Tabelle EMPLOYEES
und ein zweispaltiger Index für die Join-Prädikate auf der SALES
-Tabelle.
CREATE INDEX emp_up_name ON employees (UPPER(last_name))
CREATE INDEX sales_emp ON sales (subsidiary_id, employee_id)
Ein SQL-Join ist dennoch effizienter als verschachtelte Abfragen – obwohl er dieselben Indexzugriffe durchführt. Das kommt daher, dass der Großteil der Netzwerk-Kommunikation vermieden wird. Er ist sogar dann schneller, wenn insgesamt mehr Daten übertragen werden, weil die Spalten der Tabelle EMPLOYEES
für jeden Verkauf dupliziert werden. Das liegt an den zwei Dimensionen der Performance: Antwortzeit und Durchsatz. Bei Netzwerken spricht man von Latenz und Bandbreite. Die Bandbreite beeinflusst die Antwortzeit kaum, die Latenzen aber sehr. Das bedeutet, dass die Anzahl der Datenbankabfragen mehr Einfluss auf die Antwortzeit hat als die transferierte Datenmenge.
Tipp
Führe Join-Operationen in der Datenbank aus.
ORM-Werkzeuge bieten verschiedene Methoden an, SQL-Joins zu generieren. Das sogenannte „gierige Laden“ (eager fetching) ist wohl die Wichtigste. Es wird im Entity-Mapping auf Attribut-Ebene aktiviert – also zum Beispiel für das Attribut EMPLOYEES
in der SALES
-Klasse. Dadurch wird bei jedem Zugriff auf die SALES
-Tabelle ein SQL-Join mit der Tabelle EMPLOYEES
durchgeführt. Die Konfiguration im Entity-Mapping ist also nur sinnvoll, wenn man die Mitarbeiterdaten immer benötigt, wenn man auf Verkäufe zugreift.
Hinweis in eigener Sache
Ich lebe von SQL-Schulungen, SQL-Tuning und Beratung sowie dem Verkauf meines Buches „SQL Performance Explained“. Mehr auf winand.at.
Gieriges Laden ist aber kontraproduktiv, wenn das entsprechende Attribut nur selten benötigt wird. Bei einer Telefonbuch-Anwendung wäre es zum Beispiel nicht sinnvoll, sämtliche SALES
-Einträge zu den gefundenen Mitarbeitern automatisch mit zu laden. Wenn man einen Mitarbeiter lädt, kann es in manchen Fällen sein, dass man die zugehörigen SALES
-Daten braucht, in anderen aber nicht. Eine statische Konfiguration im Entity-Mapping ist daher keine Lösung.
Um die beste Performance zu erreichen braucht man die volle Kontrolle über Joins. Die folgenden Beispiele zeigen daher, wie man das Join-Verhalten zur Laufzeit steuern kann, um die höchste Flexibilität zu erhalten.
- Java
Beim
CriteriaBuilder
-Interface von JPA steuert man Joins mit der MethodeRoot<>.fetch()
. Damit kann man angeben, ob und wie die referenzierten Objekte durch einen SQL-Join mitgeladen werden sollen. Für unser Beispiel benötigen wir einen Left-Join für dieSALES
-Tabelle, damit auch Mitarbeiter ohne Verkäufe im Telefonbuch aufscheinen.Warnung
JPA und Hibernate liefern die Mitarbeiter für jeden Verkauf.
Das bedeutet, dass ein Mitarbeiter mit 30 Verkäufen 30-mal geliefert wird. Das ist zwar störend, aber das spezifizierte Verhalten (EJB 3.0 persistency, Absatz 4.4.5.3 „Fetch Joins“). Man kann die Daten entweder manuell de-duplizieren oder ein
LinkedHashSet
dafür verwenden oder die Funktiondistinct()
, wie im Beispiel, verwenden.CriteriaBuilder qb = em.getCriteriaBuilder(); CriteriaQuery<Employees> q = qb.createQuery(Employees.class); Root<Employees> r = q.from(Employees.class); q.where(queryBuilder.like( queryBuilder.upper(r.get(Employees_.lastName)), "WIN%") ); r.fetch("sales", JoinType.LEFT); // needed to avoid duplication of Employee records q.distinct(true); List<Employees> emp = em.createQuery(q).getResultList();
Hibernate 3.6.0 generiert die folgende SQL-Abfrage:
select distinct employees0_.subsidiary_id as subsidiary1_0_0_ , employees0_.employee_id as employee2_0_0_ -- MORE COLUMNS , sales1_.sale_id as sale1_0__ from employees employees0_ left outer join sales sales1_ on employees0_.subsidiary_id=sales1_.subsidiary_id and employees0_.employee_id=sales1_.employee_id where upper(employees0_.last_name) like ?
Die Abfrage hat den gewünschten Left-Join, aber auch das
distinct
-Schlüsselwort. JPA bietet leider keine Möglichkeit, die doppelten Haupt-Einträge zu verwerfen, ohne auch die referenzierten Objekte zu de-duplizieren. Die Verwendung desdistinct
-Schlüsselwortes in der Abfrage ist bedenklich, weil die meisten Datenbanken tatsächlich eine Filterung durchführen. Nur wenige Datenbanken erkennen aufgrund des Primärschlüssels, dass das Ergebnis ohnehin keine Duplikate enthalten kann.Die native Hibernate-API löst das Problem mit einem Ergebnis-Transformator (result set transformer).
Criteria c = session.createCriteria(Employees.class); c.add(Restrictions.ilike("lastName", 'Win%')); c.setFetchMode("sales", FetchMode.JOIN); c.setResultTransformer(Criteria.DISTINCT_ROOT_ENTITY); List<Employees> result = c.list();
Damit wird die folgende Abfrage generiert:
select this_.subsidiary_id as subsidiary1_0_1_ , this_.employee_id as employee2_0_1_ -- MORE this_ columns on employees , sales2_.sale_id as sale1_3_ -- MORE sales2_ columns on sales from employees this_ left outer join sales sales2_ on this_.subsidiary_id=sales2_.subsidiary_id and this_.employee_id=sales2_.employee_id where lower(this_.last_name) like ?
Die SQL-Abfrage hat keine unnötigen Klauseln. Man sollte jedoch beachten, dass Hibernate die Funktion
LOWER
verwendet, um die Groß- und Kleinschreibung zu ignorieren – ein wichtiges Detail bei der Funktions-basierten Indizierung.- Perl
Mit dem DBIx::Class-Framework für Perl:
my @employees = $schema->resultset('Employees') ->search({ 'UPPER(last_name)' => {-like => 'WIN%'} , {prefetch => ['sales']} });
DBIx::Class 0.08192 generiert die folgende Abfrage:
SELECT me.employee_id, me.subsidiary_id, me.last_name -- MORE COLUMNS FROM employees me LEFT JOIN sales sales ON (sales.employee_id = me.employee_id AND sales.subsidiary_id = me.subsidiary_id) WHERE ( UPPER(last_name) LIKE ? ) ORDER BY sales.employee_id, sales.subsidiary_id
Beachte die
order by
-Klausel – sie wurde von der Applikation nicht gefordert. Die Datenbank muss das Ergebnis dementsprechend sortieren – das dauert natürlich.- PHP
Mit dem Doctrine-Framework für PHP:
$qb = $em->createQueryBuilder(); $qb->select('e,s') ->from('Employees', 'e') ->leftJoin('e.sales', 's') ->where("upper(e.last_name) like :last_name") ->setParameter('last_name', 'WIN%'); $r = $qb->getQuery()->getResult();
Doctrine 2.0.5 generiert dafür die folgende Abfrage:
SELECT e0_.employee_id AS employee_id0 -- MORE COLUMNS FROM employees e0_ LEFT JOIN sales s1_ ON e0_.subsidiary_id = s1_.subsidiary_id AND e0_.employee_id = s1_.employee_id WHERE UPPER(e0_.last_name) LIKE ?
Im Ausführungsplan erscheint die Operation NESTED LOOPS
mit dem Zusatz OUTER
:
- DB2
Explain Plan --------------------------------------------------------------- ID | Operation | Rows | Cost 1 | RETURN | | 10501 2 | NLJOIN (LEFT) | 5745 of 57 | 10501 3 | FETCH EMPLOYEES | 57 of 57 (100.00%) | 49 4 | RIDSCN | 57 of 57 (100.00%) | 6 5 | SORT (UNIQUE) | 57 of 57 (100.00%) | 6 6 | IXSCAN EMP_NAME | 57 of 10000 ( .57%) | 6 7 | FETCH SALES | 101 of 101 (100.00%) | 183 8 | IXSCAN SALES_SUB_EMP | 101 of 1011118 ( .01%) | 13 Predicate Information 2 - JOIN (Q2.EMPLOYEE_ID = Q3.EMPLOYEE_ID) JOIN (Q2.SUBSIDIARY_ID = Q3.SUBSIDIARY_ID) 3 - SARG (Q1.LAST_NAME LIKE ?) 6 - START ($INTERNAL_FUNC$() <= Q1.LAST_NAME) STOP (Q1.LAST_NAME <= $INTERNAL_FUNC$()) SARG (Q1.LAST_NAME LIKE ?) 8 - START (Q2.SUBSIDIARY_ID = Q3.SUBSIDIARY_ID) START (Q2.EMPLOYEE_ID = Q3.EMPLOYEE_ID) STOP (Q2.SUBSIDIARY_ID = Q3.SUBSIDIARY_ID) STOP (Q2.EMPLOYEE_ID = Q3.EMPLOYEE_ID)
Das
UPPER
wurde aus derwhere
-Klausel entfernt, um das gewünschte Ergebnis zu erhalten.- Oracle
--------------------------------------------------------------- |Id |Operation | Name | Rows | Cost | --------------------------------------------------------------- | 0 |SELECT STATEMENT | | 822 | 38 | | 1 | NESTED LOOPS OUTER | | 822 | 38 | | 2 | TABLE ACCESS BY INDEX ROWID| EMPLOYEES | 1 | 4 | |*3 | INDEX RANGE SCAN | EMP_UP_NAME | 1 | | | 4 | TABLE ACCESS BY INDEX ROWID| SALES | 821 | 34 | |*5 | INDEX RANGE SCAN | SALES_EMP | 31 | | --------------------------------------------------------------- Predicate Information (identified by operation id): --------------------------------------------------- 3 - access(UPPER("LAST_NAME") LIKE 'WIN%') filter(UPPER("LAST_NAME") LIKE 'WIN%') 5 - access("E0_"."SUBSIDIARY_ID"="S1_"."SUBSIDIARY_ID"(+) AND "E0_"."EMPLOYEE_ID" ="S1_"."EMPLOYEE_ID"(+))
Die Datenbank lädt das Ergebnis der ersten Tabelle (EMPLOYEES
über den Index EMP_UP_NAME
) und holt für jede Zeile den entsprechenden Eintrag aus der zweiten Tabelle (SALES
über den Index SALES_EMP
).
Tipp
Lerne dein ORM-Tool kennen und übernimm die Kontrolle über Join-Operationen.
Ein Nested-Loops-Join liefert gute Performance, wenn die treibende Abfrage nur wenige Zeilen liefert. Andernfalls kann sich der Optimizer auch für einen gänzlich anderen Join-Algorithmus entscheiden – wie zum Beispiel den Hash-Join-Algorithmus, der im nächsten Abschnitt beschrieben wird. Das ist aber nur möglich, wenn die Applikation einen SQL-Join verwendet, um alle Daten auf einmal zu laden.
Links
Artikel: „Latency: Security vs. Performance“ über Netzwerk-Latenzen bei SQL-Applikationen.