跳转到内容

在长时间运行的 worker 中安全生成 PDF

长时间运行的 PHP worker(RoadRunner、Swoole、Laravel Octane)会让进程跨多个请求持续存活。在每个请求中都重新解析相同字体、重新解码相同图像,会浪费 CPU,并使常驻内存持续增长。为了避免这种情况,NextPDF 将两类生命周期拆分开来:

  • 进程生命周期、可共享: FontRegistryImageRegistry 保存已解析的字体表和已解码的图像缓存。在 worker 启动时创建一次即可。
  • 请求生命周期、可丢弃: Document,由 DocumentFactory::create() 返回。创建它、写出它,然后让它离开作用域。随后垃圾回收器会回收整个对象图。

这份 recipe(范例)会给出 worker 的启动序列、每个请求的主体,以及通过周期性 reset 让峰值内存保持平稳的做法。

Terminal window
composer require nextpdf/core:^3

worker 模式本身不需要任何额外扩展,而 worker runtime(RoadRunner / Swoole / Octane)则是可选的。同一个 factory(工厂)模式也能在单纯的 CLI for 循环中运行,这也正是测试工具所验证的场景。

对 worker 而言,推荐入口是 DocumentFactory。用一个共享的 FontRegistryImageRegistry 构建它一次:

  • FontRegistry::warmup() 会解析你指定的字体库,并缓存解析后的字体表。随后 FontRegistry::lock() 会冻结注册表,防止任何按请求执行的代码修改共享字体集合。isLocked() 会返回当前状态。锁定后,这个注册表就能安全地跨多个并发协程共享。
  • 构建 ImageRegistry 时设置一个 maxCacheBytes 预算。超出该预算时,它会逐出最近最少使用的条目。单张大于预算的图像会完全跳过缓存,不会冲垮缓存。
  • ImageRegistry::reset() 会逐出每一张已缓存的图像,同时让注册表保持完全可用。下一个请求会按需重新填充缓存。请按固定频率调用它(每 N 个请求一次,或当 memoryUsage() 超过某个阈值时),让内存高水位回到基准线。

factory 创建的每一份文档都是独立的 PDF。ISO 32000-2 §7.5.5 将从未更新过的文件的尾随数据定义为没有 Prev 项,而每个 worker 请求生成的正是这样一份第一代文件。因此,各请求之间并不共享文档状态,即使它们确实共享字体和图像缓存。子集字体的 BaseFont 标签(ISO 32000-2 §9.6.4)会跨请求保持稳定,因为已解析的字体存在于共享注册表中。

这份 recipe 的 API 接口来自 NextPDF\Core\DocumentFactoryNextPDF\Typography\FontRegistryNextPDF\Graphics\ImageRegistryNextPDF\Support\MemoryReport 上的 PHPDoc。下方用到的关键成员包括 DocumentFactory::create()FontRegistry::warmup() / lock() / isLocked() / memoryUsage()ImageRegistry::reset() / memoryUsage(),以及 MemoryReport::$currentBytes / $peakBytes / $entryCount / utilizationPercent()

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot (run ONCE, before the request loop) ---------------------
$fonts = new FontRegistry();
$fonts->lock(); // freeze the shared font set
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
// --- Per request ---------------------------------------------------------
$doc = $factory->create();
$doc->setTitle('Worker output');
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, 'Generated in a shared-registry worker', newLine: true);
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
// $doc leaves scope here → GC reclaims the whole document tree.

完整范例会遵守测试工具的输出通道。它展示了启动序列、有界请求循环、周期性 reset(),以及一项针对内存高水位的断言。这就是可重现性测试工具会运行两次的脚本。

<?php
declare(strict_types=1);
require_once __DIR__ . '/vendor/autoload.php';
use NextPDF\Core\DocumentFactory;
use NextPDF\Graphics\ImageRegistry;
use NextPDF\Typography\FontRegistry;
// --- Worker boot: shared, process-lifetime registries --------------------
$fonts = new FontRegistry();
$fonts->lock(); // share-safe once locked
$images = new ImageRegistry(maxCacheBytes: 50 * 1024 * 1024);
$factory = new DocumentFactory($fonts, $images);
$resetEvery = 4; // reset cadence in requests
$peakAfterReset = 0;
// --- Simulated request loop ---------------------------------------------
for ($request = 1; $request <= 12; $request++) {
$doc = $factory->create();
$doc->setTitle("Worker Request #{$request}");
$doc->addPage();
$doc->setFont('helvetica', 'B', 16);
$doc->cell(0, 12, "Worker Request #{$request}", newLine: true);
$doc->setFont('helvetica', '', 11);
$doc->cell(0, 8, 'Shared FontRegistry / ImageRegistry across requests.', newLine: true);
// The harness captures the LAST request's PDF via the side channel.
if ($request === 12) {
$doc->save(getenv('NEXTPDF_COOKBOOK_OUTPUT') ?: __DIR__ . '/out.pdf');
} else {
$doc->getPdfData(); // force render, then drop
}
unset($doc); // explicit end-of-request
// Bound the cache high-water mark on a fixed cadence.
if ($request % $resetEvery === 0) {
$images->reset();
\gc_collect_cycles();
$report = $images->memoryUsage();
$peakAfterReset = \max($peakAfterReset, $report->currentBytes);
}
}
$final = $images->memoryUsage();
fwrite(STDERR, \sprintf(
"fonts.locked=%s images.entries=%d images.current=%dB peak_after_reset=%dB\n",
$fonts->isLocked() ? 'yes' : 'no',
$final->entryCount,
$final->currentBytes,
$peakAfterReset,
));

STDOUT 保留给测试工具使用;进度文本输出到 STDERR。PDF 只会写入 NEXTPDF_COOKBOOK_OUTPUT,绝不会回显到输出。

  • 共享前先锁定。 在启动时调用 FontRegistry::lock()。当两个协程访问一个仍可变更的注册表时,就会形成数据竞争。请在健康检查中使用 isLocked() 作为断言。
  • reset() 不是 unset() ImageRegistry::reset() 会逐出已缓存的二进制数据,但会让注册表保持可用,因此它才是正确的周期性调用。在每个请求中都销毁并重建注册表,会彻底违背共享缓存的初衷。
  • 超大图像的跳过机制。 大于 maxCacheBytes 的图像会在每次使用时解码,并且绝不缓存,因此它无法逐出工作集。这是有意设计的。请根据常见图像大小设置预算,而不是根据罕见的大型图像。
  • 文档必须离开作用域。Document 存放在静态变量、长生命周期的容器绑定,或被 worker 捕获的闭包中,会让整个对象图持续存活,并破坏每请求的回收机制。一次 unset() 调用或一次作用域结束是必要的。
  • gc_collect_cycles() 的摆放位置。 PHP 的循环回收器并不知道请求边界的存在。请在 reset 频率之后调用它,而不是在每个请求中都调用。这样可以让内存高水位保持有界,同时避免在热路径上付出回收成本。
  • 确定性的注意事项。 文档时间戳记与尾随数据的 /ID 会在每次保存时重新生成(ISO 32000-2 §14.3)。因此,捕获到的 PDF 会以语义配置文件来比对(结构化 AST 加上元数据,绝不比对易变的字节)。请参阅「符合性」一节。
  • 共享注册表会把重复的字体解析与图像解码转化为一次性的启动成本。这样,每个请求的工作就只剩排版与序列化。
  • 峰值常驻内存的上限是 maxCacheBytes 加上一份处理中文档的工作集。周期性 reset() 会把缓存拉回基准线,因此长期存活的 worker 不会呈现持续上升的锯齿状曲线。
  • 这个 performance_budget front-matter(wall_ms: 4000peak_mb: 192)会为这个 12 个请求循环的测试工具设置运行上限。测试工具会强制执行这项上限;它并不是对任意文档的保证。
  • 这份 recipe 为 §4.3 缺口清单中的「memory/GC」项目提供了 #31 的覆盖范围。作为后盾的 examples/14-worker-factory.php 已存在,而新增的 tests/Cookbook/Php/WorkerSafeBatchRenderingRecipeTest.php 补上了缺失的 memory/GC 断言(峰值内存在 reset 之后不会跨周期增长)。
  • worker 模式在每个请求中只处理一份文档,并且只共享已解析的字体缓存与已解码的图像缓存。没有任何文档内容会跨越请求边界。一个请求无法通过共享注册表读取另一个请求的文档数据。
  • 未受信任的输入仍会经过正常的 NextPDF 输入边界,而 worker 模式不会放宽任何验证。请把每个请求的 HTML/资产输入都视为未受信任,就像在每请求各自独立的进程中那样。
陈述规范条款参考 ID
文档的修改日期会在每次保存时重新生成,因此每请求输出并非字节稳定。ISO 32000-2§14.3
每一份 worker 文档都是一个从未更新过的文件(尾随数据中没有 Prev);各请求之间不共享文档状态。ISO 32000-2§7.5.5
子集字体的标签前缀会跨请求保持稳定,因为已解析的字体存在于共享注册表中。ISO 32000-2§9.6.4

由于尾随数据的 /ID 与修改日期会在每次保存时重新生成,这份 recipe 会以语义可重现性配置文件来验证(结构化 AST 相等性加上仅比较元数据)。对 worker 输出而言,声称逐字节或结构性可重现都不严谨。