Zum Inhalt springen

SQL-Abfrageoptimierung: von langsam zu schnell

Praktische Techniken zum Erkennen und Beheben langsamer SQL-Abfragen mit EXPLAIN-Plänen, Indexierungsstrategien und Mustern zur Umstrukturierung von Abfragen.

5 Min. Lesezeit
SQL-EXPLAIN-Ausgabe, die den Ausführungsplan einer Abfrage mit sequenziellen Scans und Indexscans zeigt

Jede langsame Anwendung hat irgendwo eine langsame Abfrage versteckt. Der Anwendungscode mag gut optimiert sein, der Server mag reichlich Ressourcen haben, aber eine einzige schlecht geschriebene SQL-Abfrage, die einen vollständigen Tabellenscan über eine Million Zeilen durchführt, bremst das gesamte System aus. Die Abfrage lief in der Entwicklung mit 1.000 Zeilen einwandfrei. Im Produktionsmaßstab bricht sie zusammen.

Solche Abfragen zu finden und zu beheben ist eine der wirkungsvollsten Fähigkeiten, die ein Backend-Entwickler haben kann. Das Muster ist fast immer dasselbe: den EXPLAIN-Plan lesen, den richtigen Index hinzufügen, die Abfrage umstrukturieren.

EXPLAIN-Pläne lesen

EXPLAIN ANALYZE ist das wichtigste Werkzeug für die SQL-Performance. Es zeigt, wie die Datenbank deine Abfrage tatsächlich ausführt – nicht, wie du glaubst, dass sie ausgeführt wird.

sqlsql
EXPLAIN ANALYZE
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
WHERE u.created_at > '2020-01-01'
GROUP BY u.id, u.name
ORDER BY order_count DESC
LIMIT 20;

Darauf solltest du in der Ausgabe achten:

  • Seq Scan — Vollständiger Tabellenscan. Bei kleinen Tabellen unproblematisch, bei großen katastrophal.
  • Index Scan / Index Only Scan — Nutzung eines Index. Das ist der gewünschte Fall.
  • Nested Loop — Verknüpfung durch zeilenweises Iterieren. Effizient bei kleinen Ergebnismengen.
  • Hash Join — Baut für den Join eine Hash-Tabelle auf. Besser bei großen Datenmengen.
  • Sort — Expliziter Sortierschritt. Kann auf die Festplatte auslagern, wenn work_mem zu klein ist.
  • Actual time — Tatsächliche Ausführungszeit pro Knoten. Die größte Zahl ist dein Engpass.
sqlsql
-- ❌ Seq Scan on a million-row table — every row is read
-- Seq Scan on orders (cost=0.00..35421.00 rows=1000000)
--   Filter: (status = 'pending')
--   Rows Removed by Filter: 990000
-- Planning Time: 0.2ms
-- Execution Time: 2340ms
 
-- ✅ After adding an index — only relevant rows are read
-- Index Scan using idx_orders_status on orders (cost=0.42..825.00 rows=10000)
--   Index Cond: (status = 'pending')
-- Planning Time: 0.3ms
-- Execution Time: 12ms

Indexierungsstrategien

Ein Index ist eine sortierte Datenstruktur, mit der die Datenbank Zeilen findet, ohne die gesamte Tabelle zu durchsuchen. Der richtige Index macht aus einer 2-Sekunden-Abfrage eine mit 10 ms. Der falsche Index verschwendet Speicherplatz und verlangsamt Schreibvorgänge.

sqlsql
-- Single column index — for exact match and range queries
CREATE INDEX idx_users_email ON users (email);
CREATE INDEX idx_orders_created ON orders (created_at);
 
-- Composite index — column order matters
-- This index serves: WHERE status = X AND created_at > Y
-- It does NOT efficiently serve: WHERE created_at > Y (alone)
CREATE INDEX idx_orders_status_created
  ON orders (status, created_at);
 
-- Partial index — indexes only rows matching a condition
-- Much smaller than a full index when most data is filtered out
CREATE INDEX idx_orders_pending
  ON orders (created_at)
  WHERE status = 'pending';

Regeln zur Indexauswahl

sqlsql
-- ❌ Index on a low-cardinality column — not useful
-- A boolean column has 2 values. The index doesn't help.
CREATE INDEX idx_users_active ON users (is_active);
 
-- ✅ Partial index on the rare case — small and effective
CREATE INDEX idx_users_inactive
  ON users (email)
  WHERE is_active = false;
-- Only indexes the 2% of users who are inactive

Die Spaltenreihenfolge eines zusammengesetzten Index folgt der Regel „Gleichheit zuerst, Bereich danach":

sqlsql
-- Query: WHERE status = 'shipped' AND created_at > '2020-06-01'
-- ❌ Range column first — index partially used
CREATE INDEX idx_wrong ON orders (created_at, status);
 
-- ✅ Equality column first — index fully utilized
CREATE INDEX idx_right ON orders (status, created_at);

Abfragen umstrukturieren

Manchmal liegt die Lösung nicht in einem Index, sondern in einer anderen Abfrage. Für gängige Muster, die Performance-Probleme verursachen, gibt es bewährte Alternativen.

SELECT * vermeiden

sqlsql
-- ❌ Fetches all 30 columns — most are unused
SELECT * FROM users WHERE department = 'engineering';
 
-- ✅ Only fetch what you need — enables index-only scans
SELECT id, name, email FROM users WHERE department = 'engineering';

Wenn eine Abfrage nur Spalten benötigt, die im Index enthalten sind, kann PostgreSQL sie vollständig aus dem Index beantworten, ohne die Tabelle anzufassen – einen sogenannten „Index-Only Scan".

Subquery vs. JOIN

sqlsql
-- ❌ Correlated subquery — runs once per row in the outer query
SELECT u.name,
  (SELECT COUNT(*) FROM orders o WHERE o.user_id = u.id) as order_count
FROM users u;
 
-- ✅ JOIN with aggregation — single pass
SELECT u.name, COUNT(o.id) as order_count
FROM users u
LEFT JOIN orders o ON o.user_id = u.id
GROUP BY u.id, u.name;

EXISTS vs. IN bei großen Mengen

sqlsql
-- ❌ IN with subquery — materializes entire result set
SELECT * FROM users
WHERE id IN (SELECT user_id FROM orders WHERE total > 1000);
 
-- ✅ EXISTS — stops at first match per row
SELECT * FROM users u
WHERE EXISTS (
  SELECT 1 FROM orders o
  WHERE o.user_id = u.id AND o.total > 1000
);

N+1-Abfragen erkennen

Das N+1-Problem ist der häufigste Performance-Fehler in Anwendungen, die ORMs verwenden. Eine Abfrage lädt N übergeordnete Zeilen, danach laden N einzelne Abfragen jeweils die zugehörigen Kindzeilen.

tstypescript
// ❌ N+1 — 1 query for users + N queries for orders
const users = await db.query('SELECT * FROM users LIMIT 100');
for (const user of users) {
  user.orders = await db.query(
    'SELECT * FROM orders WHERE user_id = $1',
    [user.id]
  );
}
// Total: 101 queries
 
// ✅ Single query with JOIN
const usersWithOrders = await db.query(`
  SELECT u.*, json_agg(o.*) as orders
  FROM users u
  LEFT JOIN orders o ON o.user_id = u.id
  GROUP BY u.id
  LIMIT 100
`);
// Total: 1 query

Verwende in ORMs Eager Loading, um N+1 zu vermeiden:

tstypescript
// ❌ Prisma — lazy loading triggers N+1
const users = await prisma.user.findMany();
// Accessing users[0].orders triggers another query
 
// ✅ Prisma — include prevents N+1
const users = await prisma.user.findMany({
  include: { orders: true },
});

Paginierung richtig umsetzen

Offset-basierte Paginierung verschlechtert sich linear. Seite 1000 durchsucht 999 Ergebnisseiten und verwirft sie wieder.

sqlsql
-- ❌ Offset pagination — gets slower with higher pages
-- Page 1000 scans 100,000 rows to return 100
SELECT * FROM orders ORDER BY created_at DESC
OFFSET 99900 LIMIT 100;
 
-- ✅ Cursor-based pagination — constant performance
-- Uses the last seen value as the starting point
SELECT * FROM orders
WHERE created_at < '2020-12-15T10:30:00Z'
ORDER BY created_at DESC
LIMIT 100;

Cursor-basierte Paginierung erfordert einen indizierten, eindeutigen Sortierschlüssel. Bei nicht eindeutigen Spalten kombinierst du sie mit dem Primärschlüssel:

sqlsql
-- Cursor for non-unique sort column
SELECT * FROM orders
WHERE (created_at, id) < ('2020-12-15T10:30:00Z', 'abc-123')
ORDER BY created_at DESC, id DESC
LIMIT 100;

Langsame Abfragen überwachen

Warte nicht, bis Nutzer sich über Langsamkeit beschweren. Überwache die Abfrageperformance proaktiv.

sqlsql
-- PostgreSQL: enable slow query logging
ALTER SYSTEM SET log_min_duration_statement = '200';
-- Logs every query taking longer than 200ms
 
-- Find the heaviest queries
SELECT query, calls, mean_exec_time, total_exec_time
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 20;

Die Erweiterung pg_stat_statements ist unverzichtbar. Sie erfasst Ausführungsstatistiken für jedes Abfragemuster, sodass du die Abfragen findest, die insgesamt die meiste Datenbankzeit verbrauchen – selbst wenn einzelne Ausführungen schnell sind.

Die wichtigsten Erkenntnisse

  1. Führe zuerst EXPLAIN ANALYZE aus — rate niemals, warum eine Abfrage langsam ist, wenn die Datenbank es dir sagen kann
  2. Indexiere Gleichheitsspalten vor Bereichsspalten — die Spaltenreihenfolge eines zusammengesetzten Index wirkt sich direkt auf seine Nutzbarkeit aus
  3. Partielle Indizes sparen Platz und verbessern die Performance — indexiere nur die Zeilen, die du tatsächlich abfragst
  4. Beseitige N+1-Abfragen — nutze JOINs oder Eager Loading statt Subqueries pro Zeile
  5. Verwende cursor-basierte Paginierung — Offset-Paginierung verschlechtert sich mit wachsender Größe, Cursor bleiben konstant
  6. Überwache mit pg_stat_statements — finde die Abfragen, die insgesamt am meisten Zeit verbrauchen, bevor sie zu einem Problem werden
Wilfredo Rujel

Wilfredo Rujel

Full-Stack-Softwareentwickler

Diesen Beitrag teilenX