档案数字化研发手记:PDF合并内存溢出踩坑
一、场景:一个"看起来很简单"的功能
档案系统里有个基础功能:把一卷档案的几十上百张扫描图合并成一个 PDF(卷内文件一个 PDF,方便浏览、打印、归档)。需求看起来人畜无害:
读取目录下的 50 张 JPG → 合并成一个 PDF → 保存
第一版实现 10 分钟搞定,本地测试 50 张毫无压力。结果上线后,生产环境跑 200 页的卷宗时——内存溢出,服务直接挂。更麻烦的是:挂的时候是半夜的批量任务,第二天客户发现一批卷宗 PDF 没生成。
二、问题根因:库的默认行为是"全量载入内存"
我们用 Python 的 img2pdf / PIL 方案合并 PDF。典型错误写法:
from PIL import Image
import os
def merge_to_pdf(image_dir, output_pdf):
images = []
for fname in sorted(os.listdir(image_dir)):
if fname.lower().endswith(('.jpg', '.png', '.tif')):
# 坑:Image.open() 后直接 .copy() 或转 RGB,全量载入内存
img = Image.open(os.path.join(image_dir, fname)).convert('RGB')
images.append(img) # 所有图片对象驻留内存
# 坑:所有图片同时传给 save(),再叠一份内存
images[0].save(output_pdf, save_all=True, append_images=images[1:])
return output_pdf
问题链条:
Image.open()本身是惰性的(没真正读像素),但.convert('RGB')强制全量解码——一张 300dpi A4 灰度图约 2500×3500×3 字节 ≈ 26MB;- 50 张就是 1.3GB,200 张就是 5GB+,全驻留在
images列表里; save(save_all=True, append_images=...)时 PIL 内部还会再复制一份,内存峰值再翻倍;- 生产机器内存 8G,跑到 150 张左右直接 OOM。
三、正确方案一:逐张写入,不驻留内存
from PIL import Image
import os
def merge_to_pdf_streaming(image_dir, output_pdf, quality=85):
"""流式合并:逐张处理,单张内存占用"""
files = sorted(
f for f in os.listdir(image_dir)
if f.lower().endswith(('.jpg', '.png', '.tif'))
)
first = True
for fname in files:
# 逐张打开、转格式、保存到 PDF(写盘即释放)
with Image.open(os.path.join(image_dir, fname)) as img:
rgb = img.convert('RGB')
if first:
rgb.save(output_pdf, 'PDF', resolution=300.0,
save_all=True, append_images=[])
first = False
else:
# 追加模式:把当前页 append 到已存在的 PDF
# 注意:PIL 追加 PDF 时 append_images 一次一张
rgb.save(output_pdf, 'PDF', resolution=300.0,
save_all=True, append_images=[])
return output_pdf
(注:PIL 追加 PDF 的 API 在不同版本行为有差异,发布前请按你们实际库版本验证;更稳妥的生产方案见下。)
关键点:每张图处理完立即释放(with 块),内存峰值 = 单张图片大小,而不是总页数 × 单张。
四、正确方案二:专用 PDF 库流式追加(生产推荐)
PIL 处理大批量 PDF 仍偏慢且 API 别扭,生产环境我们换用 img2pdf(超快、流式) 或 pypdf 的流式合并:
# 方案:img2pdf(把图片直接打包成 PDF,流式、极快)
import img2pdf
import os
def merge_to_pdf_img2pdf(image_dir, output_pdf):
files = sorted(
f for f in os.listdir(image_dir)
if f.lower().endswith(('.jpg', '.png', '.tif'))
)
# img2pdf 内部流式处理,不吃内存;还支持指定 DPI 与压缩
with open(output_pdf, "wb") as f:
f.write(img2pdf.convert(
[os.path.join(image_dir, f) for f in files],
layout_fun=img2pdf.get_layout_fun((img2pdf.mm_to_pt(210), img2pdf.mm_to_pt(297)))
))
return output_pdf
对比实测(200 页卷宗,发布前替换为你们环境数值):
| 方案 | 峰值内存 | 耗时 | 稳定性 |
|---|---|---|---|
| 错误版(全量载入) | 5GB+(OOM) | 崩溃 | 差 |
| PIL 流式(逐张) | ~120MB | 45s | 中 |
| img2pdf 流式 | ~80MB | 8s | 好 |
五、另一个隐蔽的坑:图片格式兼容
- CMYK JPEG / 16bit TIFF:部分扫描仪输出 CMYK 或 16bit TIFF,
convert('RGB')能转但慢;img2pdf 对部分格式直接不支持——合并前统一做格式归一化(统一转 8bit RGB 或灰度); - EXIF 方向:手机拍照件带 EXIF 旋转信息,不处理方向就错了。
ImageOps.exif_transpose处理; - 异常图片:某张图损坏,
Image.open抛异常导致整个任务失败。逐张 try/except,坏图跳过并记录日志(而不是整批失败)。
六、生产级兜底:任务级容错
即使改成流式,生产环境还是要防"一次挂全挂":
def merge_batch_safe(batch_list):
"""批量合并任务:逐卷处理,单卷失败不拖垮整批"""
results = []
for batch in batch_list:
try:
results.append(merge_to_pdf_img2pdf(batch["dir"], batch["out"]))
except Exception as e:
# 失败记录到任务表,人工/自动重试,不中断整批
mark_task_failed(batch["id"], str(e))
return results
- 任务表 + 状态机(pending/running/done/failed/retry);
- 失败可重试(重试前清理半成品文件,防脏数据);
- 半成品清理:合并到一半失败留下的 PDF 必须删除(否则下次合并会"接在残卷后面")。
七、小结
- "全量载入再合并"是大批量 PDF 合并 OOM 的根因;
- 流式处理(逐张读、逐张写、用完即释放)让内存峰值从"总页数 × 单张"降到"单张";
- 生产推荐 img2pdf 或专用 PDF 库流式追加,比 PIL 快且稳;
- 格式归一化(8bit、RGB/灰度、EXIF 方向)+ 逐张异常容错是必做项;
- 任务级容错:失败不拖垮整批、半成品清理、可重试。
微信号:tieniu6636
浙公网安备 33010602011771号