档案数字化研发手记: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

问题链条:

  1. Image.open() 本身是惰性的(没真正读像素),但 .convert('RGB') 强制全量解码——一张 300dpi A4 灰度图约 2500×3500×3 字节 ≈ 26MB;
  2. 50 张就是 1.3GB,200 张就是 5GB+,全驻留在 images 列表里;
  3. save(save_all=True, append_images=...)PIL 内部还会再复制一份,内存峰值再翻倍;
  4. 生产机器内存 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 方向)+ 逐张异常容错是必做项;
  • 任务级容错:失败不拖垮整批、半成品清理、可重试。
posted on 2026-09-14 17:56  程序员李铁牛  阅读(3)  评论(0)    收藏  举报