PostgreSQL Indexe optimieren — Wartung, Monitoring, Einrichtung
Ziel: Praxis-Queries für die komplette Index-Pflege — unused Indexes finden,
Duplikate erkennen, Bloat messen, fehlende Indexe aufspüren, Index-Typen richtig
wählen. Mit Erklärungen warum jede Query das misst, was sie misst.
Inhaltsverzeichnis
- Grundprinzipien — wann hilft ein Index wirklich
- Unused / tote Indexe finden
- Doppelte und redundante Indexe
- Fehlende Indexe aufspüren
- Index-Bloat (Fragmentierung)
- Index-Größe und Speicherverbrauch
- Index-Nutzung tracken
- Invalid / ungültige Indexe
- Index-Typen richtig wählen
- Partielle und Expression-Indexe
- Wartungs-Workflow (REINDEX, ANALYZE)
- Monitoring-Dashboard-Queries
1. Grundprinzipien
Ein Index ist kein kostenloser Performance-Boost. Jeder Index:
- Verbraucht Disk-Space (oft 20-50 % der Table-Größe)
- Verlangsamt jeden
INSERT/UPDATE/DELETEauf der Tabelle - Muss von autovacuum gepflegt werden
- Konkurriert mit anderen Indexen um
shared_buffers
Goldene Regel: Jeden Index, den du anlegst, musst du rechtfertigen. Jeden Index,
der existiert, musst du regelmäßig prüfen ob er noch genutzt wird.
Wann hilft ein Index?
| Szenario | Index sinnvoll? |
|---|---|
WHERE col = value auf großer Tabelle |
✓ B-tree |
WHERE col LIKE 'prefix%' |
✓ B-tree (text_pattern_ops) |
WHERE col LIKE '%middle%' |
✗ B-tree, ✓ GIN (pg_trgm) |
WHERE jsonb_col @> '{"key":"val"}' |
✓ GIN |
ORDER BY col LIMIT 10 |
✓ B-tree |
WHERE arr @> ARRAY[...] |
✓ GIN |
Range-Queries (BETWEEN, <, >) |
✓ B-tree oder BRIN (bei sortierten Daten) |
Geo-Queries (ST_Within, …) |
✓ GiST |
| Kleine Tabelle (< 1000 Zeilen) | ✗ Seq Scan ist schneller |
| Spalte hat nur wenige distinct values | ⚠ Nur partial oder bitmap sinnvoll |
2. Unused / tote Indexe finden
Indexe die nie (oder kaum) gelesen werden, sind reiner Ballast.
Alle nie gelesenen Indexe
SELECT
s.schemaname
, s.relname AS table_name
, s.indexrelname AS index_name
, pg_size_pretty(pg_relation_size(s.indexrelid)) AS index_size
, s.idx_scan AS scans
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
WHERE s.idx_scan = 0
AND NOT i.indisunique -- Unique-Indexe schützen Daten, nicht löschen!
AND NOT i.indisprimary -- PK nie löschen
ORDER BY pg_relation_size(s.indexrelid) DESC;
Wichtig:
idx_scan = 0heißt „seit letztem Stats-Reset nie genutzt". Vor dem Drop mindestens
eine volle Business-Woche warten, besser einen Monat (Monatsabschluss etc.).
pg_stat_statements_reset()oder PG-Restart setzt die Counter zurück → dann wieder warten.- Unique-Constraints NIE droppen — die sind für Datenintegrität da, nicht für Performance.
Wenig-genutzte große Indexe
SELECT
schemaname
, relname AS table_name
, indexrelname AS index_name
, idx_scan
, pg_size_pretty(pg_relation_size(indexrelid)) AS index_size
, idx_tup_read
, idx_tup_fetch
FROM pg_stat_user_indexes
WHERE idx_scan < 50
AND pg_relation_size(indexrelid) > 1024 * 1024 -- > 1 MB
ORDER BY pg_relation_size(indexrelid) DESC
LIMIT 30;
Kandidaten zum Droppen: selten gelesen + viel Platz + viele Writes auf der Tabelle.
Zeitpunkt des letzten Resets prüfen
SELECT stats_reset FROM pg_stat_database WHERE datname = current_database();
Nur aussagekräftig wenn Reset lange genug her ist.
3. Doppelte und redundante Indexe
Exakte Duplikate (gleiche Spalten, gleiche Reihenfolge)
SELECT
pg_size_pretty(sum(pg_relation_size(idx))::bigint) AS size
, (array_agg(idx)) AS idx1
, (array_agg(idx)) AS idx2
, (array_agg(idx)) AS idx3
, (array_agg(idx)) AS idx4
FROM (
SELECT
indexrelid::regclass AS idx
, (indrelid::text || E'\n'
|| indclass::text || E'\n'
|| indkey::text || E'\n'
|| coalesce(indexprs::text, '') || E'\n'
|| coalesce(indpred::text, '')) AS key
FROM pg_index
) sub
GROUP BY key
HAVING count(*) > 1
ORDER BY sum(pg_relation_size(idx)) DESC;
Redundante Index-Präfixe
Wenn Index (a, b) existiert, ist ein zusätzlicher Index auf (a) meist überflüssig —
der Composite-Index kann die Einzelspalten-Queries auch bedienen.
SELECT
a.indrelid::regclass AS table_name
, a.indexrelid::regclass AS index_redundant
, b.indexrelid::regclass AS index_covering
, pg_size_pretty(pg_relation_size(a.indexrelid)) AS redundant_size
FROM pg_index a
JOIN pg_index b ON a.indrelid = b.indrelid
AND a.indexrelid <> b.indexrelid
AND a.indkey::text <> b.indkey::text
AND b.indkey::text LIKE a.indkey::text || ' %'
WHERE NOT a.indisunique
AND NOT a.indisprimary
AND a.indpred IS NULL -- keine partiellen
AND b.indpred IS NULL
ORDER BY pg_relation_size(a.indexrelid) DESC;
Achtung: Manchmal ist der kleinere Index für Hot-Queries absichtlich da (besserer
Buffer-Hit). Erst EXPLAIN ANALYZE der wichtigsten Queries, dann droppen.
4. Fehlende Indexe aufspüren
Sequential Scans auf großen Tabellen
SELECT
schemaname
, relname AS table_name
, seq_scan
, seq_tup_read
, idx_scan
, seq_tup_read / nullif(seq_scan, 0) AS avg_seq_tuples
, pg_size_pretty(pg_relation_size(relid)) AS table_size
, n_live_tup
FROM pg_stat_user_tables
WHERE seq_scan > 0
AND pg_relation_size(relid) > 10 * 1024 * 1024 -- > 10 MB
AND seq_tup_read / nullif(seq_scan, 0) > 10000 -- durchschnittlich viele Rows gelesen
ORDER BY seq_tup_read DESC
LIMIT 30;
Hohe seq_tup_read → Tabelle wird häufig voll durchgelesen. Wenn die Query ein
spezifisches WHERE hat, fehlt wahrscheinlich ein Index.
Teure Queries mit pg_stat_statements
SELECT
substring(query, 1, 120) AS q
, calls
, round(total_exec_time::numeric, 0) AS total_ms
, round(mean_exec_time::numeric, 2) AS mean_ms
, rows
, shared_blks_read
, shared_blks_hit
FROM pg_stat_statements
WHERE query NOT ILIKE '%pg_stat%'
ORDER BY total_exec_time DESC
LIMIT 20;
Queries mit hoher mean_exec_time + vielen shared_blks_read → potenzielle Index-Kandidaten.
Mit EXPLAIN (ANALYZE, BUFFERS) einzeln analysieren.
Hypothetische Indexe testen (Extension hypopg)
CREATE EXTENSION IF NOT EXISTS hypopg;
-- Hypothetischen Index erstellen (kostet nichts, nimmt keinen Platz)
SELECT * FROM hypopg_create_index(
'CREATE INDEX ON market.stock_price (ticker, price_date)'
);
-- Query planen als wäre der Index da
EXPLAIN SELECT * FROM market.stock_price WHERE ticker = 'AAPL' AND price_date > '2024-01-01';
-- Hypothetischen Index wieder entfernen
SELECT hypopg_reset();
Extrem nützlich vor teuren CREATE INDEX CONCURRENTLY-Operationen auf großen Tabellen.
5. Index-Bloat (Fragmentierung)
PostgreSQL-Indexe bloaten durch UPDATE/DELETE — gelöschte Einträge werden nicht
sofort physisch entfernt, sondern erst beim nächsten VACUUM markiert. Bei häufigen
Updates können Indexe auf das 2-5-fache ihrer Soll-Größe wachsen.
Bloat pro Index schätzen
-- pgstattuple-Extension nötig (liefert exakte Werte, kostet aber IO)
CREATE EXTENSION IF NOT EXISTS pgstattuple;
SELECT
schemaname
, indexname
, pg_size_pretty(pg_relation_size(schemaname || '.' || indexname)) AS size
, round((pgstatindex(schemaname || '.' || indexname)).avg_leaf_density::numeric, 2) AS leaf_density_pct
, round((pgstatindex(schemaname || '.' || indexname)).leaf_fragmentation::numeric, 2) AS fragmentation_pct
FROM pg_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(schemaname || '.' || indexname) DESC
LIMIT 20;
Interpretation:
leaf_density< 50 % → Index ist stark bloated → REINDEX lohntleaf_density> 80 % → gesundfragmentation> 30 % → REINDEX verbessert Scan-Performance
Schnelle Bloat-Heuristik (ohne pgstattuple)
-- Schätzt Bloat auf Basis von Statistiken, ohne den Index zu lesen
SELECT
current_database()
, schemaname
, tblname
, idxname
, pg_size_pretty(bloat_size) AS bloat_size
, round(bloat_ratio::numeric, 2) AS bloat_pct
, pg_size_pretty(real_size) AS real_size
FROM (
SELECT
nspname AS schemaname
, tblname
, idxname
, bs * (relpages)::bigint AS real_size
, bs * (relpages - est_pages_ff)::bigint AS bloat_size
, 100 * (relpages - est_pages_ff)::float / relpages AS bloat_ratio
FROM (
SELECT
n.nspname
, ct.relname AS tblname
, ci.relname AS idxname
, ci.relpages
, current_setting('block_size')::int AS bs
, ceil(i.reltuples * (6 + 8) / ((current_setting('block_size')::int - 24) * 0.9)) AS est_pages_ff
FROM pg_index x
JOIN pg_class ct ON ct.oid = x.indrelid
JOIN pg_class ci ON ci.oid = x.indexrelid
JOIN pg_class i ON i.oid = x.indexrelid
JOIN pg_namespace n ON n.oid = ci.relnamespace
WHERE ci.relpages > 100
AND n.nspname NOT IN ('pg_catalog', 'information_schema')
) a
) b
WHERE bloat_ratio > 20
ORDER BY bloat_size DESC
LIMIT 30;
Wichtig: Das ist eine Schätzung. Für echte Werte pgstattuple nehmen.
REINDEX ohne Downtime
-- Ab PG 12: CONCURRENTLY ohne Lock
REINDEX INDEX CONCURRENTLY market.idx_stock_price_ticker_date;
-- Gesamte Tabelle (alle Indexe)
REINDEX TABLE CONCURRENTLY market.stock_price;
-- Komplette Datenbank (Vorsicht: lange Laufzeit)
REINDEX DATABASE CONCURRENTLY marketinsider;
Nie normales REINDEX auf Produktion — das lockt die Tabelle komplett.
6. Index-Größe und Speicherverbrauch
Top-20 größte Indexe
SELECT
schemaname
, tablename
, indexname
, pg_size_pretty(pg_relation_size(schemaname || '.' || indexname)) AS index_size
, pg_size_pretty(pg_relation_size(schemaname || '.' || tablename)) AS table_size
, round(pg_relation_size(schemaname || '.' || indexname)::numeric
/ nullif(pg_relation_size(schemaname || '.' || tablename), 0) * 100, 1) AS pct_of_table
FROM pg_indexes
WHERE schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY pg_relation_size(schemaname || '.' || indexname) DESC
LIMIT 20;
Rote Flagge: Index > 50 % der Tabellen-Größe → zu viele Spalten im Index, oder Bloat.
Index vs. Table-Ratio pro Tabelle
SELECT
schemaname
, relname AS table_name
, pg_size_pretty(pg_relation_size(relid)) AS table_size
, pg_size_pretty(pg_indexes_size(relid)) AS indexes_size
, round(pg_indexes_size(relid)::numeric / nullif(pg_relation_size(relid), 0), 2) AS index_table_ratio
FROM pg_stat_user_tables
WHERE pg_relation_size(relid) > 10 * 1024 * 1024
ORDER BY pg_indexes_size(relid) DESC
LIMIT 20;
index_table_ratio > 1.0 → mehr Index als Daten. Oft Optimierungspotenzial.
7. Index-Nutzung tracken
Detaillierte Nutzungs-Stats
SELECT
s.schemaname
, s.relname AS table_name
, s.indexrelname AS index_name
, s.idx_scan
, s.idx_tup_read
, s.idx_tup_fetch
, pg_size_pretty(pg_relation_size(s.indexrelid)) AS size
, CASE
WHEN s.idx_scan = 0 THEN 'NEVER USED'
WHEN s.idx_scan < 100 THEN 'RARELY'
WHEN s.idx_tup_read / s.idx_scan > 1000 THEN 'LOW SELECTIVITY'
ELSE 'OK'
END AS status
FROM pg_stat_user_indexes s
WHERE s.schemaname NOT IN ('pg_catalog', 'information_schema')
ORDER BY s.idx_scan ASC, pg_relation_size(s.indexrelid) DESC
LIMIT 50;
LOW SELECTIVITY: Durchschnittlich > 1000 Rows pro Scan gelesen — entweder ist der
Index nicht selektiv genug oder der Planner nutzt ihn für Range-Scans. Prüfen mit
konkreten Query-Plänen.
Cache-Hit-Rate pro Index
SELECT
indexrelname AS index_name
, idx_blks_hit
, idx_blks_read
, round(100.0 * idx_blks_hit / nullif(idx_blks_hit + idx_blks_read, 0), 2) AS hit_pct
FROM pg_statio_user_indexes
WHERE idx_blks_hit + idx_blks_read > 0
ORDER BY idx_blks_read DESC
LIMIT 20;
Indexe mit niedriger Hit-Rate → kommen nicht in shared_buffers → entweder zu groß
oder zu selten genutzt. Indexe mit vielen Reads aber niedrigem Scan-Count sind die
teuersten.
8. Invalid / ungültige Indexe
Nach gescheitertem CREATE INDEX CONCURRENTLY
SELECT
n.nspname AS schemaname
, c.relname AS indexname
, pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE NOT i.indisvalid;
Ursache: CREATE INDEX CONCURRENTLY wurde abgebrochen oder ist auf Duplicate-Key
gelaufen. Der Index ist unbrauchbar — Planner nutzt ihn nicht, aber er wird bei
jedem INSERT gepflegt (oder auch nicht, je nach Status).
Fix:
DROP INDEX CONCURRENTLY schemaname.indexname;
-- dann neu anlegen
CREATE INDEX CONCURRENTLY ... ;
Non-ready Indexes (werden gerade gebaut)
SELECT
c.relname AS indexname
, pg_size_pretty(pg_relation_size(c.oid)) AS size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
WHERE NOT i.indisready OR NOT i.indislive;
Wenn hier was hängt obwohl kein CREATE INDEX läuft → zombie, droppen.
9. Index-Typen richtig wählen
Verwendete Index-Typen übersichtlich
SELECT
am.amname AS index_type
, count(*) AS count
, pg_size_pretty(sum(pg_relation_size(i.indexrelid))) AS total_size
FROM pg_index i
JOIN pg_class c ON c.oid = i.indexrelid
JOIN pg_am am ON am.oid = c.relam
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE n.nspname NOT IN ('pg_catalog', 'information_schema')
GROUP BY am.amname
ORDER BY count DESC;
Wenn nur btree auftaucht → oft Potenzial für GIN bei jsonb- oder Array-Spalten.
B-tree vs. BRIN-Entscheidung
Bei zeitlich sortierten Daten (Logs, Price-History, Events) ist BRIN oft deutlich
besser als B-tree:
-- B-tree auf 100-Mio-Row-Tabelle: ~3 GB Index
CREATE INDEX idx_btree ON market.price_history (price_date);
-- BRIN auf selber Tabelle: ~3 MB Index
CREATE INDEX idx_brin ON market.price_history USING brin (price_date);
Voraussetzung: Daten sind physisch ungefähr sortiert (append-only). BRIN speichert
nur Min/Max pro 128 Pages (Default) → winzig, aber nur nützlich bei korrelierten Daten.
Physische Korrelation prüfen:
SELECT attname, correlation
FROM pg_stats
WHERE tablename = 'price_history'
ORDER BY abs(correlation) DESC;
correlation nahe ±1 → BRIN perfekt. Nahe 0 → BRIN unbrauchbar.
10. Partielle und Expression-Indexe
Partielle Indexe (nur relevante Zeilen)
Wenn nur ein kleiner Teil der Tabelle abgefragt wird, ist ein partieller Index
kleiner und schneller:
-- Statt Voll-Index auf allen Orders:
CREATE INDEX idx_orders_full ON orders (user_id); -- 10 GB
-- Nur aktive Orders (99 % der Queries):
CREATE INDEX idx_orders_active
ON orders (user_id)
WHERE status = 'active'; -- 200 MB
Faustregel: Wenn WHERE <bedingung> in > 80 % aller Queries auftaucht und
< 20 % der Rows matched → partieller Index.
Expression-Indexe
Wenn Queries Funktionen anwenden, muss der Index die Funktion enthalten:
-- Query: WHERE lower(email) = $1
-- Normaler Index auf email wird NICHT genutzt!
CREATE INDEX idx_users_email_lower ON users (lower(email));
Covering Indexes (INCLUDE)
Ab PG 11: Zusätzliche Spalten in den Index aufnehmen, die nicht zur Sortierung gehören
aber für Index-Only-Scans verfügbar sein sollen:
CREATE INDEX idx_orders_user_include
ON orders (user_id)
INCLUDE (order_date, total_amount);
Dann kann SELECT order_date, total_amount FROM orders WHERE user_id = $1 ohne
Heap-Access auskommen → deutlich schneller.
Index-Only-Scan Effektivität prüfen:
SELECT
schemaname
, relname
, indexrelname
, idx_scan
, idx_tup_read
, idx_tup_fetch
, round(100.0 * idx_tup_fetch / nullif(idx_tup_read, 0), 2) AS heap_fetch_pct
FROM pg_stat_user_indexes
WHERE idx_scan > 0
ORDER BY idx_tup_read DESC
LIMIT 20;
Niedrigere heap_fetch_pct = mehr Index-Only-Scans = besser. idx_tup_fetch = 0
wäre perfekt, ist aber nur bei Visibility-Map-freundlichen Tables möglich (reguläres
VACUUM nötig).
11. Wartungs-Workflow
Monatlich (oder nach großen Data-Changes)
-- 1. Statistics aktualisieren
ANALYZE VERBOSE;
-- 2. Bloat-Report laufen lassen (siehe Abschnitt 5)
-- 3. Unused Indexes Report
-- (siehe Abschnitt 2 — mindestens 30 Tage Beobachtungszeit)
-- 4. REINDEX bei > 30% Fragmentation
REINDEX INDEX CONCURRENTLY market.idx_bloated;
Vor/Nach großen Datenimports
-- VOR Bulk-Load: Indexe droppen spart Zeit
DROP INDEX CONCURRENTLY idx_big1, idx_big2;
-- Import läuft (INSERT / COPY / pg_restore)...
-- NACH Import: Indexe neu anlegen (parallel möglich)
CREATE INDEX CONCURRENTLY idx_big1 ON tbl (col1);
CREATE INDEX CONCURRENTLY idx_big2 ON tbl (col2);
ANALYZE tbl;
Faustregel je nach Import-Methode:
| Import-Methode | Drop+Recreate lohnt ab |
|---|---|
Einzel-INSERT im Bulk |
~20 % der Tabellengröße |
INSERT ... SELECT / Batch-INSERT |
~15 % |
COPY |
~5-10 % |
pg_restore --jobs=N |
automatisch (macht Drop+Recreate selbst) |
Warum COPY eine niedrigere Schwelle hat: COPY ist 10-50× schneller als
INSERT, die Index-Pflege pro Zeile kostet aber gleich viel. Dadurch dominiert die
Index-Maintenance bei COPY die Gesamtzeit stärker. Umgekehrt nutzt CREATE INDEX
maintenance_work_mem + parallele Worker (max_parallel_maintenance_workers) und
baut sortiert auf — das kann inkrementelle Pflege nie.
WAL-Optimierung bei COPY + leerer Tabelle
Wenn die Tabelle in derselben Transaktion erstellt oder truncated wird und
wal_level = minimal gesetzt ist, überspringt COPY das WAL-Logging für die
Daten komplett (nur Metadaten werden geWALed):
BEGIN;
TRUNCATE tbl; -- oder CREATE TABLE tbl (...)
COPY tbl FROM '/path/to/data.csv' CSV; -- kein WAL für die Rows
CREATE INDEX idx_big1 ON tbl (col1); -- kein WAL für Index-Pages
CREATE INDEX idx_big2 ON tbl (col2);
COMMIT;
Voraussetzungen:
wal_level = minimal(Default istreplica— bei aktiver Replikation nicht möglich)max_wal_senders = 0TRUNCATEoderCREATE TABLEin derselben Transaktion wieCOPY- Kein
CREATE INDEX CONCURRENTLY(das läuft in eigenen Transaktionen)
Bei großen Imports (> 10 GB) macht das oft Faktor 2-3 Unterschied.
Fallstricke beim Drop+Recreate
- UNIQUE-Constraints / PK: Während des Imports nicht droppen, wenn Duplikat-
Erkennung gebraucht wird — sonst scheitert das spätere CREATE UNIQUE INDEX auf
bereits vorhandenen Duplikaten.
- Foreign Keys auf die Tabelle: Während des Drop-Fensters werden Queries auf
referenzierende Tabellen langsam (Seq-Scans statt Index-Lookups). Nur in
Maintenance-Fenstern machen.
- Concurrent Access: Ohne Indexe sind alle parallelen SELECTs auf der Tabelle
Seq-Scans. Für Online-Imports besser CREATE INDEX CONCURRENTLY in Kauf nehmen.
Nach REINDEX CONCURRENTLY
Der alte Index bleibt als _ccnew- oder _ccold-Suffix liegen falls abgebrochen.
Aufräumen:
SELECT n.nspname, c.relname
FROM pg_class c
JOIN pg_namespace n ON n.oid = c.relnamespace
WHERE c.relname LIKE '%_ccnew%'
OR c.relname LIKE '%_ccold%';
-- Falls welche da: droppen
DROP INDEX CONCURRENTLY schemaname.index_ccnew;
12. Monitoring-Dashboard-Queries
Index-Health auf einen Blick
WITH idx_stats AS (
SELECT
count(*) FILTER (WHERE s.idx_scan = 0 AND NOT i.indisunique AND NOT i.indisprimary) AS unused
, count(*) FILTER (WHERE NOT i.indisvalid) AS invalid
, count(*) AS total
, sum(pg_relation_size(s.indexrelid)) AS total_bytes
FROM pg_stat_user_indexes s
JOIN pg_index i ON i.indexrelid = s.indexrelid
)
SELECT
total AS total_indexes
, unused AS unused_indexes
, invalid AS invalid_indexes
, pg_size_pretty(total_bytes) AS total_index_size
FROM idx_stats;
Top-Tabellen mit hohem Write-Overhead durch Indexe
SELECT
s.schemaname
, s.relname AS table_name
, s.n_tup_ins + s.n_tup_upd + s.n_tup_del AS total_writes
, count(i.indexrelid) AS index_count
, pg_size_pretty(pg_indexes_size(s.relid)) AS indexes_size
FROM pg_stat_user_tables s
LEFT JOIN pg_index i ON i.indrelid = s.relid
WHERE s.n_tup_ins + s.n_tup_upd + s.n_tup_del > 10000
GROUP BY s.schemaname, s.relname, s.relid, s.n_tup_ins, s.n_tup_upd, s.n_tup_del
HAVING count(i.indexrelid) > 3
ORDER BY (s.n_tup_ins + s.n_tup_upd + s.n_tup_del) * count(i.indexrelid) DESC
LIMIT 20;
Viele Writes × viele Indexe = Kandidaten für Index-Reduktion.
Live-Index-Builds prüfen
SELECT
now() - query_start AS duration
, pid
, state
, substring(query, 1, 100) AS query
FROM pg_stat_activity
WHERE query ILIKE 'CREATE INDEX%' OR query ILIKE 'REINDEX%';
Nützlich um zu sehen ob nächtliche Maintenance-Jobs noch laufen.
Progress-Tracking für CREATE INDEX / REINDEX (PG 12+)
SELECT
pid
, phase
, round(100.0 * blocks_done / nullif(blocks_total, 0), 2) AS blocks_pct
, round(100.0 * tuples_done / nullif(tuples_total, 0), 2) AS tuples_pct
, current_locker_pid
FROM pg_stat_progress_create_index;
Live-Fortschritt eines laufenden CREATE INDEX CONCURRENTLY.
Checkliste — vor Produktion
- [ ] Keine
NOT indisvalid-Indexe (invalid indexes droppen) - [ ] Alle Unused-Indexe seit > 30 Tagen geprüft
- [ ] Top-10 größte Tabellen: Index-to-Table-Ratio < 1.0
- [ ] Keine Duplikat-Indexe (Query aus Abschnitt 3)
- [ ]
pg_stat_statementsaktiviert (für teure Queries) - [ ] autovacuum läuft regelmäßig auf Index-haltige Tabellen (Bloat-Vermeidung)
- [ ] Partitionierte Tabellen: Index auf jeder Partition, nicht nur Parent
- [ ] REINDEX-Strategie dokumentiert (wann/was/Downtime-Fenster)
Weiterführend
- PostgreSQL Docs — Indexes
- Use The Index, Luke! — SQL-Performance für Entwickler
- Extensions:
pg_stat_statements,pgstattuple,hypopg,pg_trgm - Verwandt: postgres-performance-settings.md,