前言
做 PDF 翻译预处理的时候,大家习惯只盯一件事:快不快。但真正让脚本在生产环境里挂掉的,通常不是速度,而是内存。
我遇到过两次:一次是本地跑扫描件 OCR 预处理,300 页的文档跑到一半进程被系统 kill;一次是批量任务队列里同时跑了 4 个 worker,每个都在做页面栅格化,直接把机器撑满。事后排查发现,问题都出在同一个习惯上——把每一页的处理结果攒在列表里,最后统一处理。
这篇文章用 psutil + pymupdf 把 PDF 处理链路的四个阶段拆开,实测 50 / 100 / 300 页文档的内存占用,找出真正的大头在哪。
环境准备
- Python 3.13
- 依赖:
pip install pymupdf psutil reportlab
实测版本:PyMuPDF 1.28.2 / psutil 7.2.2 / Windows 11,Python 3.13.12。
先用 reportlab 生成三个规模的测试文档(正文 + 12 行 × 3 列表格,模拟真实报告结构):
import os
from reportlab.lib.pagesizes import A4
from reportlab.pdfgen import canvas
from reportlab.lib.units import mm
FILLER = ("Multilingual document processing requires reliable text extraction. "
"This paragraph simulates body text with mixed terminology, numbers 2026 "
"and punctuation, so that the extraction stage has realistic work to do. ")
def build(path: str, pages: int, rows: int = 12):
"""生成含正文与表格的测试 PDF"""
c = canvas.Canvas(path, pagesize=A4)
w, h = A4
for p in range(pages):
c.setFont("Helvetica-Bold", 15)
c.drawString(20 * mm, h - 22 * mm, f"Section {p + 1}: Annual Report Extract")
c.setFont("Helvetica", 9)
y = h - 32 * mm
for _ in range(4): # 正文段落
c.drawString(20 * mm, y, FILLER[:110])
y -= 11
y -= 6
col_w = 42 * mm
for r in range(rows): # 表格
for k in range(3):
c.rect(20 * mm + k * col_w, y - 5, col_w, 11)
c.setFont("Helvetica", 8)
c.drawString(21 * mm + k * col_w, y - 1, f"R{r+1}C{k+1} {p*100+r*10+k}")
y -= rows * 11 + 10
c.showPage()
c.save()
for n in (50, 100, 300):
build(f"testpdf/sample_{n}p.pdf", n)
生成结果:50 页 48.1 KB、100 页 95.4 KB、300 页 285.4 KB。
分阶段内存监控脚本
核心思路是每个阶段结束后强制 GC,再读一次 RSS,用差值定位内存增长点:
import os, gc, time, tracemalloc
import psutil
import pymupdf
PROC = psutil.Process(os.getpid())
def rss_mb() -> float:
"""当前进程常驻内存 (MB)"""
return PROC.memory_info().rss / 1024 / 1024
def profile(pdf_path: str) -> dict:
gc.collect()
base = rss_mb()
res = {"file": os.path.basename(pdf_path), "base_mb": round(base, 1)}
tracemalloc.start()
doc = pymupdf.open(pdf_path)
r_open = rss_mb()
# S2 流式取文:每页取完即弃
total_chars = 0
for page in doc:
total_chars += len(page.get_text())
r_stream = rss_mb()
# S3 全量 blocks 驻留
all_blocks = []
for page in doc:
all_blocks.extend(page.get_text("blocks"))
r_blocks = rss_mb()
# S4 表格检测(重计算,但不驻留结果)
n_tables = 0
for page in doc:
tabs = page.find_tables()
n_tables += len(tabs.tables)
del tabs
r_tables = rss_mb()
peak = tracemalloc.get_traced_memory()[1] / 1024 / 1024
tracemalloc.stop()
res.update({
"pages": doc.page_count, "chars": total_chars, "blocks": len(all_blocks),
"delta_open": round(r_open - base, 1),
"delta_stream": round(r_stream - base, 1),
"delta_blocks": round(r_blocks - base, 1),
"delta_tables": round(r_tables - base, 1),
"peak_traced_mb": round(peak, 1),
})
doc.close()
del all_blocks
return res
实测结果:文本侧的内存几乎可以忽略
| 文档 | 页数 | 字符数 | 打开 | 流式取文 | blocks 驻留 | 表格检测 | tracemalloc 峰值 |
|---|---|---|---|---|---|---|---|
| sample_50p | 50 | 41,954 | +0.7 MB | +2.0 MB | +2.1 MB | +4.7 MB | 1.2 MB |
| sample_100p | 100 | 84,311 | +0.1 MB | +0.3 MB | +0.5 MB | +2.3 MB | 1.3 MB |
| sample_300p | 300 | 261,111 | +0.0 MB | +0.4 MB | +1.0 MB | +3.7 MB | 1.9 MB |
第一眼看起来很奇怪:300 页文档从头读到尾,RSS 只涨了不到 1 MB。
这不是测量误差。我单独验证了一下:把 300 页的文本全部存进列表,用 sys.getsizeof 算字符串对象的总大小:
import pymupdf, sys, os, psutil
doc = pymupdf.open("testpdf/sample_300p.pdf")
txts = [pg.get_text() for pg in doc]
obj_mb = sum(sys.getsizeof(t) for t in txts) / 1024 / 1024
print(f"页数 {len(txts)}, 字符 {sum(map(len, txts))}, 字符串对象 {obj_mb:.2f} MB")
# 页数 300, 字符 261111, 字符串对象 0.26 MB
26 万字符,字符串对象净占用 0.26 MB。原因很直接:PDF 是纯文本格式,300 页正文的字符量本来就只有几百 KB 量级,即使全部驻留内存也毫无压力。
结论一:文本提取阶段的内存占用可以不做优化。如果你在文本侧做流式分批、写临时文件,收益基本为零。
这个结论对工程实践有直接影响。我见过不少预处理脚本为了"降低内存",把提取出的文本按每 20 页写一个临时文件,处理完再读回来拼接。这套逻辑在文本侧是纯浪费——它增加了磁盘 IO 和文件句柄管理,换来的内存节省不到 1 MB。真正需要分片的场景只有一个:单次调用大模型时的 token 上限,那是接口约束,不是内存约束。
还有一个容易被误判的现象:RSS 在文本阶段确实会小幅波动,但不回落到基线不代表有泄漏。我做过一组对照实验,对同一页重复取文本 30 次:
import gc, pymupdf, psutil
P = psutil.Process()
doc = pymupdf.open("testpdf/sample_50p.pdf")
gc.collect(); a = P.memory_info().rss / 1024 / 1024
for _ in range(30):
doc[0].get_text()
gc.collect(); b = P.memory_info().rss / 1024 / 1024
for _ in range(30):
doc[0].get_text("blocks")
gc.collect(); c = P.memory_info().rss / 1024 / 1024
print(f"起始 {a:.1f} MB -> get_text 后 {b:.1f} MB -> get_blocks 后 {c:.1f} MB")
# 起始 53.8 MB -> get_text 后 54.0 MB -> get_blocks 后 54.0 MB
60 次调用只涨了 0.2 MB。这说明那点增量来自 Python 内存分配器的 arena 碎片,而不是每页泄露。判断泄漏要看"随迭代次数线性增长",不看"单次任务后不回落"。 我在排查时吃过这个亏,误以为是 PyMuPDF 有内存泄漏,白白查了一个下午。
真正的大头:页面栅格化
扫描件 OCR 预处理链路里有一步绕不开——把页面渲染成位图(page.get_pixmap())。这才是内存的真正瓶颈。
先看单页位图有多大:
import pymupdf
doc = pymupdf.open("testpdf/sample_50p.pdf")
for dpi in (150, 300):
pm = doc[0].get_pixmap(dpi=dpi)
print(f"{dpi} dpi: {pm.width}x{pm.height}, "
f"原始字节 {len(pm.samples)/1024/1024:.1f} MB")
# 150 dpi: 1241x1754, 原始字节 6.2 MB
# 300 dpi: 2481x3508, 原始字节 24.9 MB
300 dpi 下单页位图原始数据 24.9 MB。注意这是未压缩的 RGB 原始字节。
接下来对比两种写法。危险写法:把所有页的 pixmap 存进列表,最后统一处理(多线程批处理的常见写法):
# 危险:全部驻留
doc = pymupdf.open("testpdf/sample_300p.pdf")
pixs = [pg.get_pixmap(dpi=300) for pg in doc] # 300 × 24.9 MB
安全写法:流式,处理完立即释放:
# 安全:流式释放
for pg in doc:
pm = pg.get_pixmap(dpi=300)
do_something(pm.samples) # 处理完这一页就丢
del pm
实测对比(300 dpi,RSS 增量):
| 文档 | 页数 | 危险写法 RSS 增量 | 安全写法 RSS 增量 | 理论位图总量 |
|---|---|---|---|---|
| sample_50p | 50 | +1246.5 MB | +26.3 MB | 1245.0 MB |
| sample_100p | 100 | +2490.2 MB | +0.0 MB | 2490.0 MB |
| sample_300p | 300 | +7470.9 MB | +0.2 MB | 7470.1 MB |
差距非常直观:
- 危险写法:内存增量与理论位图总量几乎完全吻合(7470.9 MB vs 7470.1 MB),线性增长,每页 24.9 MB。300 页就是 7.3 GB,绝大多数机器会直接 OOM。
- 安全写法:无论 50 页还是 300 页,RSS 增量都稳定在 0 附近(50 页那次 +26.3 MB 是上一轮危险写法留下的堆未回落,随后两次都归零)。
同一份文档、同样的渲染质量,峰值内存差了两个数量级。
这个实验还解释了一个常见的困惑:为什么"50 页能跑通、100 页偶尔崩、300 页必崩"?因为危险写法的内存是页数的一次函数,而机器可用内存是固定的。从数据看,50 页 1.2 GB 大多数机器还能撑住,100 页 2.5 GB 开始和系统其他进程抢资源,300 页 7.3 GB 直接触发 OOM Killer。所以崩溃点不是"文档太大",而是"累积量越过了机器的物理水位"——换一台 16 GB 的机器,崩溃点只是往后挪到 500 页左右,根因没变。
这里还有一个隐藏的乘数容易被忽略:并发。如果任务队列开了 4 个 worker,危险写法下 100 页文档的实际占用是 2.5 GB × 4 = 10 GB,按单进程测算的容量规划会直接失效。我第二次出事就是这个原因——单跑没问题,一上队列就崩。
page.get_pixmap() 还有一个参数值得注意:默认输出是 3 通道 RGB。如果只是给 OCR 用,灰度图就够,用 colorspace=pymupdf.csGRAY 可以把单页位图从 24.9 MB 降到 8.3 MB,直接省掉三分之二。这个改动不需要重构流程,只加一个参数即可:
pm = page.get_pixmap(dpi=300, colorspace=pymupdf.csGRAY) # 24.9 MB -> 8.3 MB
OCR 的识别精度基本不受影响——文字识别依赖的是笔画边缘对比度,不是颜色信息。除非源文档用颜色区分语义(比如红色标注的警告框),否则灰度完全够用。
完整代码:带内存守护的批处理脚本
把上面的结论落成一个可用的批处理函数,加一道内存水位保护,超过阈值就强制 GC,必要时休眠等回收:
import os, gc, time
import psutil
import pymupdf
PROC = psutil.Process(os.getpid())
MEM_LIMIT_MB = 2048 # 软限制:超过则强制回收
def rasterize_stream(pdf_path: str, out_dir: str, dpi: int = 300,
on_page=None) -> list:
"""流式栅格化页面,内存占用与页数无关
Args:
pdf_path: 源 PDF 路径
out_dir: 单页图片输出目录
dpi: 渲染精度,300 适合 OCR,150 适合预览
on_page: 每页回调,接收 (页码, 位图字节)
Returns:
生成的图片文件路径列表
"""
os.makedirs(out_dir, exist_ok=True)
outputs = []
doc = pymupdf.open(pdf_path)
try:
for i, page in enumerate(doc):
pm = page.get_pixmap(dpi=dpi) # 单页位图
out = os.path.join(out_dir, f"p{i + 1:04d}.png")
pm.save(out) # 立即落盘
outputs.append(out)
if on_page:
on_page(i, len(pm.samples))
del pm # 关键:及时释放
# 每 20 页做一次内存水位检查
if (i + 1) % 20 == 0:
gc.collect()
cur = PROC.memory_info().rss / 1024 / 1024
if cur > MEM_LIMIT_MB:
print(f"[warn] 第 {i+1} 页内存 {cur:.0f} MB 超阈值,暂停回收")
time.sleep(0.5)
gc.collect()
finally:
doc.close()
return outputs
if __name__ == "__main__":
def report(i, n):
print(f" 第 {i+1} 页位图 {n/1024/1024:.1f} MB")
paths = rasterize_stream("testpdf/sample_300p.pdf", "out/pageimg", dpi=300,
on_page=report)
print(f"共输出 {len(paths)} 张图片,"
f"进程 RSS {PROC.memory_info().rss/1024/1024:.1f} MB")
实测 300 页 300 dpi 输出 300 张 PNG:纯渲染耗时 4.5 秒、进程 RSS 稳定在 52 MB;加上 PNG 编码落盘后总耗时约 58 秒,RSS 依然是 52 MB——内存完全不受页数影响,瓶颈转移到了磁盘 IO。
注意
pm.save()之后要del pm。PyMuPDF 的 Pixmap 由 C 层持有内存,Python 的 GC 不会主动感知这 24.9 MB,必须靠引用计数归零触发释放。
顺带一个体积陷阱
栅格化还有一个容易忽略的副作用:输出体积会暴涨。同一份 50 页文档,取文路径 vs 栅格化后再存回 PDF:
| 处理方式 | 输出体积 | 相对原文件 |
|---|---|---|
| 原文件(文本 PDF) | 48.1 KB | — |
| 插入 150 dpi 页面图 | 2.51 MB | 53× |
| 插入 300 dpi 页面图 | 5.25 MB | 112× |
| 插入 600 dpi 页面图 | 11.97 MB | 255× |
50 页扫描件按 300 dpi 输出就是 5.25 MB,300 页会接近 32 MB——这已经超过很多在线工具的 20 MB 单文件上限了。所以扫描件预处理链路的正确顺序是:先栅格化做 OCR,取回文本后丢掉图片,最后基于原文本流重建 PDF,而不是把渲染后的图片再拼回去。
总结
三个结论:
- 文本侧不用优化。300 页文档全文提取,RSS 增量不到 1 MB,字符串对象总共 0.26 MB。做流式分批没有收益。
- 栅格化才是内存大头,且必须流式。300 dpi 单页 24.9 MB,累积写法 300 页要 7.3 GB;流式写法全程稳定在 52 MB,与页数无关。
- 体积和内存要一起管。栅格化会让文件膨胀 50–250 倍,预处理完要及时丢弃中间图片。
最后说一下工程上的选择:如果只是偶尔处理几份文档,本地流式脚本完全够用;但如果是批量队列,考虑到渲染阶段的内存和 CPU 占用会互相挤占,把这部分交给云端服务处理反而更稳——本地只负责上传和下载结果。这也是我在批量任务里最后采用的方案。
参考资料
- PyMuPDF 官方文档 - Pixmap 与渲染:https://pymupdf.readthedocs.io/en/latest/pixmap.html
- psutil 文档 - 进程内存信息:https://psutil.readthedocs.io/en/latest/
- reportlab 用户指南:https://docs.reportlab.com/
标签:Python、PDF、性能优化、内存管理、PyMuPDF
280



被折叠的 条评论
为什么被折叠?



