Skip to content
··7 Min. Lesezeit

Meine Datenbank war „korrekt". Sie war auch 296x zu langsam.

Ich entdeckte, dass meine Postgres-Datenbank 89 Foreign Keys hatte, aber null Indizes darauf – was Millisekunden-Abfragen zu 843-ms-Alpträumen machte und meinen Alpha-Launch fast zunichte machte.

  1. Oktober 2025. Meine SaaS-Anwendung war endlich funktionsvollständig. Neun Hauptfunktionen, Multi-Tenant-Architektur, progressives Lernen – alles funktionierte. Aber es war langsam. Wirklich langsam.

Dashboard-Abfragen krochen. Listen, die sofort laden sollten, brauchten mehrere Sekunden. Alpha-Tester begannen zu fragen: „Ist die Seite kaputt?"

Ich machte mir nicht nur Sorgen um Performance. Ich machte mir Sorgen ums Überleben.

Ich verbrachte zwei Tage damit, Abfragen zu optimieren, RLS-Richtlinien umzuschreiben, Caching hinzuzufügen. Nichts machte einen Unterschied. Die Frustration stieg – 5 Tage vor dem geplanten Alpha-Launch konnte ich nicht herausfinden, warum eine technisch korrekte Datenbank so schlecht performte.

Dann führte ich eine Diagnose-Abfrage aus, die mir den Magen umkehrte.

89 Foreign Keys. Null Indizes.

Dies ist die Geschichte eines Postgres-„Features", das niemand dir erzählt – der 2 Wochen, die meinen Alpha-Launch fast zunichte machten – und der 4-Minuten-Lösung, die alles rettete.

Die Einsätze: Wenn Langsam Sterben bedeutet

Forschung zeigt, dass jede 100ms Latenz 1% der Conversions kostet. Bei 2-3 Sekunden Ladezeit lieferte ich nicht nur eine schlechte Erfahrung – ich zerstörte die ersten Eindrücke, die meinen Alpha entscheiden würden.

Die unmittelbaren Einsätze:

- Alpha-Tester, die die Plattformstabilität infrage stellten

- Verlorene Glaubwürdigkeit bei frühen Nutzern, die mir ihr Vertrauen geschenkt hatten

- Bei anhaltender Lage bis zum vollen Launch: Branchenforschung deutet auf 40% Absprungrate bei 3-Sekunden-Ladezeiten hin

- Der Unterschied zwischen „das ist vielversprechend" und „das fühlt sich kaputt an"

- Datenbank-CPU bei 80-90% (teuer und wird schlimmer)

- 5 Tage vor geplantem Launch, komplett blockiert

Was diese 2 Wochen wirklich kosteten:

- 40+ Stunden Debugging langsamer Abfragen, Code-Profiling, RLS-Richtlinien beschuldigen

- 1 Woche verzögerter Launch (internen Termin verpasst)

- Verlorene Gelegenheit, statt dessen Features zu bauen

- Glaubwürdigkeitsschaden bei frühen Testern

Die technische Schuld wurde zu Geschäftsschuld. Und ich wusste nicht warum.

---

Die Untersuchung: Den langsamen Abfragen folgen

Ende Oktober hatte meine Plattform echte Komplexität: 30+ Tabellen, 80+ RLS-Richtlinien, Hunderte von Foreign-Key-Beziehungen. Einfache Operationen, die sofort sein sollten, waren schmerzhaft langsam – Listen laden, Dashboard-Daten abrufen, nach org\_id filtern.

Ich begann mit pg\_stat\_statements, um die Täter zu finden:

```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
ORDER BY mean_exec_time DESC
LIMIT 10;
```

Jede langsame Abfrage hatte dasselbe Muster – Filterung nach Foreign Keys:

```sql
SELECT * FROM user_data
WHERE org_id = '...' AND resource_id = '...';
```

Sollte sofort sein. Ich hatte diese Spalten. Ich hatte RLS-Richtlinien. Warum war das langsam?

Ich prüfte den Ausführungsplan:

```sql
EXPLAIN ANALYZE
SELECT * FROM user_data
WHERE org_id = 'abc-123' AND resource_id = 'xyz-789';
```

Das Ergebnis ließ meinen Magen sinken:

```
Seq Scan on user_data  (cost=0.00..2847.23 rows=15 width=1024)
  Filter: ((org_id = 'abc-123') AND (resource_id = 'xyz-789'))
  Rows Removed by Filter: 12,834
Execution Time: 843.271 ms
```

Sequential Scan. Postgres las jede einzelne Zeile in der Tabelle, dann filterte. Bei einer Abfrage, die Millisekunden dauern sollte.

Aber ich hatte Foreign Keys auf org\_id und resource\_id. Sollten die nicht indiziert sein?

---

Die Erkenntnis: Foreign Keys werden nicht automatisch indiziert

Hier ist, was ich an diesem Tag lernte – auf die harte Tour:

Postgres erstellt automatisch Indizes für PRIMARY KEYs und UNIQUE-Constraints.

Postgres erstellt KEINE automatischen Indizes für Foreign Keys.

Lass mich das noch einmal sagen, denn dieses einzelne Missverständnis kostete mich 2 Wochen meines Lebens:

FOREIGN KEYS ≠ INDIZES

Wenn du das schreibst:

```sql
CREATE TABLE user_data (
  id UUID PRIMARY KEY,  -- ✅ Wird automatisch indiziert
  org_id UUID REFERENCES organizations(id),  -- ❌ NICHT indiziert!
  resource_id UUID REFERENCES resources(id),  -- ❌ NICHT indiziert!
  created_at TIMESTAMPTZ
);
```

Erstellt Postgres die Foreign-Key-Constraint (referenzielle Integrität), erstellt aber **keinen** Index auf org\_id oder resource\_id.

Warum? Weil Postgres nicht annehmen kann, wie du die Daten abfragen wirst. Vielleicht filterst du nie nach Foreign Keys. Vielleicht joinst du immer in einer bestimmten Richtung. Also überlässt es dir die Entscheidung.

Das Problem? Ich wusste nicht, dass ich diese Entscheidung treffen musste. Ich nahm an, „Foreign Key" bedeute „für Abfragen indiziert". Das tut es nicht.

---

Die Diagnose: Wie schlimm war es?

Ich schrieb eine Abfrage, um jeden Foreign Key ohne Index zu finden:

```sql
SELECT
  c.conrelid::regclass AS table_name,
  a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
  );
```

Das Ergebnis traf mich wie ein Güterzug:

89 Zeilen.

89 Foreign Keys über 32 Tabellen. Null Indizes.

Jeder Join, jedes Filter, jede RLS-Richtlinien-Prüfung führte Full-Table-Scans durch. Kein Wunder, dass alles langsam war.

---

Der Fix: Die Index-Apokalypse

  1. Oktober 2025. 7:51 Uhr. Ich wusste, was ich tun musste.

Eine Migration. 89 Indizes. Ich nannte sie „Die Index-Apokalypse."

```sql
-- Die Index-Apokalypse-Migration
-- Zweck: Performance durch Indizierung aller Foreign-Key-Spalten beheben

-- Organisations-bezogene Tabellen
CREATE INDEX idx_users_org_id ON users(org_id);
CREATE INDEX idx_resources_org_id ON resources(org_id);
CREATE INDEX idx_documents_org_id ON documents(org_id);

-- Ressourcen-bezogene Tabellen
CREATE INDEX idx_user_data_resource_id ON user_data(resource_id);
CREATE INDEX idx_tasks_resource_id ON tasks(resource_id);

-- Multi-Spalten-Indizes für häufige Abfragemuster
CREATE INDEX idx_user_data_org_resource ON user_data(org_id, resource_id);

-- Soft-Delete-Abfragen
CREATE INDEX idx_resources_org_archived ON resources(org_id, archived_at);

-- Insgesamt: 89 Indizes über 32 Tabellen
```

Ich führte die Migration aus. Meine Hand schwebte buchstäblich über der Eingabetaste für einen Moment und fragte sich, ob ich gleich alles schlimmer machen würde. T.T

Migrationszeit: 4 Minuten.

---

Die Ergebnisse: Von Katastrophe zu Launch-Bereit

Vorher (Keine Indizes)

```sql
Seq Scan on user_data
  Execution Time: 843.271 ms
  Rows Removed by Filter: 12,834
```

Nachher (Mit Indizes)

```sql
Index Scan using idx_user_data_org_resource
  Execution Time: 2.847 ms
```

843ms → 2,8ms

Das ist 296x schneller für eine einzelne Abfrage.

Auswirkung in der Praxis:

- Dashboard-Ladezeit: 2-3 Sekunden → 120ms

- Ressourcenlisten: 1+ Sekunde → 45ms

- Datenabfragen: 850ms → 12ms

Durchschnittliche Verbesserung: 20-40x schneller. Einige komplexe Joins mit mehreren Foreign Keys? 100x schneller.

Der Unterschied war wie Tag und Nacht. Die Plattform wechselte von „Ist das kaputt?" zu „Wow, das ist schnell." :D

---

Warum das für Multi-Tenant-SaaS wichtig ist

Wenn du Multi-Tenant-SaaS mit Row-Level Security baust, ist das absolut kritisch.

RLS-Richtlinien laufen bei jeder einzelnen Abfrage:

```sql
CREATE POLICY resources_org_isolation ON resources
  USING (org_id = get_user_org_id());
```

Ohne einen Index auf org\_id erzwingt diese Richtlinie einen Sequential Scan bei jeder Abfrage. Selbst einfache SELECT-Statements werden schmerzhaft langsam.

Die Kosten: Multi-Tenant-Isolation ohne Indizes = teure Infrastruktur + langsame Nutzererfahrung = Churn.

Die Business-Lektion: Sicherheitsfunktionen ohne Performance-Optimierung sind nicht wirklich sicher – denn Nutzer verlassen die Plattform, bevor sie deine Sicherheit erleben.

---

Das Muster: Was indiziert werden sollte

Nach dieser teuren Lektion ist hier meine Checkliste für jede neue Tabelle:

Immer diese indizieren:

1. Foreign-Key-Spalten – Jede einzelne

2. Spalten in RLS-Richtlinien – Besonders org\_id in Multi-Tenant-Apps

3. Spalten in WHERE-Klauseln – Wenn du häufig danach filterst, indiziere es

4. Spalten in ORDER BY – Sortieren ohne Indizes killt Performance

Multi-Spalten-Indizes in Betracht ziehen:

```sql
-- Für: WHERE org_id = X AND resource_id = Y
CREATE INDEX idx_table_org_resource ON table(org_id, resource_id);
```

Nicht überindizieren: Jeder Index kostet Storage, Write-Performance und Wartung. Faustregel: Wenn er häufig in WHERE/JOIN/ORDER BY erscheint, indiziere ihn. Sonst nicht.

---

So prüfst du deine Datenbank jetzt

Führe diese Diagnose-Abfrage aus:

```sql
SELECT
  c.conrelid::regclass AS table_name,
  a.attname AS column_name
FROM pg_constraint c
JOIN pg_attribute a ON a.attnum = ANY(c.conkey) AND a.attrelid = c.conrelid
WHERE c.contype = 'f'
  AND NOT EXISTS (
    SELECT 1 FROM pg_index i
    WHERE i.indrelid = c.conrelid AND a.attnum = ANY(i.indkey)
  );
```

Wenn du Ergebnisse bekommst, hast du fehlende Indizes.

Dann finde deine langsamen Abfragen:

```sql
SELECT query, mean_exec_time, calls
FROM pg_stat_statements
WHERE mean_exec_time > 100
ORDER BY mean_exec_time DESC;
```

Verwende EXPLAIN ANALYZE bei den langsamen, um zu verifizieren, dass du Sequential Scans siehst.

---

Lessons Learned

1. Postgres-Annahmen sind gefährlich

Ich nahm an, Foreign Keys seien indiziert. Das sind sie nicht. Immer verifizieren.

2. RLS braucht Indizes

Row-Level Security ist nutzlos – eigentlich schlimmer als nutzlos – ohne richtige Indizes. Es fügt jeder Abfrage Overhead hinzu.

3. Multi-Tenant = org_id überall indizieren

In Multi-Tenant-Architektur erscheint org\_id in fast jeder Abfrage. Auf jeder Tabelle indizieren. Keine Ausnahmen.

4. Performance ist ein Business-Problem

Nutzer interessiert sich nicht dafür, dass dein SQL technisch korrekt ist, wenn die Seite 3 Sekunden zum Laden braucht. Schnelle Abfragen = bessere Conversion = Umsatz.

5. Indizes früh hinzufügen

Indizes zu leeren Tabellen hinzuzufügen ist sofort. Sie zu Tabellen mit Millionen von Zeilen hinzuzufügen dauert Stunden und sperrt die Tabelle. Tue es während der anfänglichen Entwicklung.

6. Alles messen

EXPLAIN ANALYZE vor und nach verwenden. Die Verbesserung mit Daten beweisen.

---

Deine Checkliste

Für jede neue Tabelle, die du erstellst:

```sql
CREATE TABLE new_table (
  id UUID PRIMARY KEY,
  org_id UUID REFERENCES organizations(id),
  resource_id UUID REFERENCES resources(id),
  created_at TIMESTAMPTZ,
  archived_at TIMESTAMPTZ
);

-- ✅ Foreign Keys indizieren
CREATE INDEX idx_new_table_org_id ON new_table(org_id);
CREATE INDEX idx_new_table_resource_id ON new_table(resource_id);

-- ✅ RLS-Spalten + häufige Filter indizieren
CREATE INDEX idx_new_table_org_archived ON new_table(org_id, archived_at);

-- ✅ Sortierspalten indizieren
CREATE INDEX idx_new_table_created ON new_table(created_at DESC);

-- ✅ RLS-Richtlinien erstellen
CREATE POLICY new_table_org_isolation ON new_table
  FOR ALL TO authenticated
  USING (org_id = get_user_org_id());
```

---

Abschließende Gedanken

89 fehlende Indizes. 2 Wochen Debugging. Eine Diagnose-Abfrage, um alle zu finden. 4 Minuten zum Beheben.

Die Ironie? Postgres gibt dir alle Werkzeuge zur Diagnose. Ich wusste nur nicht, wo ich schauen musste. Jetzt bekommt jede Tabelle, die ich erstelle, sofort Indizes – Foreign Keys, RLS-Spalten, Sortierfelder, alles.

Die 4-Minuten-Migration, die 89 Indizes hinzufügte, sparte Wochen zukünftiger Optimierungsarbeit und verhinderte eine Launch-Katastrophe.

Für Gründer: Performance ist nicht nur ein Engineering-Problem. Es ist ein Conversion-Problem, ein Retention-Problem und ein Glaubwürdigkeitsproblem. Die „technisch korrekte" Datenbank, die zu langsam zum Benutzen ist? Sie kostet dich Kunden. Investiere früh in Performance.

Für Entwickler: Postgres indiziert deine Foreign Keys nicht automatisch. Aber du solltest es. Deine Nutzer (und deine Datenbank-CPU) werden dir dankbar sein.

Hattest du jemals einen „technisch korrekt, aber schmerzhaft langsam"-Moment mit deiner Datenbank? Was war der Fix – und wie lange hat es gedauert, ihn zu finden?

Viele Grüße,

Chandler

Die STRAŦUM-Architektur-Serie: Diese Performance-Krise war das letzte Stück eines Multi-Tenancy-Puzzles, das begann mit dem Aufbau von Multi-Tenancy an Tag 2, fortsetzte mit einem vollständigen Schema-Neubau an Tag 67 und die Behebung von 31 leeren Bildschirmen durch verlorenen Navigationskontext umfasste.

---

Baust du Multi-Tenant-SaaS mit Postgres? Ich lernte diese Lektion auf die harte Tour, damit du es nicht musst. Prüfe jetzt deine Datenbank, bevor deine Nutzer es bemerken.

Alpha-Zugang beantragen unter https://stratum.chandlernguyen.com/request-invitation

---

Lerne immer noch, dass „es funktioniert" und „es funktioniert schnell" verschiedene Ziele sind – und nur eines davon wird geshipped.

---

Weiterlesen

Mein Weg
Vernetzen
Sprache
Einstellungen