Перейти к содержимому

Массовая генерация документов

Spec: ISO 24495-1:2023, §5 Spec: ISO 9241-112:2025, §6.1.2.3 Evidence: Benchmark-backed

Сгенерировать один PDF — это вызов функции. Сгенерировать сто тысяч по расписанию — уже системная задача: память должна оставаться в заданных пределах, работа — выполняться параллельно, а числа — что-то значить. Эта страница проводит через сценарий пакетной генерации: от вопроса о пропускной способности до развёртывания, которое выдерживает нагрузку. Здесь прямо сказано: честный ответ — “измерьте на своих документах”, а не громкая цифра.

Пакетная генерация обычно ломается двумя способами. Первый — постепенный рост потребления памяти. Долгоживущий worker документ за документом накапливает удерживаемое состояние, пока его принудительно не завершают посреди пакета, и прогон оказывается ни завершённым, ни корректно остановленным. Второй — уверенное, но бессмысленное число: результат теста на тривиальном документе используют для расчёта парка машин, которые отрисовывают сложные документы, и ошибка проявляется только под продакшн-нагрузкой.

Избежать можно и того, и другого, но только если с самого начала спроектировать модель памяти и методику измерений, а не добавлять их после первого инцидента.

  • Единица работы — одноразовый документ, а не общий. Храните данные времени жизни процесса (шрифты, кэш изображений) в общих реестрах; документ создавайте и уничтожайте для каждой отрисовки.
  • У памяти две части, и для долгоживущего worker важна только одна. Кратковременный пик во время отрисовки ожидаем; удерживаемая память, которая не возвращается, — это утечка, которая обрывает пакет.
  • Пропускная способность — это параллелизм плюс ограниченная стоимость на одну отрисовку. Схема, которая выдерживает нагрузку, — это очередь, питающая stateless-worker’ы, каждый из которых отрисовывает и освобождает память.
  • Число без метода его получения — это не число. NextPDF сообщает измерения по каждой отрисовке как данные, которые вы собираете, и отказывается от ничем не оговорённых заявлений о скорости. Самая важная цифра — та, которую вы измеряете на собственных шаблонах (ISO 24495-1 §5.x11 — размещайте значимое сообщение там, где его найдёт читатель).

Архитектура строится вокруг одного решения: состояние, живущее в рамках процесса, является общим и неизменяемым; состояние, живущее в рамках отрисовки, создаётся заново и выбрасывается. Шрифты — это структурные данные: их разбирают один раз и затем блокируют, поэтому ни одна отрисовка не может изменить их и испортить следующую. Кэш изображений — это ограниченное хранилище по принципу least-recently-used, которое никогда не блокируется, поэтому память остаётся в пределах лимита и не утекает между запросами. Фабрика документов — stateless-синглтон; каждый создаваемый ею документ является одноразовым.

Именно это разделение позволяет безопасно держать worker запущенным часами под 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.
Сценарий больших объёмов от начала до конца: общее неизменяемое состояние прогревается один раз; каждое задание отрисовывается на одноразовом документе и освобождает память; пропускная способность масштабируется добавлением worker'ов, а не увеличением одного.

Мосты к фреймворкам делают эту схему вариантом по умолчанию, а не тем, что приходится собирать вручную. Сервис-провайдер Laravel регистрирует реестр шрифтов как заранее прогретый и заблокированный синглтон, а документ связывает как новый экземпляр при каждом разрешении зависимости. Он поставляется с заданием очереди с ограниченным числом попыток, таймаутом и экспоненциальной задержкой между повторами. Это задание проверяет путь вывода на стороне worker’а, потому что сериализованная полезная нагрузка очереди может быть подменена при передаче. Интеграции для Symfony и CodeIgniter следуют той же дисциплине одноразового документа и общего реестра.

Модель памяти подтверждена кодом. Evidence: Code-backed В Laravel NextPdfServiceProvider регистрирует FontRegistry как синглтон, который сначала прогревается, а затем блокируется через lock(), ImageRegistry — как синглтон с ограниченным LRU, который намеренно не блокируется, а Document — как привязку, создаваемую при каждом разрешении зависимости через stateless-фабрику. Модель одноразового документа заложена в проводке зависимостей, а не только описана в тексте. GeneratePdfJob задаёт tries, timeout и backoff и повторно проверяет путь вывода внутри handle().

Поверхность измерений подтверждена тестами производительности. Evidence: Benchmark-backed Движок выдаёт неизменяемый RenderReport на каждую генерацию, содержащий время отрисовки в миллисекундах, пиковое потребление памяти в байтах, число страниц, число предупреждений и случаи срабатывания резервных вариантов — те самые исходные данные, которые нужны для расчёта парка машин. Отдельный анализатор фрагментации памяти различает пиковую (кратковременную) и удерживаемую память. Это различие показывает, остаётся ли долгоживущий worker в норме или он медленно даёт утечку. Сама инфраструктура тестов производительности настроена на повторные прогоны с прогревом, потому что одно измерение времени — это шум.

Эта дисциплина является принципом проектирования: 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() здесь не формальность. Состояние уровня отрисовки должно освобождаться на каждой итерации, чтобы удерживаемая память возвращалась к базовому уровню. Worker, у которого базовый уровень растёт от итерации к итерации, — это именно тот сбой, который призван предотвратить этот цикл.

Главное заблуждение — “сколько PDF в секунду может NextPDF?” — будто у этого вопроса есть один ответ. Его нет, и именно из-за подобных цифр парки машин рассчитывают неверно. Стоимость отрисовки определяется главным образом самим документом, поэтому единственное число, на которое стоит опираться, — это число, измеренное на собственных шаблонах с помощью встроенного отчёта движка по каждой отрисовке. Цифра, за которой не указаны документ, оборудование и метод, — это украшение, а не данные.

Второе заблуждение — что следить нужно за пиковой памятью. Пик кратковременен и ожидаем — он возвращается. Пакет обрывает удерживаемая память, которая не возвращается. Именно поэтому движок разделяет эти две величины.

  • Универсального значения пропускной способности не существует, и эта страница намеренно его не приводит. Стоимость отрисовки зависит от ваших документов; измеряйте её с помощью отчёта по каждой отрисовке.
  • Ограниченность памяти зависит от того, используется ли модель одноразового документа. Удержание документа на протяжении многих отрисовок или совместное использование изменяемого состояния уровня отрисовки отменяет эту гарантию. Мосты к фреймворкам по умолчанию используют безопасную схему. Самостоятельная проводка зависимостей должна воспроизводить её.
  • Кэш изображений ограничен, а не безграничен. При тяжёлых нагрузках с уникальными изображениями LRU вытесняет элементы. Это проектное решение, а не регрессия.
  • Размер пула worker’ов, выбор очереди и автомасштабирование — это решения по развёртыванию вне движка. NextPDF предоставляет измерения и примитив с заданными границами. Он не управляет вашей очередью.
  • RenderReport — это данные, а не вердикт. Он сообщает, что произошло при отрисовке. Превращение этого в план мощностей — уже ваш анализ.
  • Эта страница подтверждена тестами производительности в части поверхности измерений и подтверждена кодом в части модели памяти. Она не утверждает никакой конкретной скорости.
Примитивы генерации в больших объёмах через очередь — edition availability
Edition Availability
Core

Модель одноразового документа, общие неизменяемые реестры, RenderReport по каждой отрисовке и анализатор фрагментации памяти относятся к Core. Для обычной генерации PDF в больших объёмах коммерческий уровень не нужен.

Pro

Те же примитивы; коммерческие возможности (подписание, PDF/A) добавляют стоимость на отрисовку, которую следует измерять, а не предполагать.

Enterprise

Те же примитивы; работа со структурированными счетами и валидацией добавляет дополнительную стоимость на отрисовку, растущую с объёмом полезной нагрузки и размером набора правил.

  • Одноразовый документ — экземпляр документа, создаваемый для одной отрисовки и затем уничтожаемый, чтобы никакое состояние не утекало в следующую отрисовку.
  • Общий реестр — состояние времени жизни процесса, неизменяемое после прогрева (шрифты, кэш изображений), повторно используемое между отрисовками без затрат на каждую отрисовку.
  • Пиковая память — кратковременный максимум во время отрисовки; он ожидаем и возвращается к базовому уровню.
  • Удерживаемая память — память, всё ещё занятая после завершения отрисовки; рост удерживаемого базового уровня от отрисовки к отрисовке — это утечка.
  • Worker — долгоживущий процесс, который забирает задания отрисовки из очереди; чтобы пережить пакет, он должен оставаться в пределах лимита памяти.
  • RenderReport — неизменяемый снимок метрик движка по каждой отрисовке (время, пиковая память, число страниц, предупреждения), используемый для планирования мощности по реальным данным.