一个‘简单’的 QR Code 生成器如何吞噬了我的全部 RAM:50000 个 QR Code 的故事

发布: (2026年2月24日 GMT+8 10:43)
10 分钟阅读
原文: Dev.to

Source: Dev.to

请提供您希望翻译的文章正文内容,我将为您翻译成简体中文并保持原有的格式、Markdown 语法以及技术术语不变。

出了什么问题

我的最初脚本 预先生成了每个二维码,把它们全部缓存到内存中,然后再组装 PDF。逻辑看起来很合理:

def generate_pdf(output_path: str, total: int = 50000):
    ids = generate_unique_ids(total)

    # Pre‑generate ALL QR codes in parallel for "speed"
    print(f"Pre-generating {total} QR codes in parallel...")
    num_workers = cpu_count()

    # Split IDs into batches for parallel processing
    batch_size = max(1, total // (num_workers * 4))
    batches = [ids[i:i + batch_size] for i in range(0, len(ids), batch_size)]

    # Generate QR codes in parallel using multiprocessing
    qr_cache = {}
    with Pool(num_workers) as pool:
        results = list(tqdm(
            pool.imap(generate_qr_batch, batches),
            total=len(batches),
            desc="Generating QR codes"
        ))

    # Store ALL images in memory
    for batch_result in results:
        for uid, img_bytes in batch_result:
            buf = io.BytesIO(img_bytes)
            qr_cache[uid] = ImageReader(buf)

    # NOW create the PDF using cached images
    # ... PDF generation code ...

我为自己的“multiprocessingparallel executionbatch processing”感到自豪。

当我运行它时,进度条在动,CPU 在所有核心上达到 100 % ,随后——我的笔记本(16 GB RAM)开始卡顿:

2 GB...
4 GB...
8 GB...
12 GB...

OOM killer 终止了进程。没有生成 PDF,只有一台冻结的机器和一次惨痛的教训。

为什么会爆炸

项目大约大小
QR码(400 × 400 px)PNG(压缩)15‑30 KB
QR码作为 PIL.Image 对象~500 KB – 1 MB
50 000 个 QR码 × 500 KB ≈~25 GB RAM
50 000 个 PNG 字节 × 20 KB ≈~1 GB RAM
+ ImageReader 对象、BytesIO 缓冲区、Python 开销、多进程复制观察到 2‑4 GB

即使是 压缩 后的字节也会耗尽我的 RAM,而且 并行工作进程会复制数据,导致使用量更高。

根本缺陷:在追求速度的同时忽视了资源消耗

Source:

简单修复 – 基于流的处理

不要一次性加载所有内容,而是一次处理一页 PDF(每页 30 个二维码)。只在内存中保留当前页的图像。

def generate_pdf(output_path: str, total: int = 50000):
    ids = generate_unique_ids(total)
    total_pages = (total + PER_PAGE - 1) // PER_PAGE

    # 创建 PDF 画布
    c = canvas.Canvas(output_path, pagesize=A4)

    # 一次处理 **一页**
    for page_start in tqdm(range(0, total, PER_PAGE), desc="Generating PDF pages"):
        page_ids = ids[page_start : page_start + PER_PAGE]

        # 仅为本页生成二维码
        page_qr_cache = {}
        for uid in page_ids:
            img = make_qr_image(uid)
            page_qr_cache[uid] = img_to_reader(img)

        # 绘制本页
        for idx, uid in enumerate(page_ids):
            # ... 将二维码绘制到 PDF ...
            c.drawImage(page_qr_cache[uid], qr_x, qr_y, ...)

        c.showPage()

        # 关键:每页结束后清空缓存!
        page_qr_cache.clear()

    c.save()

关键改动

  • 按页生成 – 任意时刻内存中仅存在 30 个二维码。
  • 每页后显式清除缓存
  • 移除多进程 – 消除数据复制并简化流程。

权衡:原始 vs. 优化

指标原始(并行)优化(每页)
内存使用2‑4 GB50‑100 MB
速度更快(理论上)更慢(顺序)
稳定性大数据集会崩溃稳定
可扩展性受限于 RAM受限于磁盘空间

是的,新版本更慢。顺序生成 50 000 个二维码耗时 30‑45 分钟——仍然远好于在完成前崩溃。正如格言所说,一个能完成的慢脚本,胜过一个永远无法完成的快脚本

要点

  • 考虑规模 – 一个在 100 条目下可运行的脚本,在 10 000 条目时可能会崩溃。
  • 在处理大数据集时,优先使用流式处理而非批量加载
  • 测量内存,而不仅仅是 CPU;并行化可能会放大 RAM 使用。
  • 简单往往是最好的 – 去除不必要的复杂性(多进程、大缓存)可以让脚本更稳健。

我在夜间运行了优化后的版本。醒来时,两个 PDF 文件(共 100 000 个二维码)已经生成完毕,我的电脑仍然运行轻松。

如果你曾经想要“预先计算所有内容”,请停下来思考:当规模扩大 10×、100×、1000× 时会发生什么?

内存炸弹问题

当你扩展脚本规模时,内存问题可能会变得灾难性。每个创建的对象都会占用内存,而图像对象的体积往往出乎意料地大。

隐藏内存占用的示例

# This innocent‑looking line...
qr_cache[uid] = ImageReader(buf)

# ...executed 50,000 times becomes a memory bomb

并行处理对于 CPU 密集型任务非常有用 前提是 你拥有足够的内存来支撑多个工作进程。当每个工作进程都创建大型对象时,并行化实际上会 乘以 内存使用量,使问题更糟。有时,一个简单的顺序循环才是正确的选择。

提示: Python 的垃圾回收器很有帮助,但并非魔法。如果你在字典或列表中保留对大型对象的引用,这些内存不会被释放,除非你显式地移除这些引用。

# This single line saved gigabytes of RAM
page_qr_cache.clear()

使用进度条

在运行长时间任务时,务必添加进度条。tqdm 库可以让这变得非常简便:

from tqdm import tqdm

for page_start in tqdm(
    range(0, total, PER_PAGE),
    desc="Generating PDF pages"
):
    # ... your code ...

进度条可以让你了解任务预计需要多长时间,并帮助你发现卡顿。

扩展前需要问的三个问题

  1. 每个项目的内存占用是多少?
  2. 我将处理多少个项目?
  3. 我能否一次处理一个项目,而不是一次性处理全部?

这些问题在以下场景尤为重要:

  • 图像处理: 图像非常占用内存。
  • 数据管道: 大型 CSV/JSON 文件。
  • API 响应: 对数千条记录进行分页。
  • 文件操作: 读取/写入大文件。

模式: 能流式处理时就流式,必须批处理时才批处理,除非绝对必要,绝不一次性将所有数据加载到内存中。

Source:

实用技巧

避免构建巨大的内存列表

# Bad: creates a list of 50,000 items in memory
ids = [generate_id() for _ in range(50_000)]
# Better: generates one at a time
def id_generator(count):
    for _ in range(count):
        yield generate_id()

增量处理项目

# Instead of processing all at once
for item in huge_list:
    process(item)
# Process in manageable chunks
chunk_size = 100
for i in range(0, len(huge_list), chunk_size):
    chunk = huge_list[i:i + chunk_size]
    for item in chunk:
        process(item)
    # Clean up after each chunk
    import gc
    gc.collect()          # Force garbage collection if needed

监控内存使用情况

import psutil, os

def get_memory_usage():
    process = psutil.Process(os.getpid())
    # Return MB
    return process.memory_info().rss / 1024 / 1024

# In your loop
for i, item in enumerate(items):
    process(item)
    if i % 1_000 == 0:
        print(f"Processed {i} items, Memory: {get_memory_usage():.1f} MB")

设置内存限制(Unix)

import resource

# Limit memory to 1 GB
resource.setrlimit(resource.RLIMIT_AS, (1_024 * 1_024 * 1_024, -1))

要点

我的“简单”二维码生成器变成了关于资源管理的宝贵教训。原始代码很巧妙——并行处理、批量操作、缓存——但不会工作的巧妙代码不如能正常工作的简单代码。

最终版本:

  • 生成 100 000 个二维码,分布在两个 PDF 文件中。
  • 运行大约 一小时
  • 使用

记住: 首先考虑内存,其次考虑速度。一个能完成的慢脚本远比一个会崩溃的快脚本更有价值。

TL;DR

我尝试一次性将 50 000 个二维码全部加载到内存中生成,结果电脑内存耗尽并崩溃。

解决方案: 分页生成二维码(例如一次生成 30 个)。速度会慢一些,但能正常工作。

经验教训: 在处理大规模数据时,务必考虑内存使用情况。

0 浏览
Back to Blog

相关文章

阅读更多 »

停止在 Rails 中错误使用 .any?

介绍:传递给 .any? 的单个块可能会在不发出警告或错误的情况下,悄悄将成千上万条记录加载到内存中——仅仅是产生不必要的对象。大多数 Rails 开发者……