故障排查:内存与性能
这些条目覆盖你在负载下会撞上的两类失败:一次渲染期间 PHP 内存耗尽,以及一个进程预热或饱和之后吞吐跌落悬崖。每个条目都点出一个症状、最可能的成因,以及一个使用真实 NextPDF 接口或标准 PHP-FPM 控件的修复。关于底层的流式模型和一个 worker 教程,阅读 流式与内存;本页是它的事故侧伴随页。
先测量。在一次渲染前后采样 memory_get_peak_usage(true),并在迭代之间调用
memory_reset_peak_usage(),就像引擎的基准隔离每次渲染开销那样。没有基线就调优只是移动悬崖,而非移除它。
条目:生成期间 “Allowed memory size exhausted”
标题为“条目:生成期间 “Allowed memory size exhausted””的章节- 症状。 一次渲染中止,带有一个来自 PHP 运行时的致命
Allowed memory size of <n> bytes exhausted,常发生在一个大的或图像密集的文档上。 - 可能成因。 默认写入路径先组合整个文档,再序列化它,因此峰值内存随总输出大小变化。一个大文档、大的嵌入图像,或一个大的嵌入字体字面可以把请求推过
memory_limit。 - 解决办法。
- 限制图像缓存。
NextPDF\Core\Config暴露imageCacheBytes(默认52428800,即 50 MB)。用实例 wither$config->withImageCacheBytes($bytes)(签名withImageCacheBytes(int $bytes): self)调低它,让一个嵌入许多图像的构建在一个已知上限上快速失败,而不是发生交换。这给内存中的图像缓存设上限;它不会对图像本身重新采样或重新编码。 - 嵌入前缩小输入。 Core 不会缩小或重新编码图像。在你嵌入超大栅格美术稿之前调整其尺寸并重新编码,并嵌入你实际使用的字体,让子集化要保留的字形集很小 (参见 缩减 PDF 文件大小)。
- 保持压缩开启。 一个全新的
Config把compress设为true。 普通构建保持它开启;withCompress(false)不是一个大小优化(它通常增大输出)。在你要调试或剖析流水线时再用它 —— 它转移 CPU/内存权衡(跳过压缩步骤),而非减少内存。 - 有意地、按 worker 提高
memory_limit。 这是一个标准 PHP 设置,不是一个 NextPDF 键。在池配置里设置它,或用ini_set('memory_limit', '256M')给 CLI/队列进程设置,并按一个剖析过的峰值来定它的大小,而不是猜。
- 限制图像缓存。
- 相关。 流式与内存。
条目:在很大的文档上内存随页数增长
标题为“条目:在很大的文档上内存随页数增长”的章节- 症状。 一个数千页的文档即便每页都很小也耗尽内存,且峰值大致随页数同步上升。
- 可能成因。 缓冲写入器把整个序列化文档保留在堆里。对于很大的文档,那是主导开销。
- 解决办法。
- 优先用流式写入路径。使用 流式与内存
中描述的有文档的流式写入路径:它在每页被组合时就序列化它并释放缓冲,这减少了页缓冲/输出增长;小的每对象元数据(偏移量、页树)仍可随页/对象数量变化。遵循有文档的入口点,而不是复制内部类 —— 底层的流式引擎是
experimental层,其符号不是稳定的公开接口。 - 对原生
writeHtml()解析器,记住输入侧内存同时被嵌套深度守护和元素数量守护限定:ADR-001 把嵌套上限设为MAX_NESTING_DEPTH = 100,并拒绝超过MAX_ELEMENT_COUNT = 50000的文档。一个撞上元素上限的文档会被明确告知,而不是悄悄耗尽内存。这些 ADR-001 上限只管原生解析器;可选的 Chrome 桥 (writeHtmlChrome())在进程外渲染,有它自己独立的内存/输入限制,而非这些上限。
- 优先用流式写入路径。使用 流式与内存
中描述的有文档的流式写入路径:它在每页被组合时就序列化它并释放缓冲,这减少了页缓冲/输出增长;小的每对象元数据(偏移量、页树)仍可随页/对象数量变化。遵循有文档的入口点,而不是复制内部类 —— 底层的流式引擎是
- 相关。 流式与内存。
条目:一个长寿命 worker 在许多作业后耗尽内存
标题为“条目:一个长寿命 worker 在许多作业后耗尽内存”的章节- 症状。 单次渲染成功,但一个背靠背渲染许多 PDF 的队列 worker 在几分钟或几小时后耗尽内存。
- 可能成因。 一个长寿命 PHP 进程跨作业累积分配。一次请求里看不见的缓慢增长,在数千次上累加起来。
- 解决办法。
- 共享注册表,重建文档。在启动时构建一次
FontRegistry和ImageRegistry并把它们传给一个DocumentFactory;用$factory->create($config)为每个作业创建一个全新的Document。字体和图像解析便对进程发生一次,而非每作业一次,而每作业的文档树在它离开作用域时被回收。遵循examples/14-worker-factory.php。 - 用
new ImageRegistry(maxCacheBytes: ...)限制共享的图像缓存,让它不能跨作业无限增长。 - 回收 worker —— 进程控制,而非引擎保证。在
PHP-FPM 里,设
pm.max_requests,让每个子进程在固定数量的请求后重生。在 Laravel 队列里用queue:work --max-jobs/--max-time/--memory;在 Symfony Messenger 里用messenger:consume --limit/--time-limit/--memory-limit。
- 共享注册表,重建文档。在启动时构建一次
- 相关。 流式与内存。
条目:在冷或未充分预热的进程上的吞吐悬崖
标题为“条目:在冷或未充分预热的进程上的吞吐悬崖”的章节- 症状。 一个全新进程里的最初几次渲染很慢,或每个请求都付一份热请求本不该付的解析开销。
- 可能成因。 两份冷启动开销叠加。没有 opcache 的 PHP
在每次请求都重新编译每个文件,而一个未预热的
FontRegistry在每个字体字面首次被用时解析它。 - 解决办法。
- 启用 opcache(并在它有帮助处启用 JIT)。 设
opcache.enable=1和一个慷慨的opcache.memory_consumption;在生产里设opcache.validate_timestamps=0,让缓存不被每请求重新检查。 那个设置要求一个在每次发布时重启或重载 PHP-FPM (或以其他方式重置 opcache,例如opcache_reset()/cachetool)的部署流程 —— 否则 opcache 会继续服务旧字节码,发布后跑的是陈旧代码。这些是标准 PHP ini 设置,不是 NextPDF 键。 - 在启动时预热并锁定字体注册表。 在一个
FontRegistry实例上,$fontRegistry->warmup($fontFiles)在启动期间把字面解析一次,而$fontRegistry->lock()冻结注册表,让请求时代码不能变更共享状态;$fontRegistry->isLocked()报告该状态。在一个真正长寿命的 worker 或应用服务器里 —— 一个队列消费者或一个跨许多请求保持同一 PHP 进程存活的 RoadRunner/Swoole/Octane worker —— 一个已预热、已锁定的注册表把它解析好的字面持有在对象状态里,把每请求的字体解析变成一次性的进程启动开销。在标准 PHP-FPM 请求模型下,那个预热好的对象状态不跨请求存活:opcache 缓存编译好的类和字节码,而非预热好的用户态对象状态,因此一个预热好的FontRegistry是每请求重建的(每次请求从子进程的引导重新运行一遍),而非在一个子进程内跨请求保持热。在普通 PHP-FPM 上,opcache 主要摊薄字节码重新编译的开销;接受字体解析是每请求付出,而非被消除。跨请求摊薄 —— 在进程的整个生命周期内把每个字面解析一次 —— 只适用于一个真正长寿命的进程,例如一个 RoadRunner/Swoole/Octane worker 或一个跨许多请求保持同一 PHP 进程存活的队列消费者。 - 不要每请求重新解析同一个模板。 通过共享的注册表在启动时把字体和可复用资源解析一次;只有每作业的
Document应当在请求里创建。
- 启用 opcache(并在它有帮助处启用 JIT)。 设
- 相关。 流式与内存。
条目:服务器在并发下饱和且延迟尖峰
标题为“条目:服务器在并发下饱和且延迟尖峰”的章节- 症状。 单独看每次渲染延迟没问题,但在负载下机器发生交换、CPU 饱和,或请求排队并超时。
- 可能成因。 对可用 RAM 而言 PHP-FPM worker 太多,于是 worker 峰值之和超过物理内存,主机发生交换;或 worker 太少,于是请求在一个小池后面串行化。
- 解决办法。
-
从一个剖析过的峰值定
pm.max_children的大小。 使用标准公式:pm.max_children = (total RAM - OS/other overhead) / per-worker peak memory用一个有代表性的文档测量一个 worker 的真实峰值(参见范围里的剖析说明),为 OS 和任何同机服务预留余量,然后做除法。留一道余地;不要把大小定到 RAM 的 100%。
-
把压缩开销钉进你的预算。Flate 压缩可以是写一个流的一项可观 CPU 开销,并随可压缩流字节的体量变化,因此页数和嵌入字体体量影响每次渲染的 CPU;图像处理、字体子集化和输入解析也可能成为主导。用有代表性的文档测量,并在你选择 worker 数量和 CPU 时把真正的驱动因素算进去。
-
把
pm.max_requests与pm.max_children一起设,让子进程回收并收回任何缓慢增长,如上面的 worker 条目所示。
-
- 相关。 流式与内存。
条目:大的不受信任输入解析起来慢或昂贵
标题为“条目:大的不受信任输入解析起来慢或昂贵”的章节- 症状。 一次渲染在一个大的或深度嵌套的输入上慢或耗内存,尤其是 HTML 或一个不是你产生的字体。
- 可能成因。 解析开销随输入大小和结构变化。一个病态输入(深嵌套、巨大的元素数量,或一个畸形字体)可以主导预算。
- 解决办法。
- 倚靠引擎的边界。原生
writeHtml()HTML 解析器强制执行MAX_NESTING_DEPTH = 100和MAX_ELEMENT_COUNT = 50000(ADR-001);超过那些上限的输入会被拒绝,而非被允许耗尽进程。 (可选的 Chrome 桥,writeHtmlChrome(),不在这些 ADR-001 上限的范围内,并强制执行它自己独立的内存/输入限制。) - 把调用方提供的字体当作不受信任的。一个畸形字体会抛出
NextPDF\Exception\FontParsingException,而非损坏输出,因此捕获那个特定异常并拒绝输入,而不是重试。 - 在你的边界处校验并限定输入大小,并对受调用方影响的内容施加请求级的文档大小限制。
- 倚靠引擎的边界。原生
- 相关。 排查:字体与标签。
决策表:症状到杠杆
标题为“决策表:症状到杠杆”的章节| 症状 | 最可能的杠杆 |
|---|---|
单次渲染上的 Allowed memory size … exhausted | 调低 $config->withImageCacheBytes();嵌入前缩小图像;提高每 worker 的 memory_limit |
| 峰值内存随页数上升 | 使用 有文档的流式写入路径 |
| Worker 内存在许多作业上攀升 | 通过 DocumentFactory 共享 FontRegistry/ImageRegistry;设 pm.max_requests / --max-jobs |
| 最初的请求慢、每请求解析开销 | 启用 opcache;在启动时 $fontRegistry->warmup() 然后 ->lock() |
| 主机在负载下交换 / 延迟尖峰 | 把 pm.max_children 定为 = (RAM − 开销) / 每 worker 峰值 |
| 在大/不受信任输入上慢或重 | 倚靠 ADR-001 上限;在 FontParsingException 上拒绝畸形字体 |
边界情况与注意点
标题为“边界情况与注意点”的章节imageCacheBytes是一个内存上限,不是一个大小旋钮。调低它给缓存设上限,让一个构建快速失败;它从不对你嵌入的图像重新采样或重新编码。Core 没有图像质量控制。withCompress(false)让文件更大,是一个调试/剖析辅助。它不是一个大小优化;它转移 CPU/内存权衡(它跳过压缩步骤),而非减少内存。- 流式引擎的确切内存画像是一个
experimental层属性,可能在次版本之间变动。把任何单次测量当作一次观测,而非一个可移植的常量。 memory_limit、opcache.*、pm.max_children和pm.max_requests是标准 PHP / PHP-FPM 设置。NextPDF 不为它们暴露自己的键;在你的运行时里配置它们,而非在Config里。
- 流式与内存 —— 流式模型、ADR-001 边界,以及完整的批处理 worker 教程。
- 缩减 PDF 文件大小 —— 压缩和字体子集化,两个真正的大小控件。
- 排查:字体与标签 —— 字体解析、解析和子集化失败。
- 知识库索引