你把发票、运单或收据服务搬到 Cloudflare Workers,是因为其他系统已经在边缘跑,延迟账也很漂亮:离用户最近的 colo 5 ms,CPU 1 ms,请求结束。
然后 PDF 生成来了,p99 突然变成 800 ms,worker bundle 警告 50 MB,你开始怀疑是不是选错了运行时。问题通常不在 Workers 本身,而在 PDF 栈不符合 isolate 的形状。下面是最常见的瓶颈,以及当天就能修掉的部分。
Workers 不是 Lambda,这一点很关键
先把运行模型说清楚。Cloudflare Workers 不是 serverless container,而是 V8 isolates,约束很不一样:
- CPU 时间限制:Free 计划每请求 50 ms,Workers Paid Bundled 30 秒,Unbound 5 分钟。wall time 可以更长,但会计费。
- 内存上限:每个 isolate 128 MB。
- Bundle 大小:Free 计划 1 MB,Paid 计划 10 MB。
- 没有文件系统。 没有
fs.readFileSync,只能内存或 fetch。 - 没有原生二进制。 只能纯 JavaScript / WebAssembly,没有
node-canvas,没有原生 zlib,也不能 shell 到 Ghostscript。 - Cold start 约 5 ms。 快得惊人,但前提是启动时没有大东西。
大多数 “Workers 上 PDF 很慢” 都是在踩这些约束,通常是 CPU 限制或 bundle 体积,然后被静默拖慢。
真正慢的五件事
按团队最常遇到的顺序看:
1. 把 Chromium 渲染器塞进 Workers
这条路走不通。Puppeteer 需要大约 250 MB Chromium 和真实操作系统。Browser Rendering API、Browserless 这类服务可以用,但它们不是 Workers,而是 Worker 去调用的外部服务,要多付约 500 ms 往返加渲染时间。
如果你的 “Worker 生成 PDF” 实际是 “Worker 调远程浏览器生成 PDF”,延迟下限就是约 500 ms。这不是 Worker 慢,而是浏览器税跟着你到了边缘。
诊断:看代码里是否有 fetch("https://browser-rendering.cloudflare.com/...") 或类似调用。有的话,你测到的是上游服务延迟,不是 Worker 自身。
2. 在 JavaScript 里做布局
如果你自己写 JS 布局引擎,手算 box 位置和换行,基本会撞 CPU 上限。JS 很快,但 30 多个元素加文本换行,在 Workers Free 上很容易超过 50 ms,在 Bundled 上也可能到 100-300 ms。
这种流水线尤其贵:
JSON → JS layout pass → SVG generation → SVG-to-PDF library → emit
它对同一份数据做了四轮 CPU 密集处理。每一轮都在 JS 里,每一轮都有 GC 压力,每一轮还会重新分配中间树。
诊断:查一次 render 的 wrangler tail 日志。如果任何 I/O 之前 CPU 已经超过 50 ms,这是计算问题。
3. 每个请求都加载字体
字体通常每个 50-250 KB。如果渲染器每次从 KV / R2 读取字体,就是每个字体一次网络往返。五个字体就是五次 RTT,渲染还没开始就花掉 50-150 ms。
诊断:给字体加载代码加 timing。如果它支配 p50,就是这里。
修复:在 module init 时加载一次字体,也就是 Worker 文件顶层,而不是 request handler 里。isolate 会在生命周期内缓存这些 bytes,通常是几分钟到几小时。
// Module-init: runs ONCE per isolate
import notoSans from "./fonts/noto-sans.woff2";
const FONT_BYTES = new Uint8Array(notoSans);
export default {
async fetch(request) {
// Per-request: zero font I/O
return renderPdf({ fontBytes: FONT_BYTES, ...data });
}
};
如果 bundler 能把字体内联成 bytes,就更好,完全没有 I/O。
4. 使用不是为 Workers 设计的 JS PDF 库
pdfkit、pdf-lib、jsPDF 都能在 Workers 里跑,但都有代价:
pdfkit需要 NodeBuffershim。能用,但多约 500 KB,计算也会慢约 30%。pdf-lib很适合编辑已有 PDF,但从零生成时抽象层每页会多约 10 ms。jsPDF首先面向浏览器,也有 Buffer 问题,API 面又大,不好 tree-shake。
如果流水线主要是 “读 JSON,写 PDF bytes”,直接输出 PDF 的专用引擎通常会快 5-20 倍。Rust 或 C++ 编译到 WebAssembly 后,紧凑循环也更适合 JIT。
5. Bundle 悄悄变成 4 MB
Workers Free 的 bundle 上限是 1 MB,Workers Bundled 是 10 MB。很多团队是在 wrangler deploy 报 “Script exceeds size limit” 时才发现。也有团队更早遇到冷启动变慢,因为 V8 要编译过大的 import。
wrangler 的 deploy 输出会告诉你 bundle 大小。超过 500 KB 就值得排查。常见原因:
- 打包字体。把字体放到 Workers Assets,并在 module init fetch 一次。
node:shim 层。如果 source map 里看到__cf_KV或polyfills:,说明 bundler 在补你并不需要的 Node API。- 未用依赖。Wrangler 4+ 可以用
npm run build -- --analyze看 treemap。
Workers 里的快速 PDF 应该长什么样
一个面向结构化文档、专为边缘端设计的渲染器,典型表现会像这样。gPdf 是一个例子,但架构原则适用于任何实现良好的渲染器。
| Metric | Typical | Why |
|---|---|---|
| Cold-start | 5-20 ms | V8 isolate boot + WASM module first-load |
| Per-render CPU | 1-4 ms | WASM tight loop, no GC pressure |
| Per-render wall | 3-8 ms | CPU + a few microseconds of crypto for PDF object IDs |
| Bundle size | 4-6 MB | Renderer + bundled fonts (Latin + CJK NotoSans) |
| Memory peak | 8-20 MB | Document tree + emitted PDF buffer |
对比典型 “Workers 调远程 Puppeteer / 浏览器渲染” 路径:p50 500-1000 ms,浏览器内存在别处占 1-2 GB,上游成本约 $0.001/render。
快速排查清单
如果你现在就遇到 Workers 上 PDF 慢,先跑这张清单:
- 时间花在哪里? 在
wrangler tail里打时间戳,确认瓶颈是 CPU、上游 fetch,还是静默期后的 cold start。 - 是不是在 JS 里跑布局? 是的话,这基本就是 CPU 主因。换成预计算布局或同 isolate 的专用渲染器。
- 是不是每请求加载字体? 把字体加载移到 module init。
- 是不是在调用外部浏览器? 那延迟下限就是这个服务的响应时间。换成同 isolate 内完成的渲染链路。
- Bundle 是否超过 1 MB? 冷启动会随 bundle 增大。裁掉未用依赖。
最快的 Workers PDF,是文档数据到 PDF bytes 全部发生在同一个 fetch handler 里,中间没有自己的 fetch(),也没有 CPU 很重的 JS 布局。很多 “Workers 不适合 PDF” 的抱怨,本质上是把不适合 Workers 的 PDF 栈搬了进去。
一句话
Cloudflare Workers 可以在个位数毫秒内生成 PDF,但前提是渲染器按 isolate 运行时来设计。为 Node 设计的 JS PDF 库、Worker 调外部浏览器、每请求加载字体、用 JavaScript 做多轮布局,都会把 p50 推到 “比源站还慢” 或 “像区域 Lambda 一样慢”。
如果你不想自己重做这套链路,可以试试 gPdf 在线体验。它就是部署在同一类边缘运行时上的渲染器。点 Render PDF,看 network tab,那就是没有和运行时对抗时 Workers 能做到的速度。