跳转到内容
getnextpdf.com

故障排查:内存与性能

这些条目覆盖你在负载下会撞上的两类失败:一次渲染期间 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
  • 解决办法。
    1. 限制图像缓存。 NextPDF\Core\Config 暴露 imageCacheBytes(默认 52428800,即 50 MB)。用实例 wither $config->withImageCacheBytes($bytes)(签名 withImageCacheBytes(int $bytes): self)调低它,让一个嵌入许多图像的构建在一个已知上限上快速失败,而不是发生交换。这给内存中的图像缓存设上限;它不会对图像本身重新采样或重新编码。
    2. 嵌入前缩小输入。 Core 不会缩小或重新编码图像。在你嵌入超大栅格美术稿之前调整其尺寸并重新编码,并嵌入你实际使用的字体,让子集化要保留的字形集很小 (参见 缩减 PDF 文件大小)。
    3. 保持压缩开启。 一个全新的 Configcompress 设为 true。 普通构建保持它开启;withCompress(false) 不是一个大小优化(它通常增大输出)。在你要调试或剖析流水线时再用它 —— 它转移 CPU/内存权衡(跳过压缩步骤),而非减少内存。
    4. 有意地、按 worker 提高 memory_limit 这是一个标准 PHP 设置,不是一个 NextPDF 键。在池配置里设置它,或用 ini_set('memory_limit', '256M') 给 CLI/队列进程设置,并按一个剖析过的峰值来定它的大小,而不是猜。
  • 相关。 流式与内存

条目:在很大的文档上内存随页数增长

标题为“条目:在很大的文档上内存随页数增长”的章节
  • 症状。 一个数千页的文档即便每页都很小也耗尽内存,且峰值大致随页数同步上升。
  • 可能成因。 缓冲写入器把整个序列化文档保留在堆里。对于很大的文档,那是主导开销。
  • 解决办法。
    1. 优先用流式写入路径。使用 流式与内存 中描述的有文档的流式写入路径:它在每页被组合时就序列化它并释放缓冲,这减少了页缓冲/输出增长;小的每对象元数据(偏移量、页树)仍可随页/对象数量变化。遵循有文档的入口点,而不是复制内部类 —— 底层的流式引擎是 experimental 层,其符号不是稳定的公开接口。
    2. 原生 writeHtml() 解析器,记住输入侧内存同时被嵌套深度守护和元素数量守护限定:ADR-001 把嵌套上限设为 MAX_NESTING_DEPTH = 100,并拒绝超过 MAX_ELEMENT_COUNT = 50000 的文档。一个撞上元素上限的文档会被明确告知,而不是悄悄耗尽内存。这些 ADR-001 上限只管原生解析器;可选的 Chrome 桥 (writeHtmlChrome())在进程外渲染,有它自己独立的内存/输入限制,而非这些上限。
  • 相关。 流式与内存

条目:一个长寿命 worker 在许多作业后耗尽内存

标题为“条目:一个长寿命 worker 在许多作业后耗尽内存”的章节
  • 症状。 单次渲染成功,但一个背靠背渲染许多 PDF 的队列 worker 在几分钟或几小时后耗尽内存。
  • 可能成因。 一个长寿命 PHP 进程跨作业累积分配。一次请求里看不见的缓慢增长,在数千次上累加起来。
  • 解决办法。
    1. 共享注册表,重建文档。在启动时构建一次 FontRegistryImageRegistry 并把它们传给一个 DocumentFactory;用 $factory->create($config) 为每个作业创建一个全新的 Document。字体和图像解析便对进程发生一次,而非每作业一次,而每作业的文档树在它离开作用域时被回收。遵循 examples/14-worker-factory.php
    2. new ImageRegistry(maxCacheBytes: ...) 限制共享的图像缓存,让它不能跨作业无限增长。
    3. 回收 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 在每个字体字面首次被用时解析它。
  • 解决办法。
    1. 启用 opcache(并在它有帮助处启用 JIT)。opcache.enable=1 和一个慷慨的 opcache.memory_consumption;在生产里设 opcache.validate_timestamps=0,让缓存不被每请求重新检查。 那个设置要求一个在每次发布时重启或重载 PHP-FPM (或以其他方式重置 opcache,例如 opcache_reset() / cachetool)的部署流程 —— 否则 opcache 会继续服务旧字节码,发布后跑的是陈旧代码。这些是标准 PHP ini 设置,不是 NextPDF 键。
    2. 在启动时预热并锁定字体注册表。 在一个 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 进程存活的队列消费者。
    3. 不要每请求重新解析同一个模板。 通过共享的注册表在启动时把字体和可复用资源解析一次;只有每作业的 Document 应当在请求里创建。
  • 相关。 流式与内存

条目:服务器在并发下饱和且延迟尖峰

标题为“条目:服务器在并发下饱和且延迟尖峰”的章节
  • 症状。 单独看每次渲染延迟没问题,但在负载下机器发生交换、CPU 饱和,或请求排队并超时。
  • 可能成因。 对可用 RAM 而言 PHP-FPM worker 太多,于是 worker 峰值之和超过物理内存,主机发生交换;或 worker 太少,于是请求在一个小池后面串行化。
  • 解决办法。
    1. 从一个剖析过的峰值定 pm.max_children 的大小。 使用标准公式:

      pm.max_children = (total RAM - OS/other overhead) / per-worker peak memory

      用一个有代表性的文档测量一个 worker 的真实峰值(参见范围里的剖析说明),为 OS 和任何同机服务预留余量,然后做除法。留一道余地;不要把大小定到 RAM 的 100%。

    2. 把压缩开销钉进你的预算。Flate 压缩可以是写一个流的一项可观 CPU 开销,并随可压缩流字节的体量变化,因此页数和嵌入字体体量影响每次渲染的 CPU;图像处理、字体子集化和输入解析也可能成为主导。用有代表性的文档测量,并在你选择 worker 数量和 CPU 时把真正的驱动因素算进去。

    3. pm.max_requestspm.max_children 一起设,让子进程回收并收回任何缓慢增长,如上面的 worker 条目所示。

  • 相关。 流式与内存

条目:大的不受信任输入解析起来慢或昂贵

标题为“条目:大的不受信任输入解析起来慢或昂贵”的章节
  • 症状。 一次渲染在一个大的或深度嵌套的输入上慢或耗内存,尤其是 HTML 或一个不是你产生的字体。
  • 可能成因。 解析开销随输入大小和结构变化。一个病态输入(深嵌套、巨大的元素数量,或一个畸形字体)可以主导预算。
  • 解决办法。
    1. 倚靠引擎的边界。原生 writeHtml() HTML 解析器强制执行 MAX_NESTING_DEPTH = 100MAX_ELEMENT_COUNT = 50000(ADR-001);超过那些上限的输入会被拒绝,而非被允许耗尽进程。 (可选的 Chrome 桥,writeHtmlChrome(),不在这些 ADR-001 上限的范围内,并强制执行它自己独立的内存/输入限制。)
    2. 把调用方提供的字体当作不受信任的。一个畸形字体会抛出 NextPDF\Exception\FontParsingException,而非损坏输出,因此捕获那个特定异常并拒绝输入,而不是重试。
    3. 在你的边界处校验并限定输入大小,并对受调用方影响的内容施加请求级的文档大小限制。
  • 相关。 排查:字体与标签
症状最可能的杠杆
单次渲染上的 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_limitopcache.*pm.max_childrenpm.max_requests 是标准 PHP / PHP-FPM 设置。NextPDF 不为它们暴露自己的键;在你的运行时里配置它们,而非在 Config 里。

术语表:流式写入器 · 字体子集化