一个‘简单’的 QR Code 生成器如何吞噬了我的全部 RAM:50000 个 QR Code 的故事
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 ...
我为自己的“multiprocessing、parallel execution、batch 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 GB | 50‑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 ...
进度条可以让你了解任务预计需要多长时间,并帮助你发现卡顿。
扩展前需要问的三个问题
- 每个项目的内存占用是多少?
- 我将处理多少个项目?
- 我能否一次处理一个项目,而不是一次性处理全部?
这些问题在以下场景尤为重要:
- 图像处理: 图像非常占用内存。
- 数据管道: 大型 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 个)。速度会慢一些,但能正常工作。
经验教训: 在处理大规模数据时,务必考虑内存使用情况。