跳转到内容

流式处理与内存:性能分析和批处理 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 列表构建有界预扫描索引数组,而不是保留 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

条款均为改写并以词汇表锚定;不重现任何规范性原文。