Warum ist das langsam? Indizes und EXPLAIN

SQL beschreibt das gewünschte Ergebnis, nicht den Suchweg. EXPLAIN zeigt den gewählten Plan und damit, ob die Datenbank eine Tabelle scannt oder einen Index verwendet.

Ein Full Scan

Setzen Sie EXPLAIN QUERY PLAN vor eine beliebige Abfrage, und sie wird nicht ausgeführt; stattdessen kommt der Plan zurück.

1

EXPLAIN QUERY PLAN
SELECT * FROM products WHERE name = 'Quill Helmet';
In Planche ausführen

Ergebnis — 1 Zeile

idparentnotuseddetail
20216SCAN products

Öffnet Planche in einem eigenen Browser-Tab, mit geladener Beispieldatenbank und dieser Abfrage im Editor. Alle Beispiele der Seite verwenden denselben Tab weiter, und ausgeführt wird erst, wenn Sie F9 drücken.

SCAN products

SCAN heißt, die Datenbank hat jede Zeile der Tabelle gelesen und einzeln geprüft. products.name hat keinen Index, es steht also nichts Besseres zur Verfügung. Bei 400 Zeilen merkt das niemand. Der Grund, sich dafür zu interessieren: Diese Kosten wachsen direkt proportional zur Tabelle. Derselbe Plan liest auf zehn Millionen Zeilen zehn Millionen Zeilen – bei jedem Aufruf.

Eine Indexsuche

price hat einen Index.

SEARCH products USING INDEX idx_products_price (price>?)

SEARCH ist das Wort, das Sie sehen wollen. Die Datenbank ist in den Index gegangen, direkt dorthin gesprungen, wo Preise über 2000 beginnen, und hat nur von dort an gelesen – keine Zeile außerhalb der Antwort wurde angefasst. Andere Engines benutzen andere Wörter für dieselbe Unterscheidung: PostgreSQL sagt Seq Scan versus Index Scan, MySQLs EXPLAIN zeigt type: ALL versus type: range, SQL Server zeichnet einen Table Scan versus einen Index Seek.

Ein Join

3

EXPLAIN QUERY PLAN
SELECT o.order_number, c.last_name
FROM orders o
JOIN customers c ON c.id = o.customer_id
WHERE o.status = 'delivered';
In Planche ausführen

Ergebnis — 2 Zeilen

idparentnotuseddetail
4061SEARCH o USING INDEX idx_orders_status (status=?)
9045SEARCH c USING INTEGER PRIMARY KEY (rowid=?)
SEARCH o USING INDEX idx_orders_status (status=?)
SEARCH c USING INTEGER PRIMARY KEY (rowid=?)

Lesen Sie es als Schleife: die zugestellten Bestellungen über den Status-Index finden und zu jeder den Kunden über den Primärschlüssel nachschlagen. Beide Schritte sind Suchen, und so sieht ein gesunder Join aus. Misstrauisch macht ein SCAN auf der inneren Seite – das ist eine komplette Tabellenlesung je Zeile der äußeren und der übliche Grund, warum ein Join, der auf der Entwicklungsdatenbank schnell war, in der Produktion Minuten braucht. Fremdschlüsselspalten wollen einen Index; die meisten Datenbanken, SQLite und MySQL eingeschlossen, legen keinen für Sie an.

Wie man einen Index verliert

orders.ordered_at ist indiziert. Diese Abfrage filtert darauf und liest trotzdem die ganze Tabelle.

4

EXPLAIN QUERY PLAN
SELECT * FROM orders WHERE substr(ordered_at, 1, 4) = '2025';
In Planche ausführen

Ergebnis — 1 Zeile

idparentnotuseddetail
20216SCAN orders
SCAN orders

Der Index speichert ordered_at, nicht substr(ordered_at, 1, 4). Sobald die Spalte in eine Funktion eingepackt ist, sagt die gespeicherte Sortierung der Datenbank nichts mehr, sie muss den Ausdruck also für jede Zeile berechnen, um es herauszufinden. Dasselbe gilt für WHERE YEAR(created_at) = 2025, WHERE UPPER(email) = ... und WHERE price * 1.1 > 100.

Schreiben Sie es so um, dass die nackte Spalte dem Vergleich gegenübersteht:

5

EXPLAIN QUERY PLAN
SELECT * FROM orders
WHERE ordered_at >= '2025-01-01'
  AND ordered_at <  '2026-01-01';
In Planche ausführen

Ergebnis — 1 Zeile

idparentnotuseddetail
30163SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at…
SEARCH orders USING INDEX idx_orders_ordered_at (ordered_at>? AND ordered_at<?)

Dieselben Zeilen, und jetzt macht der Index die Arbeit. Die allgemeine Regel: Lassen Sie die indizierte Spalte allein auf einer Seite des Vergleichs und verschieben Sie die Rechnerei auf die andere. Eine Funktion auf einem Datum in einen halboffenen Bereich zu verwandeln, ist der mit Abstand häufigste Fall davon.

ORDER BY kann gratis sein

6

EXPLAIN QUERY PLAN
SELECT * FROM products ORDER BY price DESC LIMIT 20;
In Planche ausführen

Ergebnis — 1 Zeile

idparentnotuseddetail
50222SCAN products USING INDEX idx_products_price
SCAN products USING INDEX idx_products_price

Eine dritte Form: keine Suche, aber auch kein ungeordneter Scan. Die Abfrage will Zeilen in Preisreihenfolge, und der Index ist bereits nach Preis sortiert, also läuft die Datenbank den Index entlang und hört nach 20 Zeilen auf – ganz ohne Sortierung. Ohne diesen Index würde sie alle 400 Zeilen lesen, sortieren und 380 wegwerfen.

Deshalb hilft ein Index bei ORDER BY ... LIMIT genauso wie bei WHERE, und deshalb lohnt es sich, "Top 20 nach X"-Abfragen zu prüfen: mit dem richtigen Index fassen sie 20 Zeilen an, ohne ihn die ganze Tabelle.

Eine kurze Checkliste

  • Indizieren Sie die Spalten, nach denen Sie filtern und joinen – besonders Fremdschlüssel, die selten automatisch einen bekommen.
  • Halten Sie indizierte Spalten in WHERE aus Funktionsaufrufen heraus.
  • Bevorzugen Sie LIKE 'abc%' gegenüber LIKE '%abc%', wo es geht; ein führender Platzhalter kann keinen Index nutzen.
  • Indizes sind nicht gratis – jeder von ihnen muss bei jedem Schreibvorgang aktualisiert werden, indizieren Sie also, was Sie wirklich abfragen, nicht jede Spalte.
  • Messen statt raten. Führen Sie EXPLAIN vor und nach der Änderung aus und vergleichen Sie die beiden Pläne.

Wie es weitergeht

Das war der Grundkurs. Die Dialektseiten stellen drei Aufgaben – Paging, Upsert sowie Zeichenketten- und Datumsbehandlung – für MySQL, PostgreSQL, SQL Server, Oracle und SQLite nebeneinander, weil sich bei genau diesen dreien die Syntax so weit unterscheidet, dass eine funktionierende Abfrage vor dem Wechsel auf eine andere Engine umgeschrieben werden muss.

Selbst ausprobieren

  1. Nutzt die Suche nach einem Kunden über die E-Mail-Adresse einen Index? Fragen Sie nach dem Plan, statt zu raten.

    Lösung anzeigen

    Lösung anzeigen

    EXPLAIN QUERY PLAN
    SELECT * FROM customers WHERE email = 'aisha.ahmed@example.com';
    In Planche ausführen

    Ergebnis — 1 Zeile

    idparentnotuseddetail
    3039SEARCH customers USING INDEX sqlite_autoindex_customers_1 (…

    SEARCH customers USING INDEX sqlite_autoindex_customers_1 (email=?) – die UNIQUE-Bedingung auf email hat diesen Index erzeugt, deshalb ist eine Spalte schnell, die Sie nie ausdrücklich indiziert haben.

  2. Jetzt dieselbe Suche über first_name. Sagen Sie den Plan vorher, bevor Sie ihn ausführen.

    Lösung anzeigen

    Lösung anzeigen

    EXPLAIN QUERY PLAN
    SELECT * FROM customers WHERE first_name = 'Aisha';
    In Planche ausführen

    Ergebnis — 1 Zeile

    idparentnotuseddetail
    20216SCAN customers

    SCAN customers. Auf first_name allein gibt es keinen Index – idx_customers_name deckt (last_name, first_name) ab, und ein Index lässt sich nur ab seiner führenden Spalte nutzen. Deshalb wäre der Nachname gefunden worden und der Vorname nicht.