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

  1. Grundprinzipien — wann hilft ein Index wirklich
  2. Unused / tote Indexe finden
  3. Doppelte und redundante Indexe
  4. Fehlende Indexe aufspüren
  5. Index-Bloat (Fragmentierung)
  6. Index-Größe und Speicherverbrauch
  7. Index-Nutzung tracken
  8. Invalid / ungültige Indexe
  9. Index-Typen richtig wählen
  10. Partielle und Expression-Indexe
  11. Wartungs-Workflow (REINDEX, ANALYZE)
  12. 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/DELETE auf 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 = 0 heiß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 lohnt
  • leaf_density > 80 % → gesund
  • fragmentation > 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

PostgreSQL Index-Typen B-tree Standard für alles Sortierbare • =, <, >, BETWEEN, IS NULL • ORDER BY, LIMIT • LIKE 'prefix%' → 95% aller Cases GIN für zusammengesetzte Werte • jsonb @>, ?, ?&, ?| • Array @>, && • pg_trgm (LIKE '%mid%') • Full-Text (tsvector) GiST für Geometrie & Range • PostGIS (ST_*) • tstzrange, int4range • Exclusion Constraints • KNN (Nearest Neighbor) BRIN für riesige sortierte Tables • Time-Series Data • Append-Only Logs • 1000× kleiner als B-tree • nur bei physisch sortierten Daten Hash nur =-Vergleich • WHERE col = value • keine Sort/Range • ab PG 10 WAL-logged (safe) → selten besser als B-tree SP-GiST partitionierte GiST-Variante • Telefonnummern • IP-Ranges (inet) • Radix/Quad-Trees • nicht-balancierte Daten

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 ist replica — bei aktiver Replikation nicht möglich)
  • max_wal_senders = 0
  • TRUNCATE oder CREATE TABLE in derselben Transaktion wie COPY
  • 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_statements aktiviert (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

postgres-hugepages.md