Datenbankverbindungspool-Berechnung: Warum deine idle Symfony-Worker den PostgreSQL-Durchsatz leise sabotieren
Ein B2B-SaaS-Team, mit dem wir letztes Jahr gearbeitet haben, hatte eine Geschichte, die fast identisch mit mindestens vier anderen aus unserer Kundenhistorie war. Sie starteten eine Product-Hunt-Kampagne, der Traffic verdreifachte sich innerhalb von vierzig Minuten, und ihre Symfony-Anwendung begann PostgreSQL-Fehler zu werfen: FATAL: remaining connection slots are reserved for non-replication superuser connections. Das Merkwürdigste? Ihr Monitoring zeigte vierzig idle php-fpm-Worker im pm.status, die nichts zu tun hatten. Die Anwendung stand nicht unter CPU-Druck. Der Datenbankserver hatte ausreichend RAM. Der PostgreSQL-Verbindungspool war schlicht voll, und niemand hatte je die Rechnung aufgemacht.
Dieser Artikel führt durch diese Mathematik, erklärt, warum die Zahlen die meisten Teams überraschen, beschreibt die PgBouncer-Konfiguration, die für Doctrine-Nutzer eine stille Falle darstellt, und liefert dir drei pg_stat_activity-Queries, die auf das Grafana-Board jedes PHP-Teams gehören.
Die Arithmetik, die niemand aufschreibt
Die Dimensionierung des PostgreSQL-Verbindungspools fühlt sich wie DevOps-Zeremonie an, bis du die Decke erreichst. Ab diesem Punkt wird sie zum dringendsten Engineering-Problem im Raum. Die Formel ist unkompliziert, sobald du jede Komponente auflistest, die eine Verbindung öffnet:
peak_connections =
(php-fpm pm.max_children)
+ (messenger_consumers × workers_per_consumer)
+ (scheduler_workers)
+ (cron_processes)
+ (monitoring_and_admin_connections)
+ (pg_reserved_superuser_slots)
PostgreSQL verwendet standardmäßig max_connections = 100, mit drei Slots, die standardmäßig für Superuser-Verbindungen reserviert sind. Das lässt 97 für deine Anwendung übrig. Ein typisches Symfony-Produktions-Setup sieht etwa so aus:
- 60 php-fpm-Worker (
pm = dynamic,pm.max_children = 60) - 10 Messenger-Consumer (2 Transports × 5 Worker je)
- 2 Scheduler-Prozesse
- 5 lang laufende Cron-Befehle
- 3 Monitoring-Verbindungen (Datadog-Agent, eine DBA-Query-Session, eine Read-Replica-Lag-Prüfung)
Das sind 80 Verbindungen im Grundbetrieb - bereits 82 % der Kapazität - bevor ein einziger Spike auftritt. Wenn der Traffic sich verdoppelt und php-fpm alle sechzig Children gleichzeitig hochfährt und ein Marketing-Cron im selben Zeitfenster startet, bist du bei 102. PostgreSQL beginnt, Verbindungen abzulehnen. Die vierzig "idle" Worker, die du in der Prozessliste siehst, sind diejenigen, die sich eine Millisekunde früher verbunden haben; die anderen kamen nie durch.
Warum Messenger-Worker mehr schaden als php-fpm
php-fpm-Worker halten eine Doctrine-Verbindung für die Dauer jeder Anfrage offen und geben sie frei, wenn das Script beendet wird (oder der Prozess eine neue Anfrage bearbeitet - der Verbindungslebenszyklus hängt davon ab, ob persistente Verbindungen aktiviert sind). Im normalen Symfony-Betrieb öffnet jeder Worker beim ersten Datenbankzugriff eine Verbindung, und Doctrine schließt sie, wenn der Worker-Prozess beendet wird oder wenn du explizit $em->getConnection()->close() aufrufst.
Symfony-Messenger-Worker sind eine ganz andere Kategorie. Jeder Worker-Prozess läuft in einer Schleife und hält seine Datenbankverbindung unbegrenzt offen. Das ist by design - die Verbindung wird benötigt, um den Transport abzufragen oder Handler auszuführen. Die Konsequenz ist, dass jeder Messenger-Worker dauerhaft einen Slot in PostgreSQLs Verbindungstabelle belegt, unabhängig davon, ob er gerade eine Nachricht verarbeitet.
Wenn du fünf Transports hast und drei Worker pro Transport unter Supervisor betreibst, sind das fünfzehn permanente Verbindungen, die nie freigegeben werden. Hochgerechnet auf Staging, Produktion und eine DR-Umgebung wächst diese Zahl schnell. Die Lösung beginnt damit, diese sichtbar zu machen, indem du application_name in deiner Doctrine-Konfiguration setzt:
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(DATABASE_URL)%'
options:
!php/const PDO::ATTR_EMULATE_PREPARES: true
default_table_options:
charset: utf8mb4
# application_name setzen, damit pg_stat_activity deine Worker identifiziert
driver_options:
application_name: 'sf-messenger-worker'
Du kannst das pro Umgebung oder pro Messenger-Transport mit einer separaten DATABASE_URL mit angehängtem ?application_name=sf-worker-async überschreiben. Sobald das eingerichtet ist, werden deine Monitoring-Queries aussagekräftig, anstatt eine undifferenzierte Wand anonymer PHP-Verbindungen zu zeigen.
PgBouncer Transaction-Pooling und die Doctrine-Prepared-Statement-Falle
PgBouncer ist die Standardlösung, um die Anzahl der echten Verbindungen zu reduzieren, die PostgreSQL verwalten muss. Im Session-Pooling-Modus ist es für Doctrine funktional transparent - eine Client-Session bekommt für ihre Lebensdauer eine Server-Verbindung, sodass Prepared Statements ohne Änderungen funktionieren. Das Problem: Session-Pooling reduziert bei gleichzeitiger Last keine Verbindungen; es fügt nur einen Proxy-Hop hinzu.
Transaction-Pooling-Modus ist dort, wo PgBouncer seinen Nutzen entfaltet: Eine Server-Verbindung wird nur während einer aktiven Transaktion gehalten und dann in den Pool zurückgegeben. Für eine php-fpm-Anwendung, bei der die meisten Anfragen kurze, diskrete Transaktionen haben, kann das deine echten PostgreSQL-Verbindungen um 80 % reduzieren. Es schafft aber eine kritische Inkompatibilität mit Doctrine.
Doctrine DBAL verwendet standardmäßig Prepared Statements. Wenn PgBouncer mehrere Client-Sessions auf dieselbe Server-Verbindung multiplext (über verschiedene Transaktionsgrenzen hinweg), können Prepared Statements, die in einem Session-Kontext erstellt wurden, einem anderen zu gehören scheinen, was zu Fehlern führt wie:
SQLSTATE[42P05]: Duplicate prepared statement: ERROR: prepared statement "doctrine_0" already exists
Oder schlimmer: Das Statement wird stillschweigend gegen den falschen Parameterkontext ausgeführt. Es gibt zwei zuverlässige Lösungen:
Option A - Server-seitige Prepared Statements in Doctrine deaktivieren (empfohlen für PgBouncer < 1.21):
# config/packages/doctrine.yaml
doctrine:
dbal:
url: '%env(DATABASE_URL)%'
options:
!php/const PDO::ATTR_EMULATE_PREPARES: true
PDO::ATTR_EMULATE_PREPARES weist PDO an, das Prepared-Statement-Verhalten in der Client-Bibliothek zu simulieren, anstatt PREPARE/EXECUTE an PostgreSQL zu senden. Der Performance-Einfluss ist für typische OLTP-Queries vernachlässigbar; der Hauptkompromiss ist, dass die Parametertypinferenz vom Server auf den Client verlagert wird.
Option B - PgBouncer's eingebautes Prepared-Statement-Tracking aktivieren (PgBouncer >= 1.21):
; pgbouncer.ini
[pgbouncer]
prepared_statements = on
max_prepared_statements = 200
PgBouncer 1.21 führte server-seitiges Tracking von Prepared Statements ein, das den Verbindungswechsel überlebt. Wenn du eine aktuelle Version betreibst, ist das die sauberere Lösung - sie bewahrt das vollständige server-seitige Type-Checking, während Transaction-Pooling möglich wird. Verifiziere die Version, bevor du dich darauf verlässt: Ältere Ubuntu-LTS-Repositories liefern immer noch PgBouncer 1.17.
Die drei Queries, die jedes PHP-Team auf Grafana braucht
Verbindungspool-Probleme sind unsichtbar, bis sie zum Notfall werden. Diese drei Queries, die gegen deine Produktions-PostgreSQL-Instanz alle dreißig Sekunden ausgeführt werden, machen die Probleme sichtbar, bevor es deine Nutzer tun.
Query 1 - Verbindungsinventar nach Anwendung und Status:
SELECT
application_name,
state,
count(*) AS connections
FROM pg_stat_activity
WHERE datname = current_database()
GROUP BY application_name, state
ORDER BY connections DESC;
Sobald du application_name auf jeder Komponente gesetzt hast (php-fpm, Messenger-Worker, Cron, Monitoring), zeigt diese Query dir sofort, wer Pool-Slots verbraucht und in welchem Zustand. Eine idle in transaction-Zeile mit hoher Anzahl ist ein rotes Flag - sie bedeutet, dass Transaktionen zu lange offen gehalten werden und Verbindungen verschwenden, die freigegeben werden könnten.
Query 2 - Verbindungsauslastung in Prozent:
SELECT
count(*) AS active_connections,
max_conn AS max_connections,
round(count(*) * 100.0 / max_conn, 1) AS utilisation_pct
FROM pg_stat_activity,
(SELECT setting::int AS max_conn
FROM pg_settings
WHERE name = 'max_connections') AS s
WHERE pg_stat_activity.datname = current_database()
GROUP BY max_conn;
Setze einen Grafana-Alert bei 70 % Auslastung. Bis du 85 % erreichst, reicht die verbleibende Kapazität nicht mehr aus, um einen Traffic-Spike abzufedern, und du musst handeln, bevor das Problem kommt, nicht während es da ist.
Query 3 - Idle-in-Transaction-Leak-Detektor:
SELECT
pid,
application_name,
state,
now() - query_start AS duration,
left(query, 100) AS last_query
FROM pg_stat_activity
WHERE state = 'idle in transaction'
AND datname = current_database()
AND query_start < now() - interval '5 minutes'
ORDER BY duration DESC;
Ein Doctrine-Entity-Manager, der über einen langsamen externen API-Aufruf hinweg offen bleibt, ein Messenger-Handler, der vor einem HTTP-Request eine Transaktion öffnet und beim Fehler nicht committed, ein CLI-Befehl, der eine Import-Schleife für Performance in einer einzigen Transaktion kapselt - all das erscheint hier. Jedes davon hält eine Verbindung für seine Dauer als Geisel. Löse einen Alert bei jedem Ergebnis aus, das älter als fünf Minuten ist.
Alles zusammen: Eine Dimensionierungscheckliste
Bevor du max_connections auf dem PostgreSQL-Server anfasst (eine Erhöhung über 200 hinaus beginnt, erheblich Shared Memory zu verbrauchen und die Planungsperformance zu verschlechtern), arbeite diese Checkliste durch:
- Alle Verbindungsquellen aufzählen mit der obigen Formel und Peak- versus Grundbetrieb messen.
application_namesetzen auf jedem Prozesstyp, damitpg_stat_activitydir nützliches Signal liefert.- PgBouncer im Transaction-Modus deployen mit
PDO::ATTR_EMULATE_PREPARESoder PgBouncer >= 1.21 Prepared-Statement-Tracking. Strebe ein 4:1-Verhältnis von Anwendungsverbindungen zu echten PostgreSQL-Verbindungen an. - Messenger-Worker bewusst begrenzen - du brauchst fast nie mehr als 3-5 Worker pro Transport. Bevorzuge horizontale Skalierung des Hosts gegenüber Dutzenden von Workern auf einer Maschine.
- Die drei Grafana-Queries hinzufügen und den 70%-Auslastungs-Alert vor dem nächsten Launch setzen.
Die Teams, die das richtig machen, entdecken die Decke nicht während einer Product-Hunt-Kampagne. Sie sehen einen Auslastungsgraphen, der aufwärts tendiert, handeln solange sie noch sechs Wochen Spielraum haben, und der Kampagnentag verläuft unspektakulär.
Wenn du eine zweite Meinung dazu willst, wie dein aktuelles Symfony-Stack den Datenbankverbindungslebenszyklus handhabt - oder wenn du bereits die Decke erreicht hast und Triage brauchst - ist ein Code-Qualitäts-Audit oft der schnellste Weg von der Krise zur Stabilität. Erreichst uns unter hello@wolf-tech.io oder besuche wolf-tech.io.

