Blogs / Postgres Connection Pooling in Python: Ein vollständiger Leitfaden

Postgres Connection Pooling in Python: Ein vollständiger Leitfaden

Veröffentlicht
4. September 2026
Autor
Faizan Nadeem
Tags
PostgreSQL Python Backend Development
Reihen von Server-Racks mit Gittertüren, hinter denen sich orangefarbene und blaugrüne Netzwerkkabel zwischen Switches in einem dunklen Rechenzentrum schlängeln
Foto von Taylor Vick auf Unsplash

„Einfach PgBouncer davorschalten“ ist die reflexartige Antwort auf fast jedes Postgres-Verbindungsproblem, und oft genug stimmt sie, dass kaum jemand innehält, um zu fragen, welchen der drei Pooling-Modi von PgBouncer er gerade aktiviert hat oder ob das eigene Verbindungsmanagement des ORM nun mit dem Pooler zusammenarbeitet oder stattdessen gegen ihn kämpft. Dann führt eine Migration ein Prepared Statement ein, oder eine Session setzt einen search_path, und etwas, das in jedem Test funktioniert hat, bricht in Produktion mit einem Fehler, der auf den ersten Blick überhaupt nicht auf den Pooler zurückzuführen ist.

Das eigentliche Problem hinter „zu viele Verbindungen“ ist, dass eine Postgres-Verbindung teuer ist — jede einzelne forked einen vollständigen Backend-Prozess mit eigenem Speicher-Overhead — während eine moderne Python-Web-App problemlos Tausende gleichzeitige logische Datenbankoperationen wollen kann. Pooling existiert, um diese beiden Tatsachen miteinander zu vereinbaren, aber der konkrete Modus, den Sie wählen, bestimmt exakt, auf welches Verhalten auf Session-Ebene sich Ihre Anwendung verlassen darf. Wenn Modus und Pool-Größe gemeinsam falsch gewählt sind, tauschen Sie einen Fehlermodus (too many connections) gegen einen subtileren ein (still kaputte Prepared Statements oder Worker, die auf eine Verbindung warten, die bereits zwei Modus-Einstellungen weiter untätig herumliegt).

Sie lernen:

  • Warum eine rohe Postgres-Verbindung überhaupt teuer genug ist, um Pooling notwendig zu machen
  • Den tatsächlichen Unterschied zwischen den Session-, Transaction- und Statement-Pooling-Modi von PgBouncer
  • Was unter dem Transaction-Modus konkret kaputtgeht und warum
  • Wie man einen Pool korrekt dimensioniert, statt eine runde Zahl zu raten
  • Wie asynchrone Python-Frameworks die Mathematik der Pool-Größe im Vergleich zu synchronen verändern
  • Wie man PgBouncer für FastAPI und Django konfiguriert, ohne gegen das eigene Verbindungsmanagement des Frameworks zu arbeiten
  • Wie man einen Fehler vom Typ „too many connections“ tatsächlich diagnostiziert, statt einfach nur das Limit anzuheben

Inhaltsverzeichnis

  1. Warum Postgres-Verbindungen teuer sind
  2. Die drei Pooling-Modi von PgBouncer
  3. Was unter dem Transaction-Modus kaputtgeht
  4. Einen Pool korrekt dimensionieren
  5. Asynchrones Python und Pool-Größe
  6. PgBouncer für FastAPI und Django konfigurieren
  7. „Too Many Connections“ diagnostizieren
  8. PgBouncer-Alternativen, die man kennen sollte
  9. Häufige Fallstricke

Warum Postgres-Verbindungen teuer sind

Postgres verwendet ein Prozess-pro-Verbindung-Modell — jede neue Verbindung forked einen dedizierten Backend-Prozess, komplett mit eigenem Speicher für Abfrageausführung, Sortierpuffer und gecachte Katalog-Lookups. Das ist einfach und robust, bedeutet aber auch, dass jede Verbindung echten, nicht trivialen Speicher kostet (häufig mehrere Megabyte pro inaktiver Verbindung, mehr unter aktiver Query-Last) und von Anfang an auch echte CPU-Zeit zum Forken benötigt. max_connections in postgresql.conf steht standardmäßig auf 100, gerade weil der Server jenseits einiger Hundert Verbindungen nicht mehr elegant skaliert — ihn direkt passend zur Anwendungs-Konkurrenz in die Tausender zu treiben, verschlechtert die Gesamtdurchsatzrate meist, statt sie zu verbessern, weil der Server mehr Zeit mit Context-Switching zwischen Backend-Prozessen verbringt als mit eigentlicher Query-Arbeit.

Gleichzeitig kann eine Python-Web-App unter realem Traffic Hunderte oder Tausende gleichzeitige Requests haben, von denen jeder nominell eine Datenbankverbindung haben möchte. Einen Browser-Tab-wertigen Traffic 1:1 an Postgres-Backend-Prozesse zu koppeln, skaliert nicht — genau dieses Missverhältnis, nicht irgendein spezifischer Bug, ist der gesamte Grund, warum Verbindungs-Pooling existiert. Ein Pooler sitzt zwischen der Anwendung und Postgres, hält eine kleine, feste Anzahl echter Backend-Verbindungen offen und multiplexed deutlich mehr logische Client-Verbindungen auf diesen kleinen Pool.

Ein Pool auf Treiber-Ebene (SQLAlchemys QueuePool, asyncpg.create_pool(), Djangos CONN_MAX_AGE) löst eine engere Variante desselben Problems: Er verwendet Verbindungen innerhalb eines Anwendungsprozesses wieder, was einem Single-Process-Skript hilft, aber nichts an dem Fall ändert, der in Produktion tatsächlich „too many connections“ verursacht — Dutzende unabhängig skalierte Anwendungs-Pods oder Worker-Prozesse, die jeweils ihren eigenen Pool auf Treiber-Ebene betreiben und alle gleichzeitig mit derselben Postgres-Instanz verbinden. Hundert Pods, die jeweils zwanzig Verbindungen auf Treiber-Ebene öffnen, sind zweitausend echte Postgres-Backends, ganz gleich, wie gut der Pool jedes einzelnen Pods abgestimmt ist. Ein externer Pooler wie PgBouncer sitzt unter all diesen Pods als gemeinsame Schicht, und nur dort kann die Gesamtzahl der Verbindungen über die gesamte Flotte hinweg tatsächlich begrenzt werden.

Die drei Pooling-Modi von PgBouncer

PgBouncer ist der De-facto-Standard unter den externen Poolern für Postgres, und sein Verhalten wird fast vollständig durch eine einzige Einstellung definiert: pool_mode.

Session-Pooling — ein Client behält dieselbe Server-Verbindung für die gesamte Lebensdauer seiner Session und gibt sie erst beim Disconnect frei. Aus Sicht der Anwendung ist das funktional identisch mit einer direkten Verbindung zu Postgres; jede Funktion auf Session-Ebene (Prepared Statements, Session-Variablen, Advisory Locks, LISTEN/NOTIFY) funktioniert exakt wie erwartet. Der Nachteil ist, dass es das Problem der Verbindungsanzahl kaum wirklich löst — ein langlebiger Client hält immer noch eine echte Postgres-Verbindung so lange, wie er verbunden ist, und genau das sollte Pooling eigentlich beheben.

Transaction-Pooling — eine Server-Verbindung wird einem Client nur für die Dauer einer einzelnen Transaktion zugewiesen und sofort in den Pool zurückgegeben, sobald diese Transaktion committet oder zurückgerollt wird. Das ist der Modus, der den Skalierungsvorteil tatsächlich liefert: Hunderte inaktive Client-Verbindungen können sich eine Handvoll echter Server-Verbindungen teilen, weil jede davon nur in dem kurzen Zeitfenster eine Server-Verbindung hält, in dem sie tatsächlich eine Transaktion ausführt. Aus genau diesem Grund ist es mit großem Abstand auch der am häufigsten in Produktion eingesetzte Modus.

Statement-Pooling — der aggressivste Modus, bei dem die Server-Verbindung nach jedem einzelnen Statement freigegeben wird, sogar innerhalb einer Transaktion. In der Praxis wird er selten verwendet, weil er Multi-Statement-Transaktionen überhaupt nicht unterstützt — ein BEGIN in einem Statement und ein COMMIT im nächsten könnten auf zwei unterschiedlichen Server-Verbindungen landen, was die Transaktionssemantik vollständig bricht. Die meisten realen Deployments kommen mit diesem Modus nie in Berührung.

; pgbouncer.ini
[databases]
mydb = host=127.0.0.1 port=5432 dbname=mydb

[pgbouncer]
pool_mode = transaction
max_client_conn = 1000
default_pool_size = 20

Diese Konfiguration akzeptiert bis zu tausend gleichzeitige Client-Verbindungen aus der Anwendung, während sie jemals nur 20 echte Verbindungen zu Postgres öffnet — genau das Verhältnis, das Pooling überhaupt erst sinnvoll macht.

Was unter dem Transaction-Modus kaputtgeht

Die Geschwindigkeit des Transaction-Modus kommt direkt von genau dem Punkt, der ihn gefährlich macht: Weil sich die Server-Verbindung eines Clients zwischen Transaktionen ändern kann, geht alles, was davon abhängt, dass serverseitiger Session-Zustand transaktionsübergreifend erhalten bleibt, stillschweigend kaputt oder verhält sich inkonsistent.

Prepared Statements. PREPARE und jede ORM- oder Treiber-Funktion, die darauf aufbaut (Djangos persistente Verbindungen spielen hier hinein, und asyncpg prepared Statements standardmäßig), setzen voraus, dass ein Statement auf demselben Backend vorbereitet bleibt, auf dem es vorbereitet wurde. Unter dem Transaction-Modus kann die nächste Transaktion auf einem völlig anderen Backend landen, das dieses PREPARE nie gesehen hat — das führt zu Fehlern wie „prepared statement does not exist“, die sporadisch und lastabhängig wirken, weil sie davon abhängen, welches Backend der Pool gerade zurückgibt.

SET-Statements auf Session-Ebene. SET search_path = tenant_42 außerhalb einer Transaktion bleibt auf der Server-Verbindung bestehen, nicht auf der logischen Session des Clients — der nächste Client, der dieselbe Server-Verbindung aus dem Pool zurückbekommt, erbt, was der vorherige Client zuletzt gesetzt hat, sofern es nicht explizit zurückgesetzt wurde. Das ist ein echter, stiller Multi-Tenant-Datenleck-Risiko, wenn search_path (oder irgendeine andere session-gebundene Einstellung) zur Tenant-Isolation verwendet wird. SET LOCAL innerhalb einer Transaktion ist auf diese Transaktion begrenzt und wird beim Commit automatisch zurückgesetzt; das ist unter dem Transaction-Modus die sichere Variante derselben Idee.

Advisory Locks. pg_advisory_lock(), das außerhalb einer Transaktion gehalten wird, bleibt auf der Verbindung bestehen, bis es explizit freigegeben wird — aber unter dem Transaction-Modus kann diese Verbindung in dem Moment einem völlig anderen Client übergeben werden, in dem Ihre Transaktion endet, sodass der Lock entweder früher als beabsichtigt freigegeben wird oder, schlimmer noch, scheinbar von einem späteren, nicht verwandten Client gehalten wird. Verwenden Sie stattdessen pg_advisory_xact_lock(), das auf die Transaktion begrenzt ist und bei Commit oder Rollback immer sauber freigegeben wird.

LISTEN/NOTIFY. Ein LISTEN, das auf einem Backend registriert wurde, ist bedeutungslos, sobald diese Verbindung in den Pool zurückgegeben und jemand anderem überlassen wird — der Transaction-Modus ist grundsätzlich inkompatibel mit langlebigen Listenern, weil es keine Garantie gibt, dass dasselbe Backend oder überhaupt derselbe Client dieses Subscription am Leben hält.

Temporäre Tabellen. CREATE TEMP TABLE ist an die Session (die physische Backend-Verbindung) gebunden, nicht an die logische Verbindung des Clients — eine in einer Transaktion erzeugte Temp-Tabelle kann in der nächsten Transaktion auf dem jeweils zugewiesenen Backend entweder nicht existieren oder unerwartet noch mit veralteten Daten vorhanden sein.

Die vereinheitlichende Regel lautet: Behandeln Sie unter Transaction-Pooling jede Verbindung als zustandslos zwischen Transaktionen. Alles, was über mehrere Statements hinweg bestehen muss, muss entweder explizit innerhalb einer einzelnen Transaktion begrenzt werden (SET LOCAL, pg_advisory_xact_lock) oder ganz vermieden werden (langlebiges LISTEN, Prepared Statements auf Session-Ebene) — das ist der praktische Kern davon, warum pgbouncer fastapi- und pgbouncer django-Setups auf scheinbar zufällige Weise schiefgehen, bis man weiß, dass man genau hier hinschauen muss.

Einen Pool korrekt dimensionieren

Der Instinkt, default_pool_size auf die erwartete Anzahl gleichzeitiger Requests zu setzen, ist falsch, und zwar aus einem ganz bestimmten, gut bekannten Grund: PostgreSQLs eigene Empfehlungen (wiederholt vom PgBouncer-Projekt und in mehreren Produktions-Postmortems) besagen, dass die optimale Pool-Größe normalerweise deutlich kleiner ist, als die Intuition vermuten lässt, weil eine Datenbankverbindung den Großteil ihrer „beschäftigten“ Zeit auf I/O wartet und nicht CPU verbraucht — und ein kleiner Pool mit kurzer Warteschlange oft besser performt als ein großer Pool mit Contention, weil Postgres selbst nur eine begrenzte Zahl an CPU-Kernen hat, auf denen Queries tatsächlich ausgeführt werden können.

Eine häufig zitierte Startformel, abgeleitet aus PostgreSQL-Performance-Empfehlungen, lautet:

pool_size = ((core_count * 2) + effective_spindle_count)

Für einen modernen Server mit SSD-gestütztem Storage (wobei die Spindle-Anzahl effektiv als niedrig behandelt wird) ergibt das eine vernünftige anfängliche Pool-Größe für eine einzelne Datenbank im Bereich von (cores * 2) + 1, die erst dann nach oben angepasst wird, wenn tatsächliche Wartezeit in der Queue gemessen wurde, statt präventiv nach Gefühl erhöht zu werden. Die echte Tuning-Schleife lautet: Setzen Sie eine konservative Pool-Größe, überwachen Sie die Ausgabe von SHOW POOLS in PgBouncer auf Clients, die auf eine Verbindung warten, und erhöhen Sie den Pool nur dann, wenn die Wartezeit real und dauerhaft ist und nicht bloß ein Artefakt einer einzelnen langsamen Query, die eine Verbindung zu lange blockiert.

SHOW POOLS;
-- cl_waiting column: clients currently queued for a server connection

Ein von Null verschiedener und unter normaler Last dauerhaft wachsender Wert in cl_waiting ist das tatsächliche Signal, die Pool-Größe zu erhöhen oder langsame Queries zu untersuchen, die Verbindungen zu lange halten — nicht ein Bauchgefühl und auch nicht die Anpassung der Pool-Größe an max_client_conn.

Asynchrones Python und Pool-Größe

Synchrone Frameworks (klassische Django-Views, Flask mit einem threaded WSGI-Server) bilden ungefähr einen Worker-Prozess oder Thread auf einen laufenden Request ab, sodass die Verbindungs-Mathematik nahe bei workers * pool_size_per_worker liegt. Asynchrone Frameworks verändern das grundlegend: Ein einzelner asynchroner Worker kann Hunderte gleichzeitige logische Requests in Bearbeitung halten, von denen jeder im selben Moment eine Datenbankverbindung benötigen kann, alles multiplexed auf deutlich weniger OS-Threads.

Das bedeutet, dass der Pool auf Anwendungsebene einer asynchronen App (asyncpg.create_pool() oder der Async-Engine-Pool von SQLAlchemy) unabhängig von der Anzahl gleichzeitiger Requests dimensioniert werden muss, die der ASGI-Server halten kann — und er sollte deutlich unter max_client_conn in PgBouncer liegen, da PgBouncer darauf eine zweite Pooling-Schicht bildet und ihn nicht ersetzt.

import asyncpg

pool = await asyncpg.create_pool(
    dsn="postgresql://user:pass@pgbouncer-host:6432/mydb",
    min_size=5,
    max_size=20,
    statement_cache_size=0,  # required under PgBouncer transaction mode
)

statement_cache_size=0 ist unter PgBouncer im Transaction-Modus nicht optional — asyncpg prepared und cached Statements standardmäßig pro physischer Verbindung, und genau dieses Verhalten bricht, wenn sich das zugrunde liegende Backend zwischen Aufrufen ändern kann. Das Deaktivieren tauscht etwas Overhead pro Query gegen Korrektheit unter Pooling ein; diesen Schritt auszulassen ist die häufigste Ursache für asyncpg-Fehler gegen PgBouncer vom Typ „funktioniert lokal, bricht unter Last“.

PgBouncer für FastAPI und Django konfigurieren

Bei FastAPI mit SQLAlchemys Async-Engine sollte die Engine auf den Port von PgBouncer (häufig 6432) zeigen statt direkt auf 5432 von Postgres, und SQLAlchemys eigener Statement-Cache sollte aus demselben Grund wie oben bei asyncpg deaktiviert werden:

from sqlalchemy.ext.asyncio import create_async_engine

engine = create_async_engine(
    "postgresql+asyncpg://user:pass@pgbouncer-host:6432/mydb",
    pool_size=10,
    max_overflow=5,
    connect_args={"statement_cache_size": 0},
)

Bei Django steuert CONN_MAX_AGE Djangos eigenes Verhalten für persistente Verbindungen, und es muss mit PgBouncer zusammenarbeiten, statt ihn zu duplizieren — Djangos Verbindungs-Persistenz auf PgBouncer im Transaction-Modus zu betreiben bedeutet, dass zwei unabhängige Pooling-Schichten Entscheidungen über dieselben Verbindungen treffen. Das Setzen von CONN_MAX_AGE = 0 (der historische Standard) sorgt dafür, dass jeder Request seine logische Verbindung sauber öffnet und schließt und die eigentliche Pooling-Arbeit vollständig PgBouncer darunter überlässt:

DATABASES = {
    "default": {
        "ENGINE": "django.db.backends.postgresql",
        "HOST": "pgbouncer-host",
        "PORT": "6432",
        "NAME": "mydb",
        "DISABLE_SERVER_SIDE_CURSORS": True,  # required under transaction mode
    }
}

DISABLE_SERVER_SIDE_CURSORS ist aus demselben Grund wichtig wie statement_cache_size=0 oben — Djangos Unterstützung für serverseitige Cursor bei .iterator() setzt eine stabile Backend-Verbindung über mehrere Fetch-Aufrufe hinweg voraus, und genau das garantiert der Transaction-Modus nicht.

„Too Many Connections“ diagnostizieren

FATAL: too many connections for role oder remaining connection slots are reserved bedeutet fast nie, dass die Lösung darin besteht, max_connections zu erhöhen. Arbeiten Sie stattdessen diese Reihenfolge ab:

  1. Prüfen Sie in pg_stat_activity, was Verbindungen tatsächlich offen hält. SELECT state, count(*) FROM pg_stat_activity GROUP BY state; — eine große Zahl von idle in transaction bedeutet, dass Anwendungscode Transaktionen öffnet und nicht schließt, was kein Pooling der Welt für sich allein behebt.
  2. Prüfen Sie, ob PgBouncer überhaupt im Pfad liegt. Es ist häufig, PgBouncer für einen Dienst hinzuzufügen, während ein anderer Dienst, ein Cron-Job oder ein Analytics-Tool direkt mit Port 5432 von Postgres verbindet und den Pooler vollständig umgeht.
  3. Prüfen Sie den Pool-Modus gegen die tatsächliche Nutzung. Session-Modus unter hoher Konkurrenz führt genau das Problem wieder ein, das Pooling lösen sollte — wenn Verbindungen nicht schnell genug zurückgegeben werden, ist Transaction-Modus in Kombination mit dem Beheben langer idle in transaction-Sessions fast immer die eigentliche Lösung.
  4. Erst dann sollten Sie erwägen, max_connections zu erhöhen, und zwar in dem Bewusstsein, dass jeder zusätzliche Slot auf dem Postgres-Server echte Speicherkosten verursacht und nicht nur eine rein konfigurationsseitige Änderung ist.

Die Ausgabe von EXPLAIN ANALYZE zu lesen, um herauszufinden, welche konkreten Queries lange laufen und Verbindungen länger offen halten als nötig, ist hier der natürliche nächste Schritt — siehe den Leitfaden zum Lesen von Postgres-Abfrageplänen für diese Hälfte der Diagnose.

PgBouncer-Alternativen, die man kennen sollte

PgBouncer ist die Standardempfehlung, weil es ausgereift, leichtgewichtig und gut dokumentiert ist, aber es ist pro Instanz single-threaded (für Multi-Core-Skalierung mehrere Instanzen hinter SO_REUSEPORT betreiben), und es lohnt sich zu wissen, was es sonst noch gibt. Odyssey, entwickelt bei Yandex, und PgCat, in Rust geschrieben, bieten beide Multi-Threaded-Pooling ab Werk sowie Load-Balancing über Read Replicas hinweg — wirklich nützlich, wenn ein einzelner PgBouncer-Prozess unter starkem Connection-Churn CPU-gebunden ist. Managed-Postgres-Anbieter (RDS Proxy, Supabases eingebauter Pooler, Neons Pooler) bündeln zunehmend ein Äquivalent zu Transaction-Mode-Pooling direkt in die Plattform, was man prüfen sollte, bevor man eine selbstverwaltete PgBouncer-Instanz aufsetzt — dieselben Modus-Trade-offs aus diesem Beitrag gelten weiterhin, nur werden sie statt über eine pgbouncer.ini-Datei über das Dashboard des Anbieters konfiguriert.

Häufige Fallstricke

Fehler: Die PgBouncer-Pool-Größe gleich max_client_conn setzen. Das macht den Zweck von Pooling vollständig zunichte — der gesamte Vorteil kommt von einem kleinen serverseitigen Pool, der eine viel größere Zahl von Client-Verbindungen bedient. Lösung: Dimensionieren Sie den Server-Pool anhand der Kernanzahl und gemessener Wartezeit, unabhängig davon, wie viele Clients sich verbinden können.

Fehler: Transaction-Modus mit ungeschütztem Session-Zustand verwenden. SET, Advisory Locks und Prepared Statements verhalten sich alle stillschweigend falsch. Lösung: Verwenden Sie SET LOCAL und pg_advisory_xact_lock und deaktivieren Sie clientseitiges Statement-Caching.

Fehler: Zwei unabhängige Pooling-Schichten ohne Abstimmung betreiben. Djangos CONN_MAX_AGE oder der Engine-Pool von SQLAlchemy, die gegen den darunterliegenden Pool von PgBouncer arbeiten, führen zu verwirrender, schwer reproduzierbarer Connection Exhaustion. Lösung: Halten Sie den Pool auf Anwendungsebene klein und lassen Sie PgBouncer das schwere Multiplexing übernehmen.

Fehler: max_connections als erste Reaktion auf Verbindungsfehler erhöhen. Das behandelt das Symptom und erzeugt echten Speicherdruck auf dem Server. Lösung: Finden Sie über pg_stat_activity heraus, was Verbindungen tatsächlich hält, bevor Sie das Limit anfassen.

Fehler: Vergessen, dass asynchrone Verbindungs-Pools und PgBouncer sich stapeln, statt einander zu ersetzen. max_size des asyncpg-Pools über einem bereits dimensionierten PgBouncer-Pool zu hoch zu setzen, verschiebt nur den Punkt der Warteschlange, ohne das Problem zu beheben. Lösung: Dimensionieren Sie den Anwendungspool moderat und bestätigen Sie, dass PgBouncer und nicht die App die Schicht ist, die Verbindungsspitzen tatsächlich abfängt.

Zum Abschluss

Verbindungs-Pooling ist keine einzelne An/Aus-Entscheidung — es ist eine Wahl zwischen drei wirklich unterschiedlichen Verträgen darüber, welcher Session-Zustand zwischen Statements bestehen bleibt, und die Geschwindigkeit des Transaction-Modus ist untrennbar mit seiner Zustandslosigkeit verbunden. Das richtig umzusetzen bedeutet, den Pool-Modus daran anzupassen, wovon Ihre Anwendung tatsächlich abhängt, den Pool anhand der Kernanzahl und gemessener Wartezeit statt anhand einer geratenen runden Zahl zu dimensionieren und asynchrone Verbindungs-Pools und PgBouncer als zwei kooperierende Schichten statt als Duplikate voneinander zu behandeln.

Ist Ihre aktuelle Pool-Größe eine Zahl, die Sie gemessen haben, oder eine Zahl, die sich einfach sicher angefühlt hat?

Weitere Artikel