跳到內容

高量文件產生

Spec: ISO 24495-1:2023, §5 Spec: ISO 9241-112:2025, §6.1.2.3 Evidence: Benchmark-backed

產生一份 PDF 是一次函式呼叫。依排程產生十萬份,則是一個系統問題:記憶體必須維持有界、工作必須能平行處理,而數字必須有意義。本頁帶你走過批次產生情境,從吞吐量問題一路到撐得住的部署形態。它會直白說明:誠實的答案是「在你自己的文件上量測它」,而不是一個招牌數字。

批次產生有兩種典型失敗方式。第一種是記憶體蠕變:長壽命工作行程逐份文件累積保留狀態,直到批次途中被終止,導致這趟執行既沒有完成,也沒有乾淨地失敗。第二種是信心十足卻毫無意義的數字:把一份微不足道文件的基準測試,用來替一組要渲染複雜文件的機群定規模;它的錯,只有在生產負載下才會顯露。

這兩種都能避免,但前提是你一開始就把記憶體形態與量測方法納入設計,而不是第一次事故之後才補上。

  • 工作單位是一份可拋棄的文件,不是共用的文件。 把行程生命週期資料(字型、影像快取)保存在共用登錄表中;每次渲染都建立並丟棄該文件。
  • 記憶體有兩個部分;對長壽命工作行程來說,只有一個真正重要。 渲染期間短暫的 尖峰 在預期之內;不會回復的 保留 記憶體,才是會終結批次的洩漏。
  • 吞吐量來自平行度,以及有界的單次渲染成本。 能撐住負載的形態,是讓佇列餵給無狀態工作行程,由各工作行程各自渲染並釋放。
  • 沒有方法的數字不算數字。 NextPDF 會把每次渲染的量測結果作為你蒐集的資料回報,並拒絕未經限定的速度宣稱。最重要的數字,是你在自己的範本上量測出來的那一個(ISO 24495-1 §5.x11——把重要的訊息放在讀者會找到的地方)。

這套架構建立在一個單一決定上:存續於行程的狀態是共用且不可變的;存續於一次渲染的狀態則是嶄新且被丟棄的。 字型是只解析一次後便鎖定的結構性資料,因此任何渲染都不能變動它們、污染下一次渲染。影像快取是一個有界的「最近最少使用」儲存區,永遠不會被鎖定,因此記憶體維持封頂,又不會跨請求洩漏。文件工廠是一個無狀態單例;它建立的每一份文件都可拋棄。

正是這種分離,讓工作行程能在 Octane、RoadRunner 或 Swoole 下安全地執行數小時。它從結構上消除了「請求 N 損壞請求 N+1」這種失敗模式,而不是寄望文件自行重設。

這個情境分為四個階段。

  1. Warm the shared state once On worker boot, parse and lock the font registry and size the image cache. This cost is paid once, not per document.
  2. Enqueue the work A queue holds the render jobs. The queue is the throughput dial — workers scale horizontally behind it.
  3. Render on a disposable document Each worker creates a fresh document from the factory, renders, emits the bytes, and lets the document go.
  4. Measure, then size Collect per-render time and peak memory. Size the fleet from measurements on your own templates, not a generic figure.
端到端的高量情境:共用的不可變狀態只預熱一次;每個工作都在一份可拋棄的文件上渲染並釋放;吞吐量藉由增加工作行程來擴展,而非把單一個放大。

框架橋接讓這種形態成為預設,而不是要你自行組裝的東西。Laravel 的 service provider 將字型登錄表註冊為一個已預熱、已鎖定的單例,並在每次解析時將文件繫結為嶄新的實例。它隨附一個佇列化工作,具備有界的重試次數、逾時與指數退避。該工作會在工作行程端驗證輸出路徑,因為序列化的佇列酬載可能在傳輸途中遭到竄改。Symfony 與 CodeIgniter 整合也遵循同樣的可拋棄文件、共用登錄表紀律。

這套記憶體模型有 程式碼佐證 Evidence: Code-backed Laravel 的 NextPdfServiceProviderFontRegistry 註冊為預熱後再 lock() 的單例、將 ImageRegistry 註冊為刻意 鎖定的有界 LRU 單例,並透過無狀態工廠將 Document 註冊為每次解析的繫結。可拋棄文件模型存在於接線中,而不是散文裡。GeneratePdfJob 帶有 triestimeoutbackoff,並在 handle() 內重新驗證其輸出路徑。

這個量測介面有 基準測試佐證 Evidence: Benchmark-backed 引擎會為每次產生發出一個不可變的 RenderReport,承載以毫秒計的渲染時間、以位元組計的尖峰記憶體、頁數、警告數,以及後備發生次數——這些正是你為機群定規模所需的精確輸入。另有一個獨立的記憶體碎片化分析器,可區分 尖峰(短暫)與 保留 記憶體。這項區別會告訴你,一個長壽命工作行程是健康的,還是正在緩慢洩漏。基準測試框架本身設定為重複進行帶有預熱的多輪迭代,因為單次計時只是雜訊。

這項紀律是一條 設計原則 Evidence: Design principle NextPDF 會連同方法一起回報效能,並拒絕未經限定的速度宣稱。這與本文件的撰寫方式一致—— Spec: ISO 24495-1:2023, §5 把重要的訊息放在讀者會找到它的地方。這裡重要的訊息是「量測你自己的工作負載」。

以下程式碼是帶有量測的可拋棄文件迴圈,用來說明這個模型。引擎產生 RenderReport;佇列則是你的基礎設施。

<?php
declare(strict_types=1);
use NextPDF\Contracts\DocumentFactoryInterface;
use NextPDF\Observability\RenderReport;
use Psr\Log\LoggerInterface;
/**
* One batch worker iteration: render, emit, release, measure.
*
* The factory and its registries are process-lifetime singletons; the
* document is disposable. Retained memory must return to baseline between
* iterations or the worker is leaking.
*
* @param iterable<int, callable(\NextPDF\Core\Document): \NextPDF\Core\Document> $jobs
*/
function runBatch(
DocumentFactoryInterface $factory,
LoggerInterface $logger,
iterable $jobs,
): void {
foreach ($jobs as $jobId => $build) {
$startedAt = hrtime(true);
// Fresh, disposable document — shares the warmed registries.
$doc = $factory->create();
$doc = $build($doc);
$bytes = $doc->getPdfData();
// Hand the bytes off to your sink (object store, response, etc.).
unset($doc, $bytes); // let the per-render state go
$elapsedMs = (hrtime(true) - $startedAt) / 1_000_000;
$logger->info('pdf.render.complete', [
'job_id' => $jobId,
'render_time_ms' => round($elapsedMs, 2),
'peak_memory_mb' => round(memory_get_peak_usage(true) / 1_048_576, 2),
]);
}
}

這個 unset() 不是裝飾用途。每次渲染的狀態都應該在每一輪迭代中釋放,讓保留記憶體回到基線。若工作行程的基線在多輪迭代間持續攀升,正是這個迴圈要避免的失敗。

最典型的誤解是 「NextPDF 每秒能做幾份 PDF?」,彷彿它有單一答案。事實並非如此,而引用這樣一個數字,正是機群規模被定錯的方式。渲染成本主要取決於文件,因此唯一值得據以行動的數字,是用引擎自家的每次渲染報告、在你自己的範本上量測出來的那一個。沒有文件、硬體與方法支撐的數字只是裝飾,不是資料。

第二個誤解是,尖峰記憶體才是該盯著看的東西。尖峰是短暫且在預期之內的——它會回復。會終結一個批次的數字,是不會回復的 保留 記憶體。這正是引擎將兩者區分開來的原因。

  • 並沒有一個通用的吞吐量數字,而本頁也刻意不陳述任何一個。 渲染成本取決於你的文件;請用每次渲染報告來量測。
  • 有界記憶體取決於是否採用可拋棄文件模型。 跨多次渲染持有同一份文件,或共用可變的每次渲染狀態,都會使這項保證失效。框架橋接預設採用安全形態。手寫接線則必須複製它。
  • 影像快取是有界的,不是無界的。 在包含大量獨特影像的工作負載下,LRU 會逐出。這是設計,不是退化。
  • 工作行程池規模、佇列選擇與自動擴展,都是引擎以外的部署決策。 NextPDF 提供量測與有界的基本元件。它不會替你執行佇列。
  • RenderReport 是資料,不是裁決。 它告訴你一次渲染中發生了什麼。把它轉化為容量規劃,則是你的分析。
  • 本頁在量測介面方面有基準測試佐證,在記憶體模型方面有程式碼佐證。它並未斷言任何特定速率。
Queued high-volume generation primitives — edition availability
Edition Availability
Core

可拋棄文件模型、共用的不可變登錄表、 每次渲染的 RenderReport,以及記憶體碎片化分析器都屬於 Core。純粹的高量 PDF 產生並不需要商業層級。

Pro

相同的基本元件;商業功能(簽署、PDF/A)會增加你應該量測、 而非假設的每次渲染成本。

Enterprise

相同的基本元件;結構化發票與驗證工作會進一步增加隨酬載與規則集大小而成長的每次渲染成本。

  • 可拋棄文件——為單次渲染建立,之後即丟棄的文件實例,因此沒有任何狀態會洩漏到下一次渲染。
  • 共用登錄表——行程生命週期內、預熱後即不可變的狀態(字型、影像快取),可跨渲染重複使用,且沒有每次渲染成本。
  • 尖峰記憶體——渲染期間短暫的最高水位線;在預期之內,並會回到基線。
  • 保留記憶體——渲染完成後仍持有的記憶體;跨渲染持續上升的保留基線,就是洩漏。
  • 工作行程——從佇列拉取渲染工作的長壽命行程;必須讓記憶體維持有界,才能撐過一個批次。
  • RenderReport——引擎不可變的每次渲染度量快照(時間、尖峰記憶體、頁數、警告),用於依據真實資料來定容量規模。