跳到內容

串流與記憶體:分析與批次 worker 指南

NextPDF 以單次串流(single pass)繪製,且從不保留文件層級的 DOM,因此輸入端記憶體只受巢狀深度限制,而非元素數量。本頁說明串流模型、ADR-001 的約束,以及如何在長時間執行的佇列 worker 中安全執行引擎。

Terminal window
composer require nextpdf/core:^3

NextPDF 有兩條寫入路徑,記憶體特性各不相同。

預設的記憶體內寫入器(in-memory writer)會先組合完整文件,再將其序列化。尖峰記憶體用量會與輸出總大小同步成長——對一般文件沒問題,但對極大型文件代價高昂。

串流寫入器(streaming writer)會在每一頁組合完成時立即序列化,並在開始下一頁前先寫出。已隨產品出貨的引擎——StreamingPdfWriterStreamingCursorDevNullWriter,以及 WriterState 列舉(位於 src/Writer/Streaming/)——都是真實、定稿且已測試的程式碼,自 3.1.0 起就已出貨。這些引擎透過 experimental 層級的 StreamingWriterInterfaceCursorInterface 契約對外公開。引擎類別屬於內部實作,因此你只需依賴契約,讓 Core 提供實作。(早期某份 .ai/contracts-map.md 註記曾誤把串流描述為「僅有契約/無實作」;那是一項過時註記的缺陷,已在 issue #610 追蹤並於 B1 契約文件更正——引擎自 3.1.0 起就已出貨。)

串流引擎的設計目標是:常駐記憶體用量不隨頁數成長。每一頁完稿後的緩衝區會交給寫入器並隨即釋放,而交叉參照表與 /Kids 頁面樹參照則寫入 php://temp/maxmemory:0 暫存串流,立即溢寫到磁碟,而非累積在 PHP heap 中。序列化後的結果是一棵標準頁面樹,其 Count 項目是某節點下所有子孫葉節點(頁面物件)的數量(ISO 32000-2 §7.7.3.3),其 Kids 項目則是指向該節點直屬子節點的間接參照陣列(ISO 32000-2 §7.7.3.2)。確切的記憶體特性屬於 experimental 層級屬性,可能在不同 minor 版本之間變動——不要從單次量測就把某個假設寫死。

ADR-001 規範 HTML 繪製管線的記憶體模型。分詞器(tokenizer)以單次掃描產出 token 清單;剖析器(parser)由左至右消耗清單,並將內容串流運算子發出到一個字串緩衝區。不會建立持久的元素樹:剖析器在每個巢狀層級最多只持有一個 HtmlStyleState,並受 MAX_NESTING_DEPTH = 100 限制,同時強制一個 MAX_ELEMENT_COUNT = 50_000 硬上限。兩個需要前瞻(lookahead)的作業——表格欄寬計算,以及 :has() / :last-child 選擇器家族——使用對扁平 token 清單建立的有界預掃描 Index(索引)陣列,而非保留 DOM。Phase 0 基準測試(docs/architecture/adr-001-memory-benchmark.md,於 2026-04-06 執行,PHP 8.5.3,memory_limit=1G)量得一份 50,000 元素的文件,串流路徑尖峰為 50 MB;相較之下,保留部分工作量的模擬為 4 MB。報告的分析把其中約 50 MB 歸因於架構不變的累積內容串流,並就該測試樣本分離出串流模型在輸入端約 4–5 倍的優勢。這些數字是在那一台機器與那一份樣本上觀測到的,並非保證值。

在你動手改任何東西之前,先量測。HTML 管線由 tools/perf-benchmark.php(透過 composer ai:perf-check 執行)把關,它回報 peak_memory_delta_bytes——也就是每個目標的增量尖峰;回歸(regression)的判斷依據是這個數值,而非整個行程的絕對尖峰。Cycle 36 基準(docs/architecture/PERFORMANCE-BUDGETS.md §6.3,於 2026-05-17 擷取,硬體為 i9-13900K、64 GB、PHP 8.5.3、opcache 關閉)在 16 個 target/mode 配對中的 12 個量到 0 位元組的尖峰增量,其餘四個非零增量則歸因於首次觸碰的字型快取與追蹤緩衝區配置,這些在後續繪製時維持不變。請把那些數字當作那台機器上的觀測值,而非可移植的常數。若要對你自己的文件做臨時分析,請在繪製前後取樣 memory_get_peak_usage(true),並在每次迭代之間用 memory_reset_peak_usage() 重設尖峰,做法與基準測試隔離每個目標成本的方式相同。

佇列 worker 是一個長壽命的 PHP 行程:它只在啟動時載入 Framework(框架)一次,之後便常駐記憶體,並在迴圈中處理工作。這既是它快的原因,也是記憶體衛生如此重要的原因。在單一請求中看不見的緩慢洩漏,會跨數千個工作累積起來。PERFORMANCE-BUDGETS §1 明確點名這種失效模式:一個連續繪製大量 PDF 的 worker,即使單次繪製看起來正常,幾小時後仍可能耗盡記憶體。

NextPDF 支援 worker 環境。DocumentFactory 讓 worker 為每個工作建立一份全新文件,同時共用行程生命週期內的 FontRegistryImageRegistry,因此字型與影像剖析只需發生一次,不必每個工作各做一次。ADR-001 記載 HTML 剖析器是每個請求各自建構,且不持有靜態可變狀態,並要求未來的格式化情境物件必須遵循相同的每請求作用域。以下步驟會安全地設定一個 worker。

在行程啟動時只建立一次 registry,並在每個工作中重複使用,做法參照 examples/14-worker-factory.php

<?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');

影像 registry 的 maxCacheBytes 會限制共用快取的大小,避免它跨工作無上限成長。

這是任何 PHP worker 通用的行程控管實務,並非 NextPDF 引擎保證:定期重啟 worker,避免長壽命行程無限期累積記憶體或執行過時的程式碼。兩大 PHP 佇列系統都內建限制與優雅重啟機制。

Laravel queues (https://laravel.com/docs/12.x/queues) 為例,queue:work 指令會把 worker 當作長壽命行程執行。文件記載的選項有 --memory(預設 128 MB;當 worker 記憶體超過上限時退出)、--max-jobs(處理一定數量的工作後退出),以及 --max-time(經過一定秒數後退出)。queue:restart 指令會通知 worker 在目前工作完成後優雅退出,因此部署或週期計時器可以回收它們,而不會打斷正在進行中的繪製。Laravel Horizon (https://laravel.com/docs/12.x/horizon) 以 auto 平衡策略監管 Redis worker,並提供優雅的 php artisan horizon:terminate,它會在行程監管器重啟管理員之前先完成進行中的工作。

Symfony Messenger (https://symfony.com/doc/current/messenger.html) 為例,messenger:consume 指令預設會永遠執行。文件記載的限制選項有 --limit(處理 N 則訊息後退出)、--memory-limit(例如 128M;當記憶體達到上限時退出),以及 --time-limit(例如 3600;經過該間隔後退出)。Symfony 的文件建議在 Supervisor 或 systemd 之下執行 worker,讓退出的行程自動重啟,而 messenger:stop-workers 會設定一個快取旗標,告知每個 worker 完成目前訊息後乾淨退出。

每次部署時,發出優雅重啟的訊號,讓 worker 載入新程式碼:Laravel 用 php artisan queue:restart(或 php artisan horizon:terminate),Symfony 用 php bin/console messenger:stop-workers。接著行程管理器——Supervisor、systemd,或 Horizon/Octane 監管器——便會針對新的程式碼基底啟動一個全新的行程。這是長壽命 PHP worker 的通用部署實務,與 NextPDF 無關。

串流路徑的設計透過寫出每一頁完稿後的內容,並把交叉參照與頁面樹簿記溢寫到磁碟支援的暫存串流,把尖峰記憶體限制在固定範圍,使常駐集合不隨頁數成長——這在已出貨的 3.1.0 引擎中已觀測到,並由其黃金基準的可重現性測試固定下來;但因為這項特性屬 experimental 層級,因此陳述為設計行為而非固定數字。HTML 管線的輸入端記憶體受 MAX_NESTING_DEPTH = 100 限制,而非元素數量(ADR-001)。本頁所有具體數字都綁定一份標註日期的成品——2026-04-06 的 ADR-001 基準測試,以及 2026-05-17 的 PERFORMANCE-BUDGETS Cycle 36 基準——而且都是在那些文件所指名的機器上觀測到的;請把它們當作觀測值,而非可移植的保證。1500 ms/64 MB 的 performance_budget 是 canvas 的範圍上限,並非契約性的硬上限。

串流游標的 writeContent() 會把位元組原封不動地附加到頁面內容串流——它不驗證運算子語法。在繪製受呼叫端影響的內容時,worker 絕不要把不受信任的輸入傳給 writeContent();請改用 writeText(),已出貨的游標會依 PDF 文字字串語法替它進行逸出。輸出串流由呼叫端持有:引擎只寫入它,但從不關閉或重新開啟它,因此引擎無法重新導向輸出——worker 必須在寫入器的 close() 返回後自行關閉控制代碼,否則就會跨工作洩漏一個檔案描述符(file descriptor)。跨工作共用 registry 是一項效能最佳化,並非信任邊界:共用的 ImageRegistry 會快取剖析後的影像,因此請審慎設定它的 maxCacheBytes,且在多租戶 worker 中不要假設租戶之間有快取隔離。

宣稱標準條款證據
串流寫入器發出的頁面樹,其 Kids 項目是指向該節點直屬子節點的間接參照陣列。ISO 32000-2§7.7.3.2
串流寫入器發出的 Count 項目,等於頁面樹節點底下的葉頁面物件數量。ISO 32000-2§7.7.3.3

各條款均為改寫,並以詞彙表固定用語;不重現任何規範性原文。