跳转到内容

大批量文件生成

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 的 NextPdfServiceProvider 会将 FontRegistry 注册为一个经预热后再 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——引擎不可变的单次渲染度量快照(时间、峰值内存、页数、警告),用于依据真实数据确定容量规模。