27. Leistung

Womit die Pepsi-Pipeline ihre Zeit verbringt, wie die Zahlen zu lesen sind, die die Benchmark-Skripte unter tests/ liefern (08-perstage-bench.sh, 09-latency-bench.sh und 10-goodput-bench.sh), und welche Stellschrauben sie bewegen. Wie die Skripte zu ihren Zahlen kommen, steht im Kapitel Benchmark-Suite.

Bemerkung

Abgesehen von einem datierten Lauf unter Referenzmessungen nennt dieses Kapitel keine Zahlen. Absolute Werte hängen weit mehr vom Host, den Datenbankeinstellungen und der konfigurierten Pipeline ab als von irgendetwas in Pepsi; führen Sie die Suite daher auf Ihrer eigenen Hardware aus. Übertragbar ist die Gestalt der Ergebnisse — welche Stages billig sind, welche mit der Nachrichtengröße skalieren und welcher Art die Durchsatzobergrenze sich erweist —, und genau diese beschreibt dieses Kapitel.

Führen Sie die Benchmarks mit einer ruhigen Protokollstufe aus: Die Skripte setzen [pepsi] LOG (jede Komponente beachtet es — der Dispatcher und die Stage-Worker, die er startet) für die Dauer auf warn, sodass die Protokollierung pro Nachricht die Zahlen nicht aufbläht.

27.1. Wie die Pipeline ihre Zeit verbringt

Fünf Eigenschaften der Architektur bestimmen die Zahlen. Die letzten drei folgen aus einem Prinzip: Bei Mail-Raten werden die Kosten der Pipeline davon dominiert, wie oft sie die Datenbank koordinieren lässt, und nicht von der Arbeit, die eine Stage leistet; also koordiniert sie nur dort, wo die Information tatsächlich liegt.

  • Jede Stage zahlt kleine feste Datenbankkosten pro Nachricht. Ein Stage-Worker lädt seinen Datensatz in einem SELECT (den Datensatz und seine anwendbaren Einstellungen pro Adresse, in einem Round-Trip) und schreibt sein Ergebnis in einem terminalen UPDATE/DELETE (oder einer gespeicherten Fan-out-Funktion) fest. Dieses Paar von Round-Trips plus die Worker-Einplanung ist die Untergrenze, die jede Nachricht an jedem Hop zahlt, unabhängig von der Nachrichtengröße.

  • Mehrere Mechanismen beseitigen oder amortisieren diese Round-Trips. Der Dispatcher beansprucht Arbeit für alle Stages in einem einzigen Aufruf (pro Stage liest der faire Anspruch per Loose Index Scan höchstens 64 wartende Absender und von jedem höchstens so viele seiner ältesten Datensätze, wie die Stage Kapazität hat, sodass ein tiefer Rückstau ihn nicht langsamer macht; mit FAIR_SCHEDULING = no stoppt ein LATERAL … LIMIT den Indexscan jeder Stage bei ihrer Kapazität) und pipelinet bis zu QUEUE_LIMIT Nachrichten an jeden Worker, sodass der Abschluss einer Nachricht keinen vorherigen Koordinator-Round-Trip benötigt. Der Lade-und-Weiterschalten-Hot-Path läuft unter READ COMMITTED statt SERIALIZABLE — sicher, weil höchstens ein Schreiber jemals einen gegebenen Datensatz berührt (der Dispatcher ist der einzige Beanspruchende). Der Worker einer Stage ohne Nachrichtentext lädt und committet einen ganzen pipelinten Stapel von Nachrichten in einem SELECT … = ANY und einem arrayisierten UPDATE. Und Stage-Fusion ([pepsi] ALLOW_FUSION, standardmäßig an) führt einen schnellen Nachfolger ohne Nachrichtentext, dessen FUSION eingeschaltet ist — standardmäßig if, srs, list, check-whitelist, auto-whitelist, block-language, discard und edit-settings (letzteres nur für Nachrichten, die keine Steuernachrichten sind, was es aus den Metadaten entscheiden kann) — innerhalb des Worker-Prozesses seines Vorgängers auf dem bereits geladenen Datensatz aus und kollabiert eine Folge von n solchen Stages von n Ladevorgängen + n Weiterschaltungen + n Übergaben auf einen einzigen Ladevorgang, die Arbeit der Stages und einen terminalen Schreibvorgang. Fusion ändert kein Ergebnis: Eine Stage, die nicht fusioniert werden kann (sie benötigt den Nachrichtentext, ist nicht in das vereinheitlichte Binärprogramm eingefaltet, oder ihr FUSION ist ausgeschaltet), fällt einfach auf einen normalen dispatchten Hop zurück. Fusion benötigt das vereinheitlichte pepsi-Binärprogramm und ist daher in einem multibin-Entwicklungs-Build wirkungslos.

  • Übergaben zwischen Stages sind bewusst still. Der Dispatcher erfährt durch ein LISTEN auf dem Kanal workqueue, dass Arbeit anliegt, aber eine Stage, die eine Nachricht weiterschaltet, benachrichtigt nicht. Sie muss es nicht: Der Worker meldet jede fertige Nachricht auf seiner Standardausgabe, und der Koordinator beantwortet diese Meldung mit derselben vollständigen, kapazitätsbegrenzten Beanspruchung, die eine Benachrichtigung ausgelöst hätte. Eine Benachrichtigung auf dem Weiterschaltpfad würde dem Dispatcher etwas mitteilen, das ihm ohnehin gleich über einen billigeren Kanal mitgeteilt wird.

    Benachrichtigungen sind alles andere als kostenlos: PostgreSQL hält von dem Moment an, in dem eine Transaktion eine einreiht, bis zu ihrem Commit eine datenbankweite AccessExclusive-Sperre, sodass benachrichtigende Transaktionen nicht gruppenweise committen können und jeder Hop den Commit-Pfad der gesamten Datenbank serialisieren würde. Eine Benachrichtigung wird daher nur dort ausgegeben, wo die Information wirklich neu ist — von Schreibern außerhalb der Schleife des Dispatchers: die Zulassung von Nachrichten (zusammengefasst, einmal je Schwall statt einmal je Nachricht), workqueue_inject, workqueue_resume, pepsi-keydisc(1), das geparkte Mail freigibt, pepsi-failure-bouncer(1) und die Reparaturbefehle von pepsi-queue(1).

    POLL_INTERVAL bleibt das Sicherheitsnetz hinter alledem: Eine Aufweckung, die nie gesendet wird oder verloren geht, kostet Latenz und nie eine Nachricht.

  • Stage-Übergänge warten nicht auf die Platte. Die Verbindung eines Stage-Workers läuft mit abgeschaltetem synchronous_commit ([pepsi-postgres] WORKER_SYNCHRONOUS_COMMIT, standardmäßig aus), sodass die Schreibvorgänge der Pipeline gruppenweise committen, statt dass jeder sein eigenes Flush zahlt. Die Zulassung von Nachrichten ist davon unberührt — Ingress committet immer synchron, sodass ein 250 weiterhin bedeutet, dass die Nachricht die Platte erreicht hat. Das zusätzliche Risiko ist, dass ein Maschinenabsturz die letzten paar hundert Millisekunden an Stage-Übergängen verlieren kann, woraufhin die Nachricht bei der vorherigen Stage gefunden wird und erneut durch sie läuft — genau das, was ohnehin geschieht, wenn ein Worker zwischen seiner Arbeit und dem Commit getötet wird.

  • Die Buchführung hat genau einen Schreiber. Die Zähler pro Stage und die globalen Zähler werden im Speicher des Dispatchers gesammelt und in einer einzigen Transaktion geschrieben — bei jedem STATS_INTERVAL, wenn die Pipeline in den Leerlauf geht, und beim Herunterfahren. Kein Worker schreibt sie: stage_stats hält einen Datensatz je Stage, sodass ein je Nachricht geschriebener Zähler jeden Worker einer Stage in eine Warteschlange hinter dem einen Datensatz dieser Stage stellen würde — die Buchführung stünde dort in Konkurrenz, wo die Mail selbst es nie tut, da genau ein Schreiber eine gegebene Nachricht jemals berührt. Eine Stage, die in den Durchlauf einer anderen Stage fusioniert ist, wird dem Dispatcher daher auf der Statuszeile gemeldet, die dieser Durchlauf ohnehin schreibt, und in die Zähler eingerechnet, die er ohnehin führt, sodass die Abrechnung pro Stage ohne jeden Schreibvorgang exakt bleibt. Der Preis ist der bei Statistiken übliche: Noch nicht weggeschriebene Deltas gehen verloren, wenn der Dispatcher stirbt.

27.2. Die Matrix pro Stage lesen

Der Dispatcher misst jede Stage-Ausführung, sodass 08-perstage-bench.sh die Kosten einer Stage direkt misst, ohne Instrumentierung des Stage-Codes: die Wanduhrzeit, die eine einzelne Nachricht innerhalb jeder Stage verbringt (der Worker liest den Datensatz, führt die Stage-Logik aus, schreibt das Ergebnis fest), in Millisekunden pro Nachricht, für jede durchlaufene Nachrichtengröße. Damit jede Stage als ihr eigener zugewiesener Worker beobachtbar ist, läuft dieser Benchmark mit [pepsi] ALLOW_FUSION = no; die Latenz- und Nutzdurchsatz-Benchmarks lassen die Fusion an und messen das System wie ausgeliefert.

Die Matrix hat zwei Hälften (beide Listen finden Sie im Kapitel Benchmark-Suite): Stages, die in situ gemessen werden, indem je eine Nachricht jeden Weg der ausgelieferten Pipeline hinuntergeschickt wird, und Stage-Programme, durch die die Installation keine Nachricht leitet und die in Isolation gemessen werden, indem eine Nachricht direkt in einen nur für den Benchmark bestimmten Abschnitt injiziert wird. Die isolierten Zahlen enthalten überhaupt keine Kosten für pepsi-ingress oder SMTP.

Drei Klassen von Zeilen zeichnen sich ab, und ein Messartefakt, das benannt werden sollte.

  • Metadaten-Stages sind flach und billig. if (die Verzweigungen), srs, list, aliases, route und discard deklarieren Load::Metadata — sie berühren den Nachrichtentext nie — und kosten bei jeder Größe gleich viel. check-whitelist, block-language und vacation verhalten sich in der Praxis genauso: Sie deklarieren Load::Headers, holen also den Header-Block, aber nie den Body, und der Header-Block wächst nicht mit der Nachricht.

    Die isolierte Zahl einer solchen Stage — eine Stage, ein warmer Worker, nichts anderes in der Pipeline — sind die Kosten des Hops selbst: das einzelne Lade-SELECT, das einzelne abschließende UPDATE und die eigene Buchführung des Workers. Ihre In-situ-Zahl ist derselbe Hop, mit den Workern der restlichen Kette ringsum lebendig, und sie ist höher. „Der Boden pro Hop“ ist also nicht eine Zahl: Er ist die eigene Arbeit des Hops plus der Umstand, einer von vielen zu sein. Genau das entfernt die Stage-Fusion, und deshalb ist die mit eingeschalteter Fusion gemessene Ende-zu-Ende-Latenz weit geringer als die Summe der Spalte der Matrix.

  • Stages, die den Body lesen, skalieren mit der Nachrichtengröße. detect-language ist die teuerste Einzeloperation der Pipeline und im Wesentlichen linear im Body, gefolgt von decrypt und secure-link; ein größenbasiertes if vor detect-language ist die einzelne Änderung, die bei großen Nachrichten am meisten einspart. dkim-sign, local — der setuid-Maildir-Schreiber, der die vollständige Nachricht verarbeitet — und init (ARC-Prüfung, Hashing und Siegel) steigen deutlich sanfter an.

  • Die Stages, die nur *hineinsehen*, liegen am Boden. autocrypt-learn, vks-confirm und auto-pay deklarieren alle Load::Full, sehen bei gewöhnlicher Post aber nur hin, sodass ihre Kosten nur mit den Kosten des Ladens eines größeren Bodys steigen. Sie einer Pipeline hinzuzufügen kostet etwa einen Hop mehr, nicht einen Scan mehr.

  • Die Relay-Zeilen sind mit all dem nicht vergleichbar. smarthost, internet, delaytest und bench-lmtp halten eine ausgehende SMTP- oder LMTP-Transaktion offen, ihre Zahl ist also der Umlauf eines anderen Hosts und dessen eigene Verarbeitung. Sie muss mit der Größe nicht einmal monoton steigen, und sie sagt nichts über den Pepsi-Host — siehe Zahlen, die zu einem anderen Host gehören.

  • Das Artefakt: kalte Worker. Der Durchlauf geht langsamer von Größe zu Größe als [pepsi-dispatch] WORKER_IDLE_TIMEOUT (Standard 5 s), sodass der Worker einer nur für den Benchmark bestimmten Stage zwischen den Größen abgebaut werden kann und die nächste Zelle dafür bezahlt, dass einer exec’t wird. Erkennbar ist das an einem Sprung um dieselben paar Millisekunden, unabhängig von der Nachrichtengröße, der sich entlang einer Zeile mit dem warmen Wert abwechselt. Der warme Wert ist der Boden; der kalte ist der Boden plus ein Prozessstart.

anti-spam erscheint in der Matrix freigegeben: Die Benchmarks setzen den Absender auf die Whitelist, sodass es bei state.spam kurzschließt und den Body nie ansieht. Seine Kosten beim Greifen sind die Latenz des Taler-Händler-Backends, was eine Eigenschaft des Händlers ist und nicht von Pepsi. Whitelisting ist das, was es vom üblichen Weg fernhält.

27.3. Zahlen, die zu einem anderen Host gehören

Manche Zahlen, die ein Benchmark-Lauf ausgibt, sind Messungen des Pepsi-Hosts im Gespräch mit einer anderen Maschine und werden von dieser Maschine begrenzt:

  • Die Kosten pro Stage der Relay-Stages, wie oben: ein entfernter Umlauf plus entfernte Verarbeitung, nicht das, was es Pepsi kostet, eine Nachricht weiterzuleiten.

  • Ausgehender Nutzdurchsatz (Szenario C des Nutzdurchsatz-Benchmarks). Das Weiterleiten ist dadurch begrenzt, wie schnell der empfangende MX annimmt. Ist dieser Host selbst eine Pepsi-Installation, stehen seine [pepsi-ingress] CONN_RATE_PER_SECOND/CONN_RATE_BURST standardmäßig auf einer neuen Verbindung pro Sekunde je Quelle, und ein Relay, das je Nachricht eine Verbindung öffnet, erhält 421 und stellt zurück — nichts geht verloren, aber es wird auch nichts gemessen. Erhöhen Sie sie zuerst auf der empfangenden Seite. Sind sie erhöht, bleibt die eigene Geschwindigkeit des empfangenden Hosts; ein kleiner Gegenüber nimmt einen Schwall schnell an und braucht dann lange, ihn zuzustellen.

  • ``n/a``-Zellen bei großen Größen auf dem Smarthost-Weg. Das voreingestellte message_size_limit von Postfix beträgt 10 240 000 Bytes, und ein Postfix-Smarthost lehnt größere Nachrichten mit 552 ab. Der Durchlauf hält die Ablehnung fest und überspringt die größeren Größen auf diesem Weg, anstatt sie zu reproduzieren.

27.3.1. Hochrechnen über einen entfernten Engpass hinaus

Die Nutzdurchsatz-Zusammenfassung gibt genau für diesen Fall eine hochgerechnete Spalte aus.

Warnung

Eine Hochrechnung ist Arithmetik auf Messungen, keine Messung, und sie ist eine obere Schranke: Sie nimmt an, dass die eine skalierte Ressource die begrenzende bleibt, was genau dann aufhört zu gelten, wenn man sich ihr nähert. Zitieren Sie die gemessenen Zahlen.

Die Methode ist die naheliegende: Hat ein Lauf einen Anteil u einer lokalen Ressource verbraucht und war etwas anderes die Grenze, dann würde das Beseitigen dieses anderen die Rate um bis zu 1/u steigen lassen. Welche Ressource es ist, entscheidet darüber, ob die Antwort etwas bedeutet:

  • Platten-``%util`` darf nicht verwendet werden. iostat``s ``%util ist der Anteil der Wanduhrzeit, in der die Anfragewarteschlange nicht leer war; auf einem NVMe-Gerät, das Dutzende Anfragen parallel bedient, sagt das „irgendetwas war offen“, nicht „das Gerät ist voll“, und es steigt nicht so mit dem Durchsatz, dass man damit skalieren könnte.

  • Das Netz wird berichtet, weil es auf einer stärker belegten Leitung als Erstes zur Grenze würde, ist ihr aber selten nahe.

  • Die CPU folgt der Last und ist das, was die Projektion verwendet.

Und die Projektion ist gedeckelt auf die beste Rate, die irgendein vollständig lokaler Weg (ohne entfernten Host) im selben Lauf erreicht hat: Man kann Nachrichten nicht schneller weiterleiten, als man sie verarbeiten kann. Was die Hochrechnung rechtfertigt, ist die Gestalt der Aussage — der ausgehende Weg ist durch den Gegenüber begrenzt, etwa um diesen Faktor —, nicht die Ziffer. Der ehrliche Weg, die Lücke zu schließen, ist eine zweite Maschine derselben Klasse am anderen Ende.

27.4. Ende-zu-Ende-Latenz (Leerlauf)

Bei im Übrigen unbeschäftigtem System injiziert 09-latency-bench.sh kurze Nachrichten einzeln und pollt jeweils auf ihr Eintreffen im Ziel-Maildir, wobei es den gesamten Inject→Zustellen-Umlauf über den eingehenden Weg misst, und berichtet Min/Mittel/Median/p95/Max. Lesen Sie den Median: Eine einzige langsame Stichprobe — ein Worker, der geforkt wird, ein Checkpoint — genügt, um den Mittelwert über das p95 zu ziehen.

Das Skript gibt außerdem die Summe der mittleren Verarbeitungszeiten pro Stage über denselben Lauf aus. Auf einem unbeschäftigten System liegt die Ende-zu-Ende-Latenz nahe an dieser Summe: Die Dispatcher-Einplanung und die Übergabe zwischen Stages fügen pro Hop wenig hinzu, und der Weiterschalt-Weg wird vom Statusbericht des Workers getrieben und nicht von einer Benachrichtigung, sodass POLL_INTERVAL ihn nicht begrenzt.

Zwei Eigenschaften des Messaufbaus halten die Zahl ehrlich. Der Sampler liest jede Datei im Ziel-Maildir/new einmal je Größe, bei der er sie sieht, statt jede Datei bei jedem Poll, sodass ein von einem früheren Lauf voll hinterlassenes Postfach die Messung nicht in eine des eigenen glob des Messaufbaus verwandelt. Und er öffnet je Stichprobe eine Verbindung, was bei niedriger Latenz schneller ist, als ein ausgeliefertes CONN_RATE_PER_SECOND erlaubt; daher erhöht er für den Lauf die Annahmegrenzen und meldet eine Ablehnung bei der Einlieferung getrennt von einer Nachricht, die angenommen wurde und nie ankam.

Die Leerlauf-Latenz ist die Arbeit pro Nachricht, die eine einzelne Nachricht bei leerer Warteschlange zahlt. Sie ist nicht der Kehrwert des Sättigungsdurchsatzes unten — beide sind durch Verschiedenes begrenzt.

27.5. Sättigungs-Nutzdurchsatz

10-goodput-bench.sh misst jeden der drei Last- und Zustellwege zweimal, und die beiden Instrumente sind nicht gleich gut.

Eine Rampe vervielfacht die angebotene Last alle STEP_SECS, bis die Warteschlange über SAT_QUEUE liegt und weiter wächst. Ein Burst-Leerlaufen mit fester Größe bietet BURST_MSGS Nachrichten am Anschlag an und misst, wie lange die Warteschlange zum Leerwerden braucht. Beide erhöhen für die Dauer die Annahmegrenzen ([pepsi-ingress] CONN_RATE_PER_SECOND/CONN_RATE_BURST/ MAX_CONNECTIONS und deren DB_POOL_SIZE) sowie PARALLELISM in jedem [stage-*]-Abschnitt, und beide stellen beim Beenden die ausgelieferte Konfiguration wieder her. Beide erfassen die CPU des Hosts, das %util der Platte unter PGDATA und die Leitungsauslastung der Netzwerkkarte.

Zitieren Sie den Burst. Eine Rampe schreibt Abschlüsse demjenigen festen Fenster zu, in das sie fallen, und einen Generator in Gang zu bringen kostet einen ssh-Umlauf und einen Python-Start auf dem injizierenden Host — einen großen Teil des Fensters, wenn dieser Host entfernt ist, und einen noch größeren, wenn er klein ist —, sodass eine Rampe für denselben Weg deutlich unter dem Burst liegt. Die Rampe ist lesenswert für die Gestalt des Knies — wo die Warteschlange zu wachsen beginnt, was der Host dabei tut —, aber nicht für eine Rate.

Die Wege und was jeden von ihnen begrenzt:

Pfad

was es begrenzt

A lokales Inject → lokales Maildir

oft ihr eigener Lastgenerator, der auf dem getesteten Host läuft; verwenden Sie sie, um zu bestätigen, dass das Netz nichts hinzufügt, nicht als zitierfähige Zahl

B fremder Absender → lokales Maildir

dieser Host. Die eingehende Zahl, die man zitiert

C lokaler Ursprung → Relay an einen fremden MX

der empfangende Host (siehe Zahlen, die zu einem anderen Host gehören)

nur Pipeline, SQL-gespeistes Leerlaufen (siehe Reproduzieren)

dieser Host, mit vollständig aus dem Pfad genommener SMTP-Annahme

Zusammen gelesen:

  • A gegen B isoliert das Netz. Die beiden unterscheiden sich nur darin, wo der Absender sitzt; Kosten pro Stage, die innerhalb des Rauschens übereinstimmen, bedeuten, dass die eingehende Authentifizierung fremder Post keine sichtbaren Kosten pro Nachricht verursacht.

  • Das SQL-gespeiste Leerlaufen gegen B isoliert die Annahme. Der Unterschied ist pepsi-ingress, das Verbindungen annimmt und jede Annahme synchron festschreibt — ein 250 bedeutet, dass die Nachricht die Platte erreicht hat —, und er ist der Preis dieser Garantie, kein Mangel.

  • Prüfen Sie, dass innerhalb der Datenbank nichts konkurriert. Erfassen Sie pg_stat_activity und pg_locks während eines Leerlaufens. Backends, die in Client/ClientRead stehen, sind PostgreSQL, das auf die Worker wartet, nicht umgekehrt; eine nicht gewährte Sperre oder Wartezustände in IO/WALSync besagen, dass die Datenbank die Grenze ist. Die Spalten fail/s und serr/s der Rampe müssen auf jeder Laststufe null zeigen: Eine gültige Nachricht darf nie scheitern, und die Warteschlange mit einem einzigen Beanspruchenden darf nie serialisieren, sodass ein Wert ungleich null ein Korrektheitsfehler ist und keine Kapazitätsgrenze.

  • Jenseits des Knies kann der Durchsatz fallen, statt abzuflachen. Ein Pepsi unter mehr Last, als es aufnehmen kann, verliert nichts, verfällt aber nicht zwingend sanft auf seine Maximalrate; es sind die Annahmegrenzen, die verhindern, dass ein Burst das auslöst, und sie sind der Regler, nach dem man greift.

Bemerkung

Eine gesättigte Warteschlange meldet immer nur ihre äußerste Beschränkung, daher sagt ein Durchsatzwert für sich genommen nichts darüber, was ihn begrenzt. Die Prüfungen, die das klären, sind pg_locks und pg_stat_activity unter Last, CPU/Platte/Netz des Hosts selbst und — wie Pfad C zeigt — was der nächste Hop tut. Geben Sie einen Wert zusammen mit diesen Belegen an, und führen Sie nach einer Änderung dieselben Prüfungen erneut aus.

Eine Nachricht kostet etwa zwei Datenbanktransaktionen je verteiltem Hop — das Laden und das abschließende Schreiben — plus die Annahme, was die Größe ist, um die es bei den obigen Koordinationseigenschaften wirklich geht, und die, auf die man achtet, wenn man eine Stage hinzufügt. Dort liegt der Spielraum: weniger Stages auf dem Pfad und die Stage-Fusion, die eine Folge fusionierbarer Stages ohne Nachrichtentext auf einen Ladevorgang, einen terminalen Schreibvorgang und eine Runde Buchführung statt n zusammenzieht.

27.6. Tuning-Hinweise

  • Parallelität und das Verbindungsbudget. Jeder Stage-Worker ist ein Prozess, der eine Datenbankverbindung hält, sodass die Live-Verbindungszahl etwa Σ PARALLELISM über die ausgelasteten Stages plus dem Dispatcher-Pool und den pepsi-ingress/pepsi-httpd-Pools beträgt. Diese Summe muss unter dem max_connections von PostgreSQL bleiben. Der Standardwert für PARALLELISM ist 4 und der Standardwert für DB_POOL_SIZE ist 1 für pepsi-ingress und pepsi-httpd (der eigene Pool des Dispatchers steht standardmäßig auf 8, der von pepsi-telemetry auf 2), genau damit eine Standardinstallation auf einen Standardserver passt. Eine Pipeline mit vielen Stage-Abschnitten kann Debians Standardwert max_connections = 100 dennoch überschreiten, wenn jede Stage gleichzeitig ausgelastet ist. Das PARALLELISM zu erhöhen, um den Durchsatz zu steigern, oder DB_POOL_SIZE, um mehr gleichzeitige Sitzungen zuzulassen, bedeutet, dieses Budget erneut zu prüfen. Wird es überschritten, melden Worker, die keine Verbindung bekommen, den Datenbank-Überlast-Status, und der Dispatcher reiht die Nachricht erneut ein und drosselt die ausgelastetste Stage, statt Mail zu verlieren — siehe pepsi-dispatch (Datenbank-Überlast-Gegendruck). Das erzeugt keinen Fehler, nur einen geringeren Durchsatz, weshalb 10-goodput-bench.sh die Summe gegenüber max_connections ausgibt, bevor es irgendetwas misst. Das Mittel gegen anhaltenden Druck ist, max_connections zu erhöhen oder PARALLELISM zu senken, nicht sich auf die Drosselung zu verlassen.

  • Weniger Hops hilft immer; mehr Worker hilft nur, wenn die Worker-Zahl das ist, was im Weg steht. Ein dispatchter Hop kostet ein Paar Round-Trips — das Lade-SELECT des Workers und sein terminales UPDATE — plus die Einplanung darum herum, sodass sich die Nutzdurchsatzgrenze mit der Pfadlänge bewegt, was auch sonst gilt. (Es ist kein Statistik-Schreibvorgang: Kein Worker schreibt stage_stats überhaupt, siehe Die Buchführung hat genau einen Schreiber oben.) Den Pfad zu verkürzen oder den Hop wegzufusionieren ist daher die Änderung, die sich immer auszahlt.

    PARALLELISM ist diejenige, die von der Maschine abhängt. Auf einem kleinen Host sind CPU oder das Write-Ahead-Log das Knie, und mehr Worker bedeuten nur mehr Wartende. Auf einem großen können wenige Worker je Stage, jeder auf seinem eigenen Datenbank-Round-Trip blockiert, die Maschine nahezu unbeschäftigt lassen, ohne jede Sperrkonkurrenz, und dann ist das Erhöhen von PARALLELISM die ganze Geschichte. Die Regel ist die Diagnose und nicht eine Zahl: ist der Host am Knie unbeschäftigt und pg_locks sauber, dann ist die Worker-Zahl die Obergrenze und sie zu erhöhen wird wirken; ist der Host beschäftigt oder sind die Wartezustände IO/WALSync, dann nicht. Jeder Worker ist eine Datenbankverbindung, wie weit es gehen kann, ist also eine Eigenschaft von max_connections. (%util der Platte klärt die Frage nicht — siehe Hochrechnen über einen entfernten Engpass hinaus.)

  • Lassen Sie die Stage-Fusion an. [pepsi] ALLOW_FUSION (standardmäßig an) führt einen fusionierbaren Nachfolger ohne Nachrichtentext innerhalb des Workers seines Vorgängers auf dem bereits geladenen Datensatz aus, sodass eine Folge von n solchen Stages einen Ladevorgang, einen terminalen Schreibvorgang und eine Runde Buchführung statt n kostet. Der einzige Grund, sie abzuschalten, ist, Stages getrennt zu messen, wie es der Benchmark pro Stage tut. FUSION = yes auf einer günstigen Nur-Metadaten-Stage zu setzen, die die Fusions-Registry unterstützt, ist mehr wert als jede Pool-Stellschraube.

  • Pipeline-Tiefe vor Parallelität. Aus demselben Grund greifen Sie zum QUEUE_LIMIT einer Stage (an einen Worker pipelinte Nachrichten), bevor Sie zu ihrem PARALLELISM greifen. QUEUE_LIMIT hebt die Kapazität einer Stage an in Bearbeitung befindlichen Nachrichten auf QUEUE_LIMIT × PARALLELISM und entfernt einen Koordinator-Round-Trip pro Nachricht, ohne Worker-Prozesse oder Datenbankverbindungen hinzuzufügen, sodass es nicht in das obige Budget eingeht; und der Worker einer Stage ohne Nachrichtentext committet einen ganzen pipelinten Stapel in einem arrayisierten UPDATE, was ein Round-Trip für den ganzen Stapel ist. Es steht standardmäßig auf 4 — Pipelining ist ab Werk an — sodass es bei der Stellschraube oft darum geht, sie zu senken: Setzen Sie QUEUE_LIMIT = 1 für Stages, deren Arbeit pro Nachricht lang und ungleichmäßig ist, vor allem die Netzwerk-Relays, wo eine pipelinte Nachricht sonst hinter einem langsamen Geschwister warten würde.

  • Lassen Sie Arbeit weg, die Sie nicht benötigen. Die günstigste Stage ist eine, die nicht in der Kette ist — doppelt günstig, denn sie kostet weder ihre eigene Arbeit noch den Anteil eines Hops an der Koordination. Besonders detect-language lohnt sich wegzulassen oder mit einer Größenverzweigung abzusichern, sofern Sprachfilterung nicht tatsächlich genutzt wird.

  • Lassen Sie ``WORKER_SYNCHRONOUS_COMMIT`` aus, sofern nicht eine Stage in Ihrer Pipeline einen äußeren Seiteneffekt hat, der auch über einen Stromausfall hinweg nicht wiederholt werden darf. Es einzuschalten kostet etwa ein Write-Ahead-Log-Flush je Stage-Hop. Was das wert ist, hängt ganz von der Platte ab — erheblich auf einer SATA-SSD, gering auf einem schnellen NVMe-Gerät —, messen Sie es also dort, wo Sie sind, bevor Sie es weggeben.

  • Front-End-Grenzen sind von dem Pipeline-Durchsatz getrennt, und sie greifen zuerst. [pepsi-ingress] CONN_RATE_PER_SECOND / CONN_RATE_BURST / MAX_CONNECTIONS sind eine Annahmerichtlinie je Quelle, und die Voreinstellungen sind absichtlich streng — CONN_RATE_PER_SECOND und CONN_RATE_BURST stehen beide standardmäßig auf 1, eine neue Verbindung pro Sekunde von einer gegebenen Adresse. Das ist die richtige Voreinstellung für einen Host am offenen Internet und völlig falsch für einen Host, über den eine andere Ihrer eigenen Maschinen relayt, und es ist das Erste, was man prüft, wenn ein Nachbar von Ihnen Post langsam anzunehmen scheint. Erhöhen Sie sie auf der empfangenden Seite, je Quelle, wenn möglich. Die Benchmarks erhöhen sie auf dem getesteten Host für die Dauer genau deshalb, damit sie die Pipeline messen und nicht dies.

  • PostgreSQL selbst. Die Benchmarks brauchen über max_connections hinaus keine Datenbankabstimmung, und die Suite belässt den Rest absichtlich bei den Voreinstellungen der Distribution; ein so gemessener Wert ist eine Untergrenze, die eine abgestimmte Datenbank (insbesondere shared_buffers) anheben würde, keine Obergrenze.

27.7. Referenzmessungen

Ein Lauf der drei Benchmarks, durchgeführt am 2026-09-26 auf Commit 0c6a40ea mit der Pipeline, die tests/01-deploy.sh bereitstellt, und jedem Regler auf dem Standardwert, den das Kapitel Benchmark-Suite aufführt. Die Zahlen beschreiben nur diesen Host und diesen Commit; lesen Sie sie auf die in den Abschnitten oben dargelegte Gestalt hin, nicht als Wert, den Sie anderswo erwarten können.

Der getestete Host und seine Gegenstellen

Merkmal

Wert

getesteter Host

der ADMIN-Host, physische Maschine

CPU

2 x AMD EPYC 9555, 128 Kerne / 256 Threads

Arbeitsspeicher

256 GiB

Speicher

PGDATA und die Maildirs auf ext4 auf einer NVMe-SSD (Samsung 9100 PRO)

Betriebssystem

Debian GNU/Linux 13 (trixie), Kernel 6.12

Datenbank

PostgreSQL 17.11 auf demselben Host, Standardwerte der Distribution (shared_buffers 128 MB, synchronous_commit an) außer max_connections = 700

von der Suite für den Lauf gesetzt

STATS_INTERVAL 2 s; POLL_INTERVAL 2 s (5 s beim Nutzdurchsatz-Lauf); [pepsi] LOG = warn; ALLOW_FUSION = no nur beim Lauf pro Stage; die Zulassungsgrenzen angehoben auf CONN_RATE_PER_SECOND = 5000, CONN_RATE_BURST = 10000, MAX_CONNECTIONS = 8000, Ingress-DB_POOL_SIZE = 16; beim Nutzdurchsatz-Lauf zusätzlich PARALLELISM = 16 in jedem [stage-*]-Abschnitt und Dispatcher-DB_POOL_SIZE = 16 (etwa 561 der 700 Verbindungen)

Host von ALICE/BOB

eine virtuelle Maschine mit 2 vCPUs und 4 GiB, auf der Postfix läuft; der Smarthost des eingehenden Relay-Pfads und der Absender von Pfad C

Host von CAROL

ein Core i3-4005U mit 4 Threads und 16 GiB, auf dem selbst Pepsi läuft; der fremde Absender der eingehenden Szenarien und von Pfad B sowie der empfangende MX der Stage internet und von Pfad C

Kosten pro Stage (08-perstage-bench.sh), Millisekunden pro Nachricht, Mittelwert aus drei Nachrichten je Zelle. Die bench-*-Zeilen sind die isolierte Hälfte, jede andere Zeile wurde in situ gemessen:

Stage

1K

10K

100K

1M

5M

10M

20M

init

17,9

17.6

18,0

19.4

27,0

35,4

63,6

route

6.8

6.5

7.0

6.5

7.1

6.6

11.3

decrypt

7.1

7.1

8,4

21,8

76,2

144

286

detect-language

11,9

16.0

23,9

57,0

208

385

741

block-language

4.9

5.0

5.0

5.1

5.4

5.3

9,4

check-whitelist

5.6

5.4

5.9

4,7

4,7

5.3

8,5

anti-spam

4.8

5.0

4.8

6.0

7.0

9.7

19,0

list

6.1

6.2

6.1

6.5

6.4

5,7

10,3

forward

4,3

6.0

6.1

7,6

26,2

11.3

20,2

local

4.6

10.9

11.6

13.4

21,1

27.5

48,9

srs

5.8

6.3

5.8

6.3

14,5

n/a

n/a

dkim-sign-relay

13,0

13.9

13,3

16,6

49,3

n/a

n/a

smarthost

294

267

258

217

347

n/a

n/a

list-out

11,9

6.6

5.8

6.9

10.7

15.1

14.3

edit-settings

9.7

4.8

5.6

5,7

12,8

18,8

25,1

auto-whitelist

10,5

7.0

6.4

6.8

10,3

15.6

15.2

delay-route

10.7

6.1

5.4

5.5

9,2

13.4

15.1

encrypt

10.1

6.9

6.4

11,1

30,8

54,4

91,4

dkim-sign

14,0

13,8

13,0

17.1

40,1

66,9

98,5

internet

245

112

132

256

628

1145

2121

bounce

5.3

5.1

5.0

5.3

5.1

5.2

5.5

bounce-language

5.1

4.8

4.6

5.5

5.2

5.0

5.0

bench-discard

6.5

1.8

1.7

1.7

1.9

6.5

6.6

bench-aliases

7.2

2.0

1.9

1.8

1.9

6.2

6.9

bench-route

6.8

2.0

1.8

1.8

1.7

6.3

6.9

bench-vacation

6.3

1.4

1.6

1.2

1.1

5.9

6.2

bench-autocrypt-learn

5.5

0.7

0.9

1.8

4.5

11,2

17.5

bench-vks-confirm

5.0

0.8

0.7

1.4

3,8

11,0

16.2

bench-auto-pay

5.2

0.8

0.8

1.5

3,6

10.8

16.0

bench-secure-link

24.9

19,3

20,3

27.5

55,6

95,7

175

n/a ist das 552 des Smarthosts für eine Nachricht von 10 MB, siehe Zahlen, die zu einem anderen Host gehören. Die Zeilen smarthost und internet sind der Umlauf zum ALICE/BOB-Host bzw. zum CAROL-Host und die Verarbeitung dort, keine Kosten dieses Hosts. Die isolierten Zeilen zeigen das Artefakt kalter Worker bei 1K, 10M und 20M: Ihr warmer Wert, etwa 2 ms für eine reine Metadaten-Stage, ist die Untergrenze.

Latenz im Leerlauf (09-latency-bench.sh), 200 Nachrichten zu 1 KiB vom lokalen SMTP-Port in ein lokales Maildir, alle 2 ms abgefragt:

zugestellt

Min

Median

Mittelwert

p95

Max

Summe der mittleren Stage-Zeiten

200 von 200

26 ms

28 ms

32 ms

29 ms

448 ms

15,8 ms (10 Stages)

Sättigungs-Nutzdurchsatz (10-goodput-bench.sh), Nachrichten zu 2 KiB. Der Burst ist die maßgebliche Zahl; die Rampen-Spalte ist die letzte Stufe, die noch Schritt hielt, und die Auslastungen sind die dieses Hosts während des Bursts:

Pfad

Burst (Nachr./s)

Rampe (Nachr./s)

CPU

Platte %util

Verbindung

A lokale Injektion -> lokales Maildir, 20 000 Nachrichten

1162

256

8 %

63 %

0,0 %

B CAROL-Host -> lokales Maildir, 20 000 Nachrichten

1210

334

7 %

65 %

2,8 %

C ALICE/BOB-Host -> Relay zum CAROL-Host, 2 000 Nachrichten

88

109

1 %

11 %

1,8 %

Kein Lauf verzeichnete eine fehlgeschlagene Nachricht, einen Serialisierungsfehler oder eine SMTP-Ablehnung. Pfad B ist die eingehende Zahl, die Sie zitieren sollten. Er lief bei 7 % CPU-Auslastung des Hosts, also in der Lage, die Tuning-Hinweise unter PARALLELISM beschreiben; welche Grenze bindend war, hat dieser Lauf nicht ermittelt. Pfad C ist die Geschwindigkeit des CAROL-Hosts: Als die Warteschlange hier leer war, hatte er alle 2 000 Nachrichten angenommen und 296 davon zugestellt.

27.8. Reproduzieren

Die Benchmarks sind nicht Teil von make integrationtests (sie sind langsam und belasten den MTA bewusst). Führen Sie sie gegen eine installierte Pipeline aus mit:

tests/08-perstage-bench.sh tests/test-accounts.ini
tests/09-latency-bench.sh  tests/test-accounts.ini
tests/10-goodput-bench.sh  tests/test-accounts.ini
# or all three in order:
make benchmarks ACCOUNTS=tests/test-accounts.ini

Jedes Skript senkt das STATS_INTERVAL/POLL_INTERVAL des Dispatchers für die Dauer des Laufs, sodass die Zähler umgehend weggeschrieben werden, und stellt beim Beenden die installierte Konfiguration wieder her (einschließlich der Zulassungsobergrenzen, die der Nutzdurchsatz-Lauf anhebt). Siehe das Kapitel Benchmark-Suite für die vollständige Methodik und die verfügbaren Stellgrößen (Nachrichtengrößen, Stichprobenzahlen, Steigerungsparameter, Zulassungs-Überschreibungen).

Das SQL-gespeiste Leerlaufen ist keines der Skripte, denn es umgeht absichtlich den Teil von Pepsi, durch den die Skripte gehen. Fügen Sie mit derselben Taktung und demselben PARALLELISM, die der Nutzdurchsatzlauf setzt, den Rückstau direkt ein und messen Sie, wie lange die Warteschlange zum Leerwerden braucht:

INSERT INTO pepsi.workqueue
  (token, mail_from, rcpt_to, from_header, subject, headers, body,
   stage, status, state)
SELECT 'bulk-' || g, 'sender@example.org', ARRAY['dave@<your-host>'],
       'sender@example.org', 'bulk ' || g,
       convert_to('From: sender@example.org' || E'\r\n' || '…' || E'\r\n', 'UTF8'),
       convert_to(repeat('filler' || E'\r\n', 300), 'UTF8'),
       'init', 'pending', '{"local_origin": false, "spam": false}'::jsonb
FROM generate_series(1, 50000) g;

Eine Transaktion, sodass der Dispatcher den ganzen Rückstau auf einmal sieht; pollen Sie dann SELECT count(*) FROM pepsi.workqueue, bis es null erreicht. Nichts benachrichtigt den Dispatcher, das übt also auch den POLL_INTERVAL-Herzschlag aus.