CREATE INDEX blockt standardmäßig alle Schreib-Zugriffe auf die Tabelle — bei großen Tabellen Stundenfall. CONCURRENTLY macht den Build live: Reader und Writer sind parallel erlaubt. Dafür dauert's länger, ist nicht-transaktional und kann fehlschlagen — mit einem INVALID-Index als Folge. Hier alle Details.
Die Grundform
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON orders (customer_id);Was passiert hinter den Kulissen:
- Postgres scannt die Tabelle einmal komplett (mit
SHARE UPDATE EXCLUSIVELock — Reader und Writer dürfen weiter) - Baut eine erste Version des Index aus dem Snapshot
- Wartet auf alle Transaktionen, die zu Beginn liefen
- Macht einen zweiten Pass, um in der Zwischenzeit eingefügte/geänderte Zeilen einzuholen
- Markiert den Index als valide
Insgesamt: deutlich langsamer als ein normales CREATE INDEX, aber kein Service-Stop.
Einschränkungen
CONCURRENTLY hat Einschränkungen:
- Nicht in Transaktionen erlaubt — muss als Standalone-Statement laufen, kein
BEGIN; CREATE INDEX CONCURRENTLY ... ; COMMIT; - Höhere Last während des Builds — Tabelle wird zweimal gescannt
- Längere Build-Zeit — typisch 2-3× länger als ein normaler Index-Build
- Kann fehlschlagen — z. B. bei Konflikten in einer UNIQUE-Constraint
Migrations-Tools brauchen oft Spezial-Handling: golang-migrate und Co. wickeln Migrationen sonst in Transaktionen, was hier explizit deaktiviert werden muss.
Was passiert bei Abbruch?
Wenn der Build abstürzt — z. B. Verbindung weg, Server-Restart, Constraint-Konflikt — bleibt ein INVALID-Index zurück:
SELECT
i.relname AS index_name,
t.relname AS table_name
FROM pg_index ix
JOIN pg_class i ON i.oid = ix.indexrelid
JOIN pg_class t ON t.oid = ix.indrelid
WHERE NOT ix.indisvalid;INVALID-Indexe werden vom Planner nicht genutzt — sind also ohne Wert. Sie müssen aufgeräumt werden, bevor ein erneuter Build versucht wird.
INVALID-Index aufräumen
DROP INDEX CONCURRENTLY orders_customer_id_idx;Auch DROP INDEX CONCURRENTLY blockt nicht — sehr praktisch.
Danach: erneut versuchen.
CREATE INDEX CONCURRENTLY orders_customer_id_idx
ON orders (customer_id);Wer Glück hat: läuft beim zweiten Mal durch. Wer Pech hat: muss erst die Ursache finden (Constraint-Konflikt, etc.) und Daten bereinigen.
REINDEX CONCURRENTLY
Bestehende Indexe neu bauen, ohne Lock — z. B. um Bloat aufzuräumen oder einen Index nach Postgres-Major-Upgrade zu refreshen:
REINDEX INDEX CONCURRENTLY orders_customer_id_idx;
REINDEX TABLE CONCURRENTLY orders; -- alle Indexe der Tabelle
REINDEX SCHEMA CONCURRENTLY public; -- alle Indexe des SchemasVerfügbar ab PG 12. Vorher musste man ein temporäres Index erstellen, dann tauschen.
UNIQUE-Indexe und Konflikte
Wenn der Build einen UNIQUE-Constraint baut, der von Bestandsdaten verletzt wird:
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq
ON users (email);
-- ERROR: could not create unique index "users_email_uniq"
-- DETAIL: Key (email)=(hans@example.com) is duplicated.Folge: INVALID-Index, der gedroppt werden muss. Nach Bereinigung der Duplikate erneut versuchen.
Pragmatisch: bei großen Tabellen vorher prüfen mit:
SELECT email, count(*)
FROM users
GROUP BY email
HAVING count(*) > 1;Constraints, die nicht CONCURRENTLY gehen
PRIMARY KEY und UNIQUE als Constraints (nicht als Index) brauchen ACCESS EXCLUSIVE. Workaround: erst Index CONCURRENTLY bauen, dann als Constraint registrieren.
-- 1. Unique-Index ohne Lock
CREATE UNIQUE INDEX CONCURRENTLY users_email_uniq_idx
ON users (email);
-- 2. Existierenden Index als Constraint registrieren (kurzer Lock)
ALTER TABLE users
ADD CONSTRAINT users_email_uniq
UNIQUE USING INDEX users_email_uniq_idx;Schritt 2 ist instant — Postgres muss nichts mehr scannen, der Index ist schon da.
Häufige Stolperfallen
Keine Transaktion — Migrations-Tool muss das wissen.
CREATE INDEX CONCURRENTLY darf nicht in BEGIN; ... COMMIT; laufen. Migrations-Frameworks wie Flyway, Liquibase, golang-migrate haben dafür Markers (z. B. -- transactional: false oder eigene Schalter). Wer das vergisst: Fehler beim Start der Migration.
INVALID-Index nach Abbruch — unbedingt cleanen.
Ein abgebrochener Build hinterlässt einen INVALID-Index. Der Planner ignoriert ihn, kostet aber Speicher und Schreib-Performance. \d table zeigt's. Mit DROP INDEX CONCURRENTLY aufräumen, dann erneut.
Build dauert deutlich länger als ein normaler.
Faktor 2-3× ist typisch — dafür ohne Service-Stop. Bei riesigen Tabellen kann ein Build mehrere Stunden brauchen. Ist okay, weil keine Downtime; aber Erwartungs-Management mit dem Team.
Hohe Last während des Builds.
Postgres scannt die Tabelle zweimal und braucht Sort-Memory. Auf Production-DB mit hoher Schreib-Last kann das spürbar werden. In Off-Peak-Zeiten planen, falls möglich.
Lange laufende Transaktionen blockieren das Finalisieren.
Phase 3 (Warten auf Transaktionen) hängt, wenn eine Transaktion ewig offen ist. Symptom: Build steht nahe Ende, kommt aber nicht durch. Mit pg_stat_activity lange Transaktionen identifizieren und ggf. terminieren.
Bei UNIQUE: Bestand vorher prüfen.
Ein UNIQUE-Index bricht beim ersten Duplikat ab — und du hast einen INVALID-Index. Spar dir den Loop: vor dem Build mit GROUP BY ... HAVING count(*) > 1 prüfen und Duplikate bereinigen.