PostgreSQL Indexing für Laravel-Apps: Wann welcher Index
B-Tree, GIN, GiST, Composite, Partial, Trigram - Postgres bietet ein halbes Dutzend Index-Typen. Wir zeigen anhand realer Laravel-Migrationen, wann welcher passt und wie Sie mit EXPLAIN ANALYZE den Effekt messen.
Vom Slow-Log zum Plan
Erst messen, dann indexieren
Ein Index, der eine Query nicht beschleunigt, kostet Sie trotzdem Write-Performance und Plattenplatz. Deshalb startet jede Indexierungs-Entscheidung bei uns mit einem konkreten Plan aus EXPLAIN ANALYZE - nie mit dem Bauchgefühl, dass „da bestimmt ein Index helfen würde“. Postgres ist beim Erzählen, was es tut, deutlich gesprächiger als MySQL.
EXPLAIN ANALYZE lesen lernen
EXPLAIN ANALYZE führt die Query tatsächlich aus und liefert echte Zeiten pro Knoten. Wichtig sind drei Zahlen: actual time (die echte Laufzeit pro Knoten), rows (Schätzung vs. Realität) und loops. Wenn estimated und actual rows um Faktor 100 auseinanderliegen, hilft kein Index - dann fehlen Statistiken (ANALYZE) oder das Datenmodell ist schief.
Seq Scan ist nicht immer böse
Bei kleinen Tabellen oder Queries, die ohnehin den Großteil der Zeilen brauchen, ist ein Seq Scan schneller als ein Index Scan, weil der Planner sich die Indirektion über den Index-Heap spart. Erst wenn Postgres bei einer selektiven Query trotzdem sequenziell liest, lohnt sich ein neuer Index. Faustregel: Index, wenn weniger als 10 Prozent der Zeilen getroffen werden.
EXPLAIN (ANALYZE, BUFFERS)
SELECT id, title FROM articles
WHERE author_id = 42 AND published_at IS NOT NULL
ORDER BY published_at DESC LIMIT 20;
-- Limit (cost=0.42..18.74 rows=20 width=48) (actual time=0.041..0.182 rows=20 loops=1)
-- -> Index Scan Backward using articles_author_published_idx on articles
-- (cost=0.42..1834.21 rows=2003 width=48)
-- (actual time=0.039..0.176 rows=20 loops=1)
-- Index Cond: (author_id = 42)
-- Filter: (published_at IS NOT NULL)
-- Buffers: shared hit=8
-- Planning Time: 0.182 ms
-- Execution Time: 0.214 ms
Index-Typen
B-Tree ist der Default - aber nicht die einzige Wahl
Postgres unterstützt B-Tree, Hash, GIN, GiST, SP-GiST, BRIN. Für 90 Prozent der Felder reicht B-Tree. Für JSONB, Volltext, Arrays und Geometrie gibt es spezialisierte Strukturen, die in der richtigen Situation um Größenordnungen schneller sind. Hier sind die drei, die Sie in einer Laravel-App regelmäßig brauchen.
B-Tree für Gleichheit, Range und Sortierung
B-Tree ist der Standard hinter $table->index() in Laravel-Migrationen. Er deckt Vergleiche mit =, <, <=, >, >=, BETWEEN, IN und IS NULL ab und liefert sortierte Ergebnisse - perfekt, wenn Sie ORDER BY auf demselben Feld haben. Für Foreign Keys, Status-Spalten und Datumsfelder ist B-Tree fast immer richtig.
GIN für JSONB, Arrays und Volltext
GIN (Generalized Inverted Index) ist ein invertierter Index: Er indexiert die Tokens innerhalb eines Werts. Ideal für jsonb-Spalten mit ->> und @>-Operatoren, für Array-Mitgliedschaft (settings @> ARRAY['pro']) und für tsvector-Volltextsuche. Schreib-Overhead ist höher als bei B-Tree - bei Lese-lastigen Tabellen lohnt es trotzdem fast immer.
GiST für Geometrie und Range-Overlap
GiST (Generalized Search Tree) ist die Wahl für PostGIS-Geodaten, Range-Typen (tstzrange, int4range) und Nearest-Neighbour-Suchen. Klassisches Beispiel: Buchungssystem mit Überschneidungs-Check von Zeiträumen via && - ohne GiST-Index läuft das auf Seq Scan, mit Index in Millisekunden.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
Schema::table('articles', function (Blueprint $table) {
// B-Tree (Default) auf einer Foreign-Key-Spalte
$table->index('author_id', 'articles_author_idx');
// GIN auf einer JSONB-Spalte für ->> und @> Operatoren
// Laravel 13 unterstützt ->index() nicht für GIN - also raw:
// (Spalte muss bereits als jsonb existieren)
});
DB::statement('CREATE INDEX articles_metadata_gin_idx
ON articles USING GIN (metadata jsonb_path_ops)');
// Volltext über title + body, generierte tsvector-Spalte
DB::statement("ALTER TABLE articles
ADD COLUMN search_vector tsvector
GENERATED ALWAYS AS (
to_tsvector('german', coalesce(title,'') || ' ' || coalesce(body,''))
) STORED");
DB::statement('CREATE INDEX articles_search_gin_idx
ON articles USING GIN (search_vector)');
// GiST für Range-Overlap-Suche auf bookings
DB::statement('CREATE INDEX bookings_period_gist_idx
ON bookings USING GIST (period)');
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS articles_metadata_gin_idx');
DB::statement('DROP INDEX IF EXISTS articles_search_gin_idx');
DB::statement('DROP INDEX IF EXISTS bookings_period_gist_idx');
DB::statement('ALTER TABLE articles DROP COLUMN IF EXISTS search_vector');
Schema::table('articles', function (Blueprint $table) {
$table->dropIndex('articles_author_idx');
});
}
};
Slow Queries im Live-System?
Wir lesen Ihren Slow-Log mit, schreiben die nötigen Indizes und liefern eine reversible Migration - inklusive Vorher/Nachher-Messung.
Composite, Partial, Trigram
Wo Standard-Indizes nicht reichen
Drei Patterns, die wir in jeder zweiten Codebase nachrüsten: Composite-Indizes mit korrekter Spaltenreihenfolge, Partial Indexes für sparse Werte und Trigram-Indexe, wenn das Frontend ILIKE %x% schickt. Alle drei sind in Postgres native - Sie brauchen kein Extra-Tooling.
Composite-Index: Reihenfolge ist alles
Ein Composite-Index auf (author_id, published_at DESC) bedient sowohl WHERE author_id = ? als auch WHERE author_id = ? ORDER BY published_at DESC LIMIT 20 - aber nicht WHERE published_at > ? alleine. Faustregel: gleichheitsgeprüfte Spalten zuerst, Range/Sortier-Spalten danach. Mit der richtigen Reihenfolge ersetzt ein einziger Composite zwei separate Indizes.
Partial Index: nur das, was zählt
Partial Indexes indexieren nur die Zeilen, die einer WHERE-Klausel entsprechen. Klassiker: nur veröffentlichte Artikel, nur offene Tickets, nur nicht-gelöschte Datensätze. Der Index wird kleiner, schneller und passt häufiger in den Buffer-Cache. Besonders wertvoll, wenn 95 Prozent Ihrer Zeilen einen Status haben, den Sie nie abfragen.
Trigram-Index für LIKE und ILIKE
Wenn das Frontend ILIKE '%suche%' schickt - wofür ein B-Tree nutzlos ist - rettet Sie pg_trgm. Die Extension legt einen GIN- oder GiST-Index über Drei-Buchstaben-Gramme und macht damit Substring-Suche indizierbar. Aktivieren Sie in jedem Projekt mit Live-Search-Feldern auf Namen, Titeln oder Tags.
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
use Illuminate\Support\Facades\DB;
return new class extends Migration
{
public function up(): void
{
// Composite-Index: zuerst Gleichheit, dann Sortierung
Schema::table('articles', function (Blueprint $table) {
$table->index(
['author_id', 'published_at'],
'articles_author_published_idx'
);
});
// Partial Index: nur veröffentlichte Einträge
DB::statement('CREATE INDEX articles_published_only_idx
ON articles (published_at DESC)
WHERE published_at IS NOT NULL');
// Partial Index: nur offene Tickets
DB::statement("CREATE INDEX tickets_open_idx
ON tickets (assigned_to, created_at)
WHERE status = 'open'");
// Trigram für ILIKE-Suche auf customers.name
DB::statement('CREATE EXTENSION IF NOT EXISTS pg_trgm');
DB::statement('CREATE INDEX customers_name_trgm_idx
ON customers USING GIN (name gin_trgm_ops)');
// Volltext-Fulltext direkt über Laravel-Schema (Postgres-Default)
Schema::table('articles', function (Blueprint $table) {
$table->fullText('title', 'articles_title_fulltext_idx');
});
}
public function down(): void
{
DB::statement('DROP INDEX IF EXISTS articles_published_only_idx');
DB::statement('DROP INDEX IF EXISTS tickets_open_idx');
DB::statement('DROP INDEX IF EXISTS customers_name_trgm_idx');
Schema::table('articles', function (Blueprint $table) {
$table->dropFullText('articles_title_fulltext_idx');
$table->dropIndex('articles_author_published_idx');
});
}
};
Artikel teilen
Hat das geholfen? Wir machen Audit, Workshop oder Coaching auch live
Pakete direkt im Shop. Unser Standard-Tagessatz liegt bei 1.500 € netto.