PostgreSQL und SQLAlchemy 2
Wann verwenden
- Entwerfen von Tabellen, Constraints und Indizes.
- Schreiben von SQLAlchemy-2-Modellen und -Abfragen, synchron oder asynchron.
- Eine Abfrage ist langsam, Locks häufen sich an oder Verbindungen gehen aus.
- Schreiben einer Alembic-Migration, insbesondere für Tabellen mit Live-Traffic.
Für destruktive Datenänderungen zusätzlich naki-safety anwenden. Falls das Projekt eine Regel-Fähigkeit hat (z. B. naki-myapp), dieser folgen.
Schema-Regeln
- Primärschlüssel:
bigint generated always as identityoder UUID (wenn verfügbar v7, wegen Index-Lokalität). Keine fortlaufenden IDs öffentlich preisgeben, wenn Aufzählbarkeit relevant ist. - Typen:
timestamptz(niemalstimestampohne Zeitzone),textmit einemCHECKauf Länge stattvarchar(n), wenn Grenzen sich ändern könnten,numericfür Geldbeträge,jsonb(nichtjson),boolean not null default false. - Constraints sind die letzte Verteidigungslinie:
NOT NULLstandardmäßig,UNIQUE,CHECK, Fremdschlüssel mit expliziterON DELETE-Wahl. - Jeden Fremdschlüssel indexieren, der in Joins oder Kaskaden verwendet wird; PostgreSQL erledigt das nicht automatisch.
- Constraints und Indizes benennen über eine Namenskonvention in SQLAlchemy
MetaData, damit Alembic-Diffs stabil sind. - jsonb für wirklich variable Daten, nicht um Modellierung zu vermeiden. Abfrage-häufige Schlüssel werden zu echten Spalten.
- Soft Delete nur, wenn nötig: falls doch, partielle Indizes
WHERE deleted_at IS NULLhinzufügen.
Index-Regeln
| Abfragemuster | Index |
|---|---|
| Gleichheit und Bereich auf einer Spalte | B-tree |
WHERE a = ? AND b > ? ORDER BY b | zusammengesetzt (a, b), Gleichheitsspalten zuerst |
| Teilmenge von Zeilen (offene Elemente) | partiell WHERE status = 'open' |
lower(email) = ? | Ausdrucksindex auf lower(email) |
jsonb-Enthaltensein @> | GIN |
Volltext, Trigramm ILIKE '%x%' | GIN mit tsvector oder pg_trgm |
| riesige Append-Only-Zeitreihen | BRIN auf dem Zeitstempel |
- Jeder Index kostet Schreiboperationen und Speicherplatz. Nicht verwendete Indizes entfernen (
pg_stat_user_indexes.idx_scan = 0über einen langen Zeitraum). - Abdeckender Index mit
INCLUDE (...)für Index-Only-Scans bei heißen Leseoperationen.
Query-Tuning-Workflow
- Die echte Abfrage besorgen (
echooderlog_min_duration_statementaktivieren) und die echten Parameter. EXPLAIN (ANALYZE, BUFFERS)auf einem produktionsähnlichen Datensatz ausführen. Plans von einer 100-Zeilen-Entwicklungstabelle nie vertrauen.- Suchen nach:
Seq Scanauf großen Tabellen mit selektiven Filtern, Zeilenschätzung weit weg vom tatsächlichen Wert (veraltete Statistiken:ANALYZE), verschachtelten Schleifen über viele Zeilen, Sortierungen, die auf Festplatte auslagern, hohenshared read-Werten. - Die günstigste Ursache beheben: Index hinzufügen oder anpassen, Prädikat sargbar umschreiben, N+1 entfernen, per Keyset paginieren.
EXPLAIN ANALYZEerneut ausführen und Zeiten vergleichen.pg_stat_statementsverwenden, um die Abfragen mit der höchsten Gesamtzeit zu finden.
SQLAlchemy-2-Regeln
- Den 2.0-Stil verwenden:
select(),session.execute(),session.scalars(),Mapped[...]undmapped_column. - Eine Session pro Arbeitseinheit (Request, Job). Niemals eine Session über Threads oder Tasks hinweg teilen.
- N+1 vermeiden: Beziehungen explizit mit
selectinload(Sammlungen) oderjoinedload(viele-zu-eins) laden. In asynchronem Code löst lazy loading einen Fehler aus; setzen Sielazy="raise"auf Beziehungen, um es auch in synchronem Code zu erwischen. - Nur gebundene Parameter verwenden. Niemals Benutzereingaben in SQL-Strings einfügen, auch nicht in
text(). - Pool-Größe und
pool_pre_ping=Truesetzen; die Gesamtzahl der Verbindungen über alle App-Instanzen hinweg muss untermax_connectionsbleiben (PgBouncer verwenden, wenn viele Worker). - Transaktionen kurz halten; keine Netzwerkaufrufe an andere Dienste, während eine Transaktion gehalten wird.
from datetime import datetime
from sqlalchemy import ForeignKey, Index, String, select
from sqlalchemy.orm import DeclarativeBase, Mapped, mapped_column, relationship, selectinload
class Base(DeclarativeBase):
pass
class Incident(Base):
__tablename__ = "incidents"
id: Mapped[int] = mapped_column(primary_key=True)
status: Mapped[str] = mapped_column(String(16), index=True)
opened_at: Mapped[datetime]
alerts: Mapped[list["Alert"]] = relationship(back_populates="incident", lazy="raise")
__table_args__ = (Index("ix_incidents_open", "opened_at", postgresql_where="status = 'open'"),)
async def open_incidents(session: AsyncSession, limit: int) -> list[Incident]:
stmt = (
select(Incident)
.where(Incident.status == "open")
.order_by(Incident.opened_at.desc(), Incident.id.desc())
.limit(limit)
.options(selectinload(Incident.alerts))
)
return list((await session.scalars(stmt)).all())
Transaktionen und Locking
- Standard
READ COMMITTEDist für die meisten Arbeiten ausreichend.SELECT ... FOR UPDATEverwenden, um Zeilen zu sperren, die basierend auf dem Gelesenen geändert werden. - Für Warteschlangen:
FOR UPDATE SKIP LOCKEDermöglicht mehreren Workern, Jobs zu beanspruchen, ohne sich gegenseitig zu blockieren. - Upserts:
INSERT ... ON CONFLICT (key) DO UPDATEanstelle von erst lesen, dann einfügen (Rennbedingungen). lock_timeoutundstatement_timeoutfür App-Rollen setzen, damit eine schlechte Abfrage nicht alles blockieren kann.- Deadlocks entstehen durch Sperren von Zeilen in unterschiedlicher Reihenfolge; in einer konsistenten Reihenfolge sperren (nach ID sortieren).
Alembic und Änderungen ohne Ausfallzeit
Automatisch generierte Migrationen immer überprüfen; Autogenerate übersieht Umbenennungen (es generiert Drop und Add) sowie einige Typänderungen.
Expand and Contract für breaking changes:
- Expand: neue Spalte nullable hinzufügen (oder mit konstantem Default, was auf PG 11+ sofort geht), neue Tabelle, neuen Index
CONCURRENTLY. - Code deployen, der sowohl alt als auch neu schreibt.
- Backfill in Batches (z. B. 5k Zeilen pro Transaktion) außerhalb der Migration oder in einer separaten Datenmigration mit Fortschrittsprotokollierung.
- Code deployen, der neu liest.
- Contract: alte Spalte in einem späteren Release löschen.
Gefährliche Operationen auf großen Live-Tabellen:
| Operation | Sicherer Ansatz |
|---|---|
CREATE INDEX | CREATE INDEX CONCURRENTLY (Alembic: postgresql_concurrently=True innerhalb von with op.get_context().autocommit_block():) |
NOT NULL hinzufügen | CHECK (col IS NOT NULL) NOT VALID hinzufügen, VALIDATE CONSTRAINT, dann NOT NULL setzen |
| Fremdschlüssel hinzufügen | mit NOT VALID hinzufügen, dann VALIDATE CONSTRAINT |
| Spalte umbenennen | Expand and Contract, niemals direkt, während alter Code läuft |
| Spaltentyp ändern | Neue Spalte plus Backfill, nicht ALTER TYPE (schreibt Tabelle neu) |
SET lock_timeout = '5s'in Migrationen setzen, damit sie schnell fehlschlagen, anstatt sich hinter langen Transaktionen anzustellen.- Jede Migration hat ein funktionierendes
downgrade()oder einen schriftlichen Grund, warum es nicht möglich ist. - Migrationen auf einer Kopie realistischer Daten testen (up, down und wieder up).
Checkliste
-
timestamptz,NOT NULL-Defaults, benannte Constraints, indizierte Fremdschlüssel. - Abfragen parametrisiert; keine f-Strings in SQL.
- Kein N+1; Beziehungen explizit geladen.
- Langsame Abfragen mit
EXPLAIN (ANALYZE, BUFFERS)auf realistischen Daten überprüft. - Transaktionen kurz; Timeouts gesetzt.
- Migration überprüft, rückgängig machbar, wo nötig
CONCURRENTLY/NOT VALIDverwendet. - Backfills gebatched und fortsetzbar.
Häufige Fehler
OFFSET 100000-Paginierung; Keyset auf einem indizierten Sortierschlüssel verwenden.WHERE date(created_at) = ?zerstört den Index; Bereich auf der rohen Spalte verwenden.LIKE '%term%'ohne Trigram-Index.await session.commit()vergessen oder in einer Schleife pro Zeile committen.alembic revision --autogenerateausführen und ungelesen committen.- Eine Transaktion offen halten, während eine externe API aufgerufen wird.
- Zeitstempel in lokaler Zeit speichern.