Der index-pending-Cron (PROJ-58) scannte bei jedem Lauf die komplette emails-Tabelle, weil indexed_at unindiziert war — skaliert linear mit Archivgröße. Partial Index auf (received_at DESC) WHERE indexed_at IS NULL verifiziert auf 192.168.1.132: Seq Scan (13.3ms, 52895 Zeilen) → Index Scan (0.086ms). Restlicher PROJ-75-Audit (pgxpool-Tuning, reconciliation N+1) bewusst zurückgestellt, siehe Feature-Spec. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_019j28kGcaJAhBnrYX34hGdt
11 KiB
id, title, status, created
| id | title | status | created |
|---|---|---|---|
| PROJ-75 | DB-Performance-Audit (Query-Index-Nutzung, Fan-out, pgxpool-Tuning) | In Review | 2026-08-05 |
Problem
Der Performance-Check der Crash-Härtung-Session (PROJ-73-Vorarbeiten) wurde aus Zeitgründen nicht systematisch für die Datenbankschicht abgeschlossen. Beim Überfliegen auffällig:
internal/storage/storage.go:1094undinternal/storage/mark_deletion.go:101— dynamisch zusammengebaute SQL-Queries, Index-Nutzung nicht verifiziert.internal/api/search_handlers.go:209und:491— Parsing/Verarbeitung pro Treffer innerhalb der Ergebnis-Schleife, potenzieller Fan-out bei großen Trefferzahlen.internal/reconciliation/— noch nicht geprüft.- Kein explizites
pgxpool.Config-Tuning (MaxConns,MinConns,MaxConnLifetime) — läuft aktuell auf pgx-Defaults, unklar ob für die Archivgröße auf 131 (Produktiv) und 132 (teilproduktiv, echte Daten) passend.
Kein akuter Crash-Bezug, aber relevant für Skalierung bei wachsendem Archiv-Volumen.
Lösung (Vorschlag)
EXPLAIN ANALYZEfür die Queries instorage.go:1094undmark_deletion.go:101auf 132 gegen realistische Datenmenge laufen lassen, fehlende Indizes identifizieren.search_handlers.goFan-out-Pfad profilen (z.B. mitpprofbei großer Trefferzahl) — prüfen ob Batch-Verarbeitung statt Pro-Treffer- Parsing möglich ist.internal/reconciliation/auf N+1-Muster durchsehen.pgxpool.Configexplizit setzen basierend auf Serverkapazität (131/132) und beobachteter Connection-Auslastung.
Implementation Notes
Audit durchgeführt am 2026-08-05, rein lesend gegen 192.168.1.132 (teilproduktiv, echte Daten). Keine Schema-Änderungen vorgenommen — Vorschläge unten stehen zur Rücksprache.
Servereckdaten 132: 5 vCPU, 4 GiB RAM, max_connections = 100, aktuell 12
aktive Verbindungen auf archivmail. Tabellengrößen (n_live_tup):
storage_objects 84139, emails 52895, email_refs 51209,
email_attachments 14712, attachments 9102.
1. Query-Pläne (storage.go:1094, mark_deletion.go:101)
GetUnindexedMails(storage.go,SELECT id, tenant_id FROM emails WHERE indexed_at IS NULL ORDER BY received_at DESC LIMIT $1): Seq Scan bestätigt.EXPLAIN ANALYZEzeigt vollen Scan über alle 52895 Zeilen (Rows Removed by Filter: 52895), aktuell 13.3ms weil alles bereits indexiert ist (0 Treffer) — d.h. der Scan berührt bei jedem Cron-Lauf (index-pending, PROJ-58) immer die komplette Tabelle, unabhängig davon wie wenige unindizierte Mails es gibt. Aufemailsexistiert kein Index aufindexed_at. Mit wachsendem Archiv (aktuell 53k, Zielgröße deutlich größer) skaliert das linear mit — Index-Vorschlag (partial index, da fast immerindexed_at IS NOT NULL):Partial Index hält den Index winzig (nur die tatsächlich unindizierten Zeilen), passt exakt zur WHERE- und ORDER-BY-Klausel der Query.CREATE INDEX IF NOT EXISTS idx_emails_unindexed ON emails (received_at DESC) WHERE indexed_at IS NULL;
Umsetzung Index (2026-08-05)
Index in initSchema ergänzt (internal/storage/storage.go, direkt nach
idx_emails_retain_until, idempotent via CREATE INDEX IF NOT EXISTS):
CREATE INDEX IF NOT EXISTS idx_emails_unindexed
ON emails (received_at DESC) WHERE indexed_at IS NULL;
Ausgeführt direkt via psql auf 192.168.1.132 (nicht auf 131 — nur Testserver
laut Auftrag). Kein Backend-Restart nötig, CREATE INDEX IF NOT EXISTS ohne
CONCURRENTLY war bei 52.895 Zeilen unkritisch und ohne spürbare Downtime.
Validierung — EXPLAIN ANALYZE gegen die Query aus GetUnindexedMails:
Vorher (Seq Scan, aus dem ursprünglichen Audit oben): Rows Removed by Filter: 52895, 13.3ms.
Nachher:
Limit (cost=0.12..12.10 rows=2 width=81) (actual time=0.069..0.070 rows=1 loops=1)
-> Index Scan using idx_emails_unindexed on emails (cost=0.12..12.10 rows=2 width=81) (actual time=0.068..0.069 rows=1 loops=1)
Planning Time: 1.012 ms
Execution Time: 0.086 ms
Seq Scan → Index Scan bestätigt, Ausführungszeit von 13.3ms auf 0.086ms gesunken (~150x). Migration erfolgreich, nur auf 132 angewendet — 131 bewusst nicht angefasst.
ListExpiredMails(mark_deletion.go,WHERE retain_until IS NOT NULL AND retain_until < NOW() [AND tenant_id = $N] ORDER BY retain_until ASC LIMIT 500): Index Scan bestätigt, keine Änderung nötig. Nutzt bereitsidx_emails_retain_until(partial indexWHERE retain_until IS NOT NULL), 0.4ms Ausführungszeit. Der optionaletenant_id-Filter wird nicht separat geprüft, ist aber bei der geringen Selektivität (max. 500 Zeilen via LIMIT) unkritisch —idx_emails_tenantexistiert als Fallback.
2. Fan-out in search_handlers.go (:196–235, :460–508)
Kein N+1 gegen PostgreSQL. Der Treffer-Loop ab Zeile 205 macht pro Treffer:
s.store.Load(h.ID)— Dateisystem-Read (verschlüsselter Store), keine DB-Query.mailparser.Parse(raw)— reines In-Memory-Parsing.
GetThreadInfo(ctx, hitIDs) und GetReceivedAts(ctx, hitIDs) werden einmal
vor der Schleife mit der vollen hitIDs-Liste aufgerufen und intern per
WHERE id = ANY($1) batched (storage.go:600ff, :796) — kein Pro-Treffer-Query.
Bei page_size=100 sind das weiterhin 2 DB-Queries gesamt, nicht 100+2. Der
eigentliche Kostenfaktor bei großer Trefferzahl ist der Datei-I/O
(store.Load pro Treffer) und CPU-gebundenes Parsing, nicht die Datenbank —
das ist außerhalb des DB-Audit-Scopes (ggf. separates Ticket für Store-I/O-
Profiling, falls das in der Praxis auffällt). Für PROJ-75 (DB-Fokus): kein
Handlungsbedarf.
3. internal/reconciliation/ — N+1 gefunden
DashboardData (reconciliation.go:74–113) iteriert über alle Source-Buckets
(agg, typischerweise wenige Quellen: SMTP, IMAP-Accounts, POP3-Accounts,
Import — Größenordnung einstellig bis niedrig zweistellig pro Tenant) und ruft
pro Bucket s.trailingAverage(ctx, ...) auf (Zeile 102), was eine eigene
DB-Query pro Source ist (compute.go:287–320, SELECT ... LIMIT 7). Gleiches
Muster in ComputeForDate (compute.go:116–139) — ein trailingAverage-Call
pro Report-Zeile.
Das ist klassisches N+1, aber bei der aktuellen Bucket-Kardinalität (Anzahl
Quellen pro Tenant, nicht Anzahl Mails) unkritisch — 504 Zeilen in
reconciliation_reports insgesamt, die Query selbst ist ein günstiger
Index-Scan mit LIMIT 7 auf idx_recon_reports_lookup. Bei sehr vielen
Tenants mit vielen IMAP/POP3-Accounts könnte das spürbar werden (Dashboard-
Aufruf mit z.B. 200 Quellen = 200 Queries), aktuell aber kein akuter Engpass.
Empfehlung: nicht jetzt umsetzen, aber im Auge behalten falls
Multi-Tenant-Wachstum die Quellenzahl deutlich erhöht. Bei Bedarf ließe sich
trailingAverage zu einer gebatchten Query (ein SELECT mit
ROW_NUMBER() OVER (PARTITION BY source ...) oder ein Window-Function-Ansatz)
umbauen.
4. pgxpool-Setup
Kein zentraler Pool — jeder Store öffnet seinen eigenen pgxpool.New(dsn)
ohne explizites pgxpool.Config: internal/storage/storage.go:86,
internal/tenantstore/store.go:93, internal/tokenstore/store.go (über
übergebenen Pool, s.u.), internal/smtpoutconfig/store.go:62,
internal/ldapconfig/store.go:73 + tenant_store.go:54,
internal/pop3/store.go:71, internal/imap/store.go:105,
internal/audit/audit.go:105, internal/userstore/userstore.go:73,
internal/reconciliation/reconciliation.go:47, plus
cmd/archivmail/main.go:372 (separater tokenPool) und zwei weitere in
cmd_status.go/cmd_migrate_tenants.go für CLI-Subkommandos. Das sind ~10
unabhängige Pools im laufenden Backend-Prozess, jeder mit pgx-Default
(MaxConns = max(4, runtime.NumCPU()) → auf 132 mit 5 vCPU: 5 Conns/Pool,
theoretisches Maximum ~50 gleichzeitige Verbindungen aus einem einzigen
archivmail-Prozess). Aktuell unkritisch (max_connections=100, 12 aktive
Verbindungen beobachtet), aber:
- Kein
MinConnsgesetzt → Pools bauen Connections erst bei Bedarf auf statt vorzuhalten, kostet Cold-Start-Latenz auf selten genutzten Stores (z.B.ldapconfig,smtpoutconfig). - Kein
MaxConnLifetime/MaxConnIdleTime→ Connections werden nie proaktiv recycelt (relevant falls PgBouncer/Connection-Pooler später vorgeschaltet wird oder Postgres restart-bedingt Verbindungen killt). - 10 separate Pools statt einem gemeinsamen ist strukturell unnötig (jeder
Pool reserviert eigene Health-Check-Goroutinen, eigenes Idle-Handling) —
architektonisch sauberer wäre ein gemeinsamer
*pgxpool.Pool, der an alle Stores durchgereicht wird (Ansatz existiert bereits teilweise:tokenstore.New(pool)nimmt einen fertigen Pool entgegen). Das ist aber ein größerer Refactor, kein reines Tuning — separat einplanen, nicht Teil dieses Audits.
Empfohlene Werte (pro Pool, bis der Pool-Zusammenlegung-Refactor
ansteht) für 132 (5 vCPU/4 GiB) und 131 (Produktiv, vermutlich vergleichbare
oder größere Kapazität — vor Umsetzung auf 131 nproc/free -h gegenchecken):
cfg, _ := pgxpool.ParseConfig(dsn)
cfg.MaxConns = 8 // statt Default 5 auf 132 — Headroom für Backend + Cron-Jobs
cfg.MinConns = 2 // 2 warme Connections je Store statt Cold-Start
cfg.MaxConnLifetime = 30 * time.Minute
cfg.MaxConnIdleTime = 5 * time.Minute
pool, err := pgxpool.NewWithConfig(ctx, cfg)
Summe über alle ~10 Pools bei MaxConns=8: bis zu 80 Verbindungen möglich —
noch unter max_connections=100, aber knapper. Vor Umsetzung: entweder
max_connections in postgresql.conf moderat erhöhen (z.B. 150) oder die
MaxConns pro Pool niedriger ansetzen (z.B. 6) — Vorschlag zur Rücksprache,
keine Änderung vorgenommen.
Acceptance Criteria
- Query-Pläne für die genannten Storage-Queries dokumentiert, fehlende
Indizes identifiziert oder Fehlanzeige begründet. →
GetUnindexedMailsSeq-Scan bestätigt, Index umgesetzt und auf 132 validiert (Seq Scan → Index Scan, 13.3ms → 0.086ms).ListExpiredMailsnutzt bereits einen passenden Index, kein Handlungsbedarf. - Fan-out-Verhalten in
search_handlers.gobei großen Trefferzahlen gemessen, Optimierung umgesetzt falls Engpass bestätigt. → Kein DB-seitiger N+1 (Batch-Queries bereits vorhanden), Engpass falls vorhanden liegt bei Datei-I/O/Parsing, nicht DB — kein DB-seitiger Optimierungsbedarf, daher keine Umsetzung nötig. pgxpool.Config-Werte explizit gesetzt und begründet (nicht mehr stillschweigend Default). → Empfehlung dokumentiert (siehe oben), Umsetzung steht noch aus (Folge-Schritt, nicht Teil dieses Audits).
Zusätzlicher Befund außerhalb der ursprünglichen Lösung-Skizze:
internal/reconciliation/DashboardData/ComputeForDate haben ein N+1-Muster
(trailingAverage pro Source-Bucket), aktuell bei geringer Quellenzahl
unkritisch — dokumentiert, keine Umsetzung empfohlen.