惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

Last Week in AI
Last Week in AI
GbyAI
GbyAI
Apple Machine Learning Research
Apple Machine Learning Research
奇客Solidot–传递最新科技情报
奇客Solidot–传递最新科技情报
N
Netflix TechBlog - Medium
量子位
aimingoo的专栏
aimingoo的专栏
D
Docker
F
Fortinet All Blogs
T
Tailwind CSS Blog
V
V2EX
D
DataBreaches.Net
Google DeepMind News
Google DeepMind News
MyScale Blog
MyScale Blog
G
Google Developers Blog
罗磊的独立博客
MongoDB | Blog
MongoDB | Blog
Microsoft Security Blog
Microsoft Security Blog
Microsoft Azure Blog
Microsoft Azure Blog
WordPress大学
WordPress大学
博客园 - 三生石上(FineUI控件)
小众软件
小众软件
A
About on SuperTechFans
IT之家
IT之家

博客园 - 程序员李铁牛

赛事报名系统开发总管理中心规划 赛事报名系统开发:角色与权限体系设计 赛事系统报名接龙高并发技术细节处理浅析 纸质档案数字化管理系统研发手记:法院诉讼档案单套制落地案例复盘——从纸质卷宗到电子卷宗 档案数字化管理系统开发手记:人事档案专项审核与数字化的系统支撑——"凡提必审"下的技术要点 档案数字化研发手记:我们的技术栈选择——档案系统前后端选型理由(含一次"炫技"的教训) 社群团购系统开发分享——工程化(下):资金支付测试策略——资金模块 100% 覆盖,其他 60% 就够 社群团购系统类快团团源码开发——工程化(上):一套让 5 人小团队不出事故的开发规范 社群团购类快团团系统开发——RBAC 权限设计源码分析:当一个微信号有 4 种身份时怎么办 社群团购系统类快团团模式开发——微信小程序登录:code2Session 之后还有 5 件事要做 数字档案管理系统化研发手记:医院病历档案数字化——合规、病案首页 OCR 与调阅提速 数字档案系统研发手记:批量扫描任务调度——TWAIN/SANE 采集驱动的封装实践 档案数字化系统研发源码手记:置信度过滤——把 97% 识别率变成真正可交付的成果 档案数字化开发实例手记:盖章遮挡文字识别恢复 景区运营预约系统开发:景区会员体系怎么搭?3级会员模型+积分玩法 景区门票预约系统全渠道平台库存数据同步技术处理方式 系统宕机了怎么办?景区票务系统应急预案模板 景区门票预约系统开发:接入AI预测客流设计思路分析 景区预约系统开发总结:门票分时预约设计原理和落地方法 设备运维管理系统开发实战:用规则引擎实现可配置的设备告警策略自研轻量引擎 vs Drools 落地对比 设备维修保养系统预测性维护不用深度学习?设备健康度评分的务实实现方案 档案数字化管理系统研发手记:档案权限模型——全宗-门类-案卷-文件四级控制的设计与实践 档案管理信息化系统研发手记:数字水印方案对比——可见水印 vs 盲水印(档案借阅防泄露) 景区票务系统开发实例分析:上云还是本地部署?6个维度判断 档案数字化系统源码研发手记:老旧档案去污点——中值滤波与 inpainting 效果对比 一物一码防伪溯源系统开发全栈源码串联一次扫码的完整链路追踪代码走读 景区门票预约系统票务系统的6个核心模块 景区上线门票预约系统的5个常见翻车点 景区门票预约系统之人脸识别 vs 身份证核验 vs 扫码入园,哪种最适合你? 景区门票预约系统开发深度解析之定价策略功能设计
档案数字化研发手记:PDF合并内存溢出踩坑
程序员李铁牛 · 2026-09-14 · via 博客园 - 程序员李铁牛

一、场景:一个"看起来很简单"的功能

档案系统里有个基础功能:把一卷档案的几十上百张扫描图合并成一个 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 方向)+ 逐张异常容错是必做项;
  • 任务级容错:失败不拖垮整批、半成品清理、可重试。