跳转到内容

内存与流式处理

Spec: ISO 32000-2, §7.5.4 Evidence: Mixed evidence

大型 PDF 不应该需要大量内存。本页说明 NextPDF 如何在文档持续增长时让进程堆保持有界、何时流式写入磁盘而非持续累积,以及「性能预算」在此处所代表的意义——一份经过校验的契约,而非一个营销数字。

PDF 格式并未强迫生成器使用大量内存。其交叉引用表为每个间接对象记录一个字节偏移量,因此读取器只需要对文件进行随机访问,而不需要将整个文件载入内存。生成器可以仿效这一点:它可以在对象完成时即输出,并只记住它们的写入位置。相反,如果整份文档在最终写出之前都留在堆中,那么页数就会线性推高内存用量,一份在一百页时运作正常的报表,到了五万页就会导致进程失败。

对于批处理和工作进程负载来说,这正是服务稳定运行和在负载下不可预测地失败之间的差别。有界内存是一项需要通过工程设计实现的特性,而不是一个只能寄望的数字。

  • 流式写入器的设计目标是让内存保持 每份文档有界。每一页一旦定稿,就立即写入输出,随后释放其缓冲区。
  • 那些原本会随对象数量增长的记账数据——交叉引用偏移量以及页面树的 Kids 引用——会写入以 php://temp/maxmemory:0 打开的临时流,这些流会立即溢写至磁盘,而不会填满 PHP 堆。
  • 设计目标是 每页 O(1) 堆:持有文档并不会因为页面增加而付出更多成本。这正是写入器围绕实现的工程目标。
  • 性能预算 是文档系统中一个真实且结构化的概念:一个挂钟时间上限与一个峰值内存上限,以一份经过校验的契约形式表达。它陈述的是一项义务,而不是一项基准测试结果。
  • 具体数字被视为一种 动态信号,在一套明确的方法下测量得出,而不是固定在文字中悄然过时。

流式写入器的整体设计源自一个决策:能够输出的东西,就绝不持有。

  1. Start page A single active cursor; no document-wide page graph in memory.
  2. Finalise page Page content + page object written straight to the output stream.
  3. Release buffer The finalised page buffer is dropped; the heap returns to baseline.
  4. Record offset to disk Xref and Kids entries go to php://temp/maxmemory:0 — immediate disk spill.
  5. Close Pages-tree root, Catalog, and trailer written once at the end.
流式写入器的逐页循环:每一页都被输出并释放,而不断增长的记账数据则被送往以磁盘为后盾的临时流,因此堆不会随页数而增长。

磁盘溢写这个细节才是关键。PHP 的 php://temp 会先在内存中保留少量数据,只有超过某个阈值时才溢写。写入器以 maxmemory:0 选项打开这些临时流,强制它们 立即 溢写——内存阈值为零。实际效果是:那些按定义会随文档增长的逐对象记账数据,永远不会在堆中累积;它们累积在磁盘上,而磁盘容量并不是这里的限制因素。若没有该选项,默认的内存窗口就必须先填满才会溢写,这恰恰会在最关键的时刻破坏有界内存的目标。

性能预算 是故事的另一半:它是一份文档系统契约,而非一项营销主张。schema 将预算定义为两个有界整数:以毫秒为单位的挂钟时间上限,以及以 mebibyte 为单位的峰值常驻内存上限。一份声明了预算的配方,也就声明了一项可被校验的义务;这就像带有类型的签名声明了一项编译器可以校验的义务。预算的价值在于它是 被明确声明且被强制执行的,而不在于它很小。

本页属于 Evidence: Mixed evidence ,这种混合是刻意为之,因为证据确实有三种类型。

  • 由代码佐证的机制。 位于 src/Writer/Streaming/StreamingPdfWriter.php 的流式写入器记录并实现了逐页先输出后释放的循环,并以 php://temp/maxmemory:0 打开其 xref 与 Kids 流,以强制立即溢写至磁盘,使得「无论对象数量多少,PHP 内存都维持有界」。这种流式、单一游标、不保留树状结构的设计,也是 ADR-001 中所记录的架构决策(渲染管线最多只持有 O(depth) 的状态,而非 O(n) 个节点)。
  • 设计原则层面的预算。 performance_budget 字段是文档 schema 中一个真实且可选的部分,定义为 { wall_ms, peak_mb },并带有明确的上限。它在设计上就是一份可强制执行的契约。
  • 作为动态信号的基准测试。 ADR-001 明确指出,受控大型文档的峰值内存与挂钟时间数字是 一项在明确方法下收集并记录的实证目标——而非一个在文字中断言的数字。因此,本页陈述机制与契约,并将具体数字指向实际测量它们的地方。

这种文件格式使该目标合情合理,而非仅是空想。由于交叉引用表是一个逐对象的偏移量索引,依照 Spec: ISO 32000-2, §7.5.4 ,生成器便 能够 在完成各个对象时即写出它们,并只保留它们的偏移量。有界内存与文件格式相一致,而不是与之对抗。

有界内存是你「如何生成」的一种特性,而不是你所设定的某个标志。一个将每份文档定稿并释放的批处理循环,能让堆在整个执行过程中保持平稳:

<?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;
// Process-lifetime, shared once.
$factory = PdfFactory::new()
->withCompress(true)
->withDocumentFactory(new DocumentFactory(
new FontRegistry(),
new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024),
));
// Per-document, created and released each iteration.
foreach ($invoiceBatch as $invoice) {
$doc = $factory->create();
$doc->addPage();
$doc->writeHtml($invoice->toHtml());
$doc->save($invoice->outputPath());
unset($doc); // the document model is not carried into the next iteration
}

这些注册表之所以共享,是因为将字体与图像只解析一次正是工作进程的意义所在。而文档则 共享,并在每一轮都被释放——这正是让批处理内存受限于单一文档、而非受限于整个批次的关键。

最常见的误解是把「有界内存」当成一项基准测试主张——期待有一个 megabyte 数字可以引用。这恰恰颠倒了我们要表达的意思。这里的保证是 结构性 的:写入器的设计使得持有一份文档不会因为页面增加而付出更多成本。一个具体的峰值数字取决于页面内容、字体与图像,且唯有附上其测量方法时才有意义,这正是它属于基准测试而不属于本句陈述的原因。

第二个陷阱:以为 php://temp 已经保护了你。它确实会——但只在其默认的内存窗口填满之后。maxmemory:0 选项才是让溢写立即发生的关键。这个细节正是机制本身。少了它,这项特性恰恰会在它所要应对的大型文档下无法成立。

本页说明流式机制以及性能预算的意义。它 陈述实际测量到的峰值内存或吞吐量数字。那些数字由基准测试规范在一套明确的方法下产生,而 ADR-001 明确地将实证数字交由该测量来决定。「每份文档」有界并不代表无论单一文档内容为何都维持固定:一个内嵌许多大型图像的页面,仍须付出那些图像所需的成本。不会增长的是 逐页记账数据 与被保留的页面图。并非每一条生成路径都会使用流式写入器。哪些路径采用流式、哪些采用缓冲,是由代码与管线的形态决定,而非由本概览决定。所描述的机制截至本页审阅日期为止皆属正确。权威来源是核心仓库中的 src/Writer/Streaming/ 与 ADR-001。

流式与有界内存的设计是 Core 的一项特性。各版本并不会改变它:

Bounded-memory streaming writer — edition availability
Edition Availability
Core Core 提供流式、磁盘溢写的写入器设计。
Pro Pro 沿用相同的有界内存写入器;它增添功能,而非采用不同的内存模型。
Enterprise Enterprise 沿用相同的有界内存写入器;它增添功能,而非采用不同的内存模型。
  • 有界内存——一种设计特性,即持有文档不会因为页面增加而耗用更多堆(也就是每页 O(1) 的目标)。
  • 流式写入器——将每一页输出并释放其缓冲区,而不保留整份文档的写入器。
  • php://temp/maxmemory:0——一个被强制立即溢写至磁盘的 PHP 临时流,用于那些不断增长的逐对象记账数据。
  • 性能预算——一份结构化的文档契约:一个挂钟时间上限与一个峰值内存上限,经过明确声明且可校验。
  • 动态信号——一个在明确条件下连同其方法一并报告的测量值,而非一个内嵌于文字中的固定数字。