Zum Inhalt springen

Streaming und Speicher: Tutorial für Profiling und Batch-Worker

NextPDF rendert in einem einzigen Durchlauf und hält nie ein dokumentweites DOM im Speicher. Dadurch wird der eingangsseitige Speicherbedarf durch die Verschachtelungstiefe begrenzt, nicht durch die Anzahl der Elemente. Diese Seite erläutert das Streaming-Modell, die Vorgaben aus ADR-001 und wie Sie die Engine sicher in einem dauerhaft laufenden Queue-Worker betreiben.

Terminal-Fenster
composer require nextpdf/core:^3

NextPDF bietet zwei Schreibpfade mit unterschiedlichen Speicherprofilen.

Der standardmäßige In-Memory-Writer setzt das gesamte Dokument zusammen und serialisiert es anschließend. Der Speicherspitzenwert wächst mit der gesamten Ausgabegröße — für typische Dokumente unproblematisch, bei sehr großen jedoch teuer.

Der Streaming-Writer serialisiert jede Seite, während sie zusammengesetzt wird, und gibt sie aus, bevor die nächste Seite beginnt. Die ausgelieferte Engine — StreamingPdfWriter, StreamingCursor, DevNullWriter und das WriterState-Enum in src/Writer/Streaming/ — ist vorhanden, final und getestet; sie wird seit 3.1.0 ausgeliefert. Sie wird über die Verträge des experimental-Tiers StreamingWriterInterface und CursorInterface bereitgestellt. Die Engine-Klassen sind intern; stützen Sie sich daher auf die Verträge und überlassen Sie Core die Bereitstellung der Implementierung. (Eine ältere .ai/contracts-map.md-Annotation beschrieb Streaming fälschlicherweise als „contract-only / no implementation“; das ist ein Fehler aufgrund einer veralteten Annotation, der in Issue #610 verfolgt und in der B1-Vertragsdokumentation korrigiert wurde — die Engine wird seit 3.1.0 ausgeliefert.)

Das Designziel der Streaming-Engine ist, dass der residente Speicherbedarf nicht mit der Seitenanzahl wächst. Der Puffer jeder fertiggestellten Seite wird an den Writer übergeben und freigegeben, und die Cross-Reference-Tabelle sowie die /Kids-Seitenbaum-Referenzen werden in temporäre php://temp/maxmemory:0-Streams geschrieben, die Daten sofort auf die Festplatte auslagern, statt sie im PHP-Heap anzusammeln. Das serialisierte Ergebnis ist ein standardkonformer Seitenbaum, dessen Count-Eintrag die Anzahl der Blattknoten (Seitenobjekte) ist, die von einem Knoten abstammen (ISO 32000-2 §7.7.3.3), und dessen Kids-Eintrag ein Array indirekter Referenzen auf die unmittelbaren Kinder dieses Knotens ist (ISO 32000-2 §7.7.3.2). Das genaue Speicherprofil ist eine Eigenschaft des experimental-Tiers und kann sich zwischen Minor-Releases verschieben — kodieren Sie keine Annahmen aus einer einzelnen Messung fest.

ADR-001 regelt das Speichermodell der HTML-Render-Pipeline. Der Tokenizer erzeugt in einem Durchlauf eine Tokenliste; der Parser verarbeitet sie von links nach rechts und gibt Content-Stream-Operatoren in einen Stringpuffer aus. Es wird kein dauerhafter Elementbaum aufgebaut: Der Parser hält höchstens einen HtmlStyleState pro Verschachtelungsebene vor, begrenzt durch MAX_NESTING_DEPTH = 100, und erzwingt eine harte Obergrenze von MAX_ELEMENT_COUNT = 50_000. Die beiden Operationen, die Lookahead benötigen — die Tabellenspalten-Dimensionierung und die Selektor-Familie :has() / :last-child — verwenden begrenzte Index-Arrays für Vorab-Scans über die flache Tokenliste, kein vorgehaltenes DOM. Im Phase-0-Benchmark (docs/architecture/adr-001-memory-benchmark.md, ausgeführt am 2026-04-06, PHP 8.5.3, memory_limit=1G) wurde für ein Dokument mit 50,000 Elementen ein Spitzenwert von 50 MB im Stream-Pfad gemessen; dem gegenüber stand eine simulierte, teilweise vorgehaltene Arbeitslast mit 4 MB. Die Analyse des Berichts ordnet davon rund 50 MB dem architekturinvarianten akkumulierten Content-Stream zu und isoliert für dieses Fixture einen eingangsseitigen Speichervorteil von 4–5x für das Stream-Modell. Diese Werte wurden auf genau diesem Rig und Fixture beobachtet; sie sind keine Garantie.

Profilieren Sie den Speicher, bevor Sie optimieren

Abschnitt betitelt „Profilieren Sie den Speicher, bevor Sie optimieren“

Messen Sie, bevor Sie etwas ändern. Die HTML-Pipeline wird durch tools/perf-benchmark.php (ausgeführt über composer ai:perf-check) abgesichert, das peak_memory_delta_bytes meldet — den inkrementellen Spitzenwert pro Ziel, der die Regressionsachse ist, nicht den absoluten Prozess-Spitzenwert. Die Cycle-36-Baseline (docs/architecture/PERFORMANCE-BUDGETS.md §6.3, erfasst am 2026-05-17 auf einem i9-13900K, 64 GB, PHP 8.5.3, opcache aus) hat bei 12 von 16 target/mode-Paaren ein Spitzenwert-Delta von 0 Byte beobachtet; die vier Deltas ungleich null werden auf First-Touch-Font-Cache- und Trace-Puffer-Allokationen zurückgeführt, die bei nachfolgenden Render-Vorgängen konstant bleiben. Lesen Sie diese Werte als Beobachtungen auf diesem Rig, nicht als übertragbare Konstanten. Für ein Ad-hoc-Profil Ihres eigenen Dokuments erfassen Sie memory_get_peak_usage(true) vor und nach dem Render und setzen den Spitzenwert mit memory_reset_peak_usage() zwischen den Iterationen zurück, so wie der Benchmark die Kosten pro Ziel isoliert.

Ein Queue-Worker ist ein langlebiger PHP-Prozess: Er bootet das Framework einmal und bleibt resident, während er Jobs in einer Schleife abarbeitet. Das macht ihn schnell, macht aber zugleich Speicherhygiene wichtig. Ein schleichendes Leck, das bei einer einzelnen Anfrage unsichtbar ist, sammelt sich über Tausende von Jobs an. PERFORMANCE-BUDGETS §1 benennt diesen Fehlermodus ausdrücklich: Ein Worker, der viele PDFs hintereinander rendert, kann nach Stunden den Speicher erschöpfen, selbst wenn einzelne Render-Vorgänge unauffällig aussehen.

NextPDF unterstützt Worker-Umgebungen. DocumentFactory erlaubt einem Worker, pro Job ein frisches Dokument zu erstellen und dabei eine FontRegistry und ImageRegistry für die gesamte Prozesslebensdauer gemeinsam zu nutzen, sodass Schriften und Bilder einmal und nicht pro Job geparst werden. ADR-001 hält fest, dass der HTML-Parser pro Anfrage ohne statischen veränderlichen Zustand konstruiert wird und dass künftige Formatting-Context-Objekte auf dieselbe Weise pro Anfrage abgegrenzt werden müssen. Mit den folgenden Schritten konfigurieren Sie einen Worker sicher.

Erstellen Sie die Registries einmal beim Prozessstart und verwenden Sie sie für jeden Job wieder, wie in examples/14-worker-factory.php gezeigt:

<?php
declare(strict_types=1);
require_once __DIR__ . '/../vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Core\PdfFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// Created once at process boot — not per job.
$fontRegistry = new FontRegistry();
$imageRegistry = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$documentFactory = new DocumentFactory($fontRegistry, $imageRegistry);
$factory = PdfFactory::new()
->withCompress(true)
->withDocumentFactory($documentFactory);
// Per job: a fresh document, shared registries.
$doc = $factory->create();
$doc->addPage();
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Rendered inside a worker.', newLine: true);
$doc->save('/path/to/output.pdf');

Der Wert maxCacheBytes der Image-Registry begrenzt den gemeinsamen Cache, sodass er jobübergreifend nicht unbegrenzt wachsen kann.

Schritt 2 — Die Lebensdauer des Workers begrenzen

Abschnitt betitelt „Schritt 2 — Die Lebensdauer des Workers begrenzen“

Das ist allgemeine Praxis der Prozesssteuerung für jeden PHP-Worker, keine Engine-Garantie von NextPDF: Starten Sie Worker regelmäßig neu, damit ein langlebiger Prozess nicht unbegrenzt Speicher ansammelt oder veralteten Code ausführt. Beide großen PHP-Queue-Systeme bieten eingebaute Limits und sanfte Neustarts.

Bei Laravel-Queues (https://laravel.com/docs/12.x/queues) führt der Befehl queue:work den Worker als langlebigen Prozess aus. Die dokumentierten Optionen sind --memory (Standard 128 MB; der Worker beendet sich, wenn sein Speicher das Limit überschreitet), --max-jobs (Beenden nach einer bestimmten Anzahl von Jobs) und --max-time (Beenden nach einer bestimmten Anzahl von Sekunden). Der Befehl queue:restart signalisiert den Workern, sich nach dem aktuellen Job sauber zu beenden, sodass ein Deployment oder ein periodischer Timer sie neu starten kann, ohne einen laufenden Render zu unterbrechen. Laravel Horizon (https://laravel.com/docs/12.x/horizon) überwacht Redis-Worker mit einer auto-Balancing-Strategie und einem sanften php artisan horizon:terminate, das laufende Jobs abschließt, bevor der Prozessmonitor den Supervisor neu startet.

Bei Symfony Messenger (https://symfony.com/doc/current/messenger.html) läuft der Befehl messenger:consume standardmäßig endlos. Die dokumentierten Limit-Optionen sind --limit (N Nachrichten verarbeiten und dann beenden), --memory-limit (zum Beispiel 128M; beenden, wenn der Speicher das Limit erreicht) und --time-limit (zum Beispiel 3600; nach Ablauf des Intervalls beenden). Die Symfony-Dokumentation empfiehlt, den Worker unter Supervisor oder systemd zu betreiben, damit ein beendeter Prozess automatisch neu startet, und messenger:stop-workers setzt ein Cache-Flag, das jedem Worker mitteilt, seine aktuelle Nachricht abzuschließen und sich sauber zu beenden.

Signalisieren Sie bei jedem Deployment einen sanften Neustart, damit Worker den neuen Code übernehmen: php artisan queue:restart (oder php artisan horizon:terminate) für Laravel, php bin/console messenger:stop-workers für Symfony. Der Prozessmanager — Supervisor, systemd oder der Horizon-/Octane-Supervisor — startet dann einen frischen Prozess mit der neuen Codebasis. Das ist allgemeine Deployment-Praxis für langlebige PHP-Worker und unabhängig von NextPDF.

Das Design des Streaming-Pfads begrenzt den Speicherspitzenwert, indem es jede fertiggestellte Seite ausgibt und die Cross-Reference- und Seitenbaum-Buchführung auf festplattengestützte temporäre Streams auslagert, sodass der residente Speicherbedarf absichtlich nicht mit der Seitenanzahl wachsen soll. Dieses Verhalten wurde in der ausgelieferten 3.1.0-Engine beobachtet und durch deren Golden-Baseline-Reproduzierbarkeitstests fixiert, wird aber als Designverhalten und nicht als feste Zahl angegeben, weil das Profil eine Eigenschaft des experimental-Tiers ist. Der eingangsseitige Speicherbedarf der HTML-Pipeline wird durch MAX_NESTING_DEPTH = 100 statt durch die Anzahl der Elemente begrenzt (ADR-001). Alle konkreten Zahlen auf dieser Seite sind an datierte Artefakte gebunden — den ADR-001-Benchmark vom 2026-04-06 und die PERFORMANCE-BUDGETS-Cycle-36-Baseline vom 2026-05-17 — und wurden auf den Rigs beobachtet, die diese Dokumente nennen; behandeln Sie sie als Beobachtungen, nicht als übertragbare Garantien. Das performance_budget von 1500 ms / 64 MB ist die Canvas-Hülle, keine vertragliche Obergrenze.

Die Methode writeContent() des Streaming-Cursors hängt Bytes wörtlich an den Content-Stream der Seite an — sie validiert keine Operator-Syntax. In einem Worker, der durch Aufrufer beeinflusste Inhalte rendert, übergeben Sie niemals nicht vertrauenswürdige Eingaben an writeContent(); verwenden Sie writeText(), das der ausgelieferte Cursor für die PDF-Grammatik literaler Strings escapt. Der Aufrufer besitzt den Ausgabestream: Die Engine schreibt hinein, schließt oder öffnet ihn jedoch nie erneut, sodass sie die Ausgabe nicht umleiten kann — ein Worker muss das Handle selbst schließen, nachdem das close() des Writers zurückgekehrt ist, sonst entsteht über Jobs hinweg ein File-Descriptor-Leck. Registries jobübergreifend zu teilen, ist eine Performance-Optimierung, keine Vertrauensgrenze: Eine gemeinsame ImageRegistry cacht geparste Bilder; dimensionieren Sie ihr maxCacheBytes daher bewusst und gehen Sie in einem mandantenfähigen Worker nicht von einer Cache-Isolation zwischen Mandanten aus.

BehauptungStandardKlauselNachweis
Der Streaming-Writer gibt einen Seitenbaum aus, dessen Kids-Eintrag ein Array indirekter Referenzen auf die unmittelbaren Kinder des Knotens ist.ISO 32000-2§7.7.3.2
Der Streaming-Writer gibt einen Count-Eintrag aus, der der Anzahl der vom Seitenbaumknoten abstammenden Blatt-Seitenobjekte entspricht.ISO 32000-2§7.7.3.3

Klauseln werden paraphrasiert und am Glossar ausgerichtet; normativer Text wird nicht wiedergegeben.

  • Verträge / Streaming — die Verträge experimentalStreamingWriterInterface und CursorInterface und ihre Zustandsmaschine.
  • HTML / Streaming-Constraints (ADR-001) — die Entscheidung für Single-Pass und gegen ein vorgehaltenes DOM sowie die Schwellenwerte für eine erneute Prüfung.
  • Performance — das Gate für Latenz- und Speicherregressionen der HTML-Pipeline.
  • Layout — die Seitenausstattungs-Engines, die keinen Zustand pro Seite halten.
  • PERFORMANCE-BUDGETS — der Fehlermodus eines Workers mit Speicherleck und die Baseline des Regressions-Gates.