用 Python cProfile 排查 CSV 脚本慢:定位热点并验证集合查询优化
定时任务能跑完,CSV 多了以后却越来越慢。先加线程或重写整段程序,容易把时间花在不影响结果的地方。本文用标准库 cProfile + pstats 找出反复扫描列表的热点,只改一次查询容器,再核对输出和实际耗时。
面向初级到中级读者,适合在 Linux 本地复现。示例使用 CPython 3.10 或更新版本,无需第三方包;本文在 Linux、CPython 3.11.4 下测试。如果任务是卡住不动,可先看用 faulthandler 获取线程堆栈。
1. 方法:先定位,再验证
- 对同一份输入运行列表版本,保存函数级性能记录。
- 找到
is_allowed()的查询开销,把允许列表在循环外转换为集合。 - 确认两份 CSV 字节一致,再关闭分析器测量整个命令。
本文演示的是字符串 ID 的存在性查询。重复的允许 ID 不增加输出行数,输入记录的重复项和顺序必须保留。它不执行多表连接,也不把全部输入行装入内存。
2. 准备环境与数据
- 在同一终端中新建练习目录,后续命令都在这里执行:
python3 --version
python3 -m cProfile --help
mkdir cprofile-csv-demo
cd cprofile-csv-demo
- 生成 20000 条记录和 5001 条允许列表项。其中只有 5000 个不同的允许 ID,输入中每个 ID 出现两次;部分备注含中文、逗号和换行。
python3 - <<'PY_DATA'
import csv
from pathlib import Path
with open("samples.csv", "x", encoding="utf-8", newline="") as file:
writer = csv.writer(file)
writer.writerow(["sample_id", "note"])
for i in range(20000):
note = f"record {i}"
if i % 4999 == 0:
note = f"记录 {i}, item\nsecond line"
writer.writerow([f"S{i % 10000:05d}", note])
with Path("allowed.txt").open("x", encoding="utf-8") as file:
for i in range(0, 10000, 2):
file.write(f"S{i:05d}\n")
file.write("S00000\n")
print("Created 20000 records and 5001 allow-list entries")
PY_DATA
数据文件使用 x 模式创建,已存在时会报错,避免覆盖。重新演示时可换一个新目录。CSV 按官方文档以 newline="" 打开,交给 CSV 解析器处理跨行字段。
3. 保存完整筛选脚本
把下面代码保存为 filter_samples.py:
"""Profile list versus set membership while filtering a demo CSV."""
import argparse
import csv
from pathlib import Path
import sys
def is_allowed(sample_id, allowed_ids):
return sample_id in allowed_ids
def filter_samples(source, allow_file, output, index):
allowed_ids = allow_file.read_text(encoding="utf-8").splitlines()
if index == "set":
allowed_ids = set(allowed_ids)
kept = 0
with source.open(encoding="utf-8-sig", newline="") as incoming:
reader = csv.reader(incoming, strict=True)
if next(reader, None) != ["sample_id", "note"]:
raise ValueError("expected header: sample_id,note")
with output.open("x", encoding="utf-8", newline="") as outgoing:
writer = csv.writer(outgoing)
writer.writerow(["sample_id", "note"])
for number, row in enumerate(reader, 1):
if len(row) != 2 or not row[0]:
raise ValueError(f"invalid data record {number}")
if is_allowed(row[0], allowed_ids):
writer.writerow(row)
kept += 1
return kept
def main():
parser = argparse.ArgumentParser(description=__doc__, allow_abbrev=False)
parser.add_argument("source", type=Path)
parser.add_argument("allow_file", type=Path)
parser.add_argument("output", type=Path, help="new output CSV; must not exist")
parser.add_argument("--index", choices=["list", "set"], default="list")
args = parser.parse_args()
try:
kept = filter_samples(args.source, args.allow_file, args.output, args.index)
except (OSError, ValueError, csv.Error) as exc:
print(f"ERROR: {exc}", file=sys.stderr)
return 1
print(f"kept={kept}")
return 0
if __name__ == "__main__":
raise SystemExit(main())
--index list 保留原始列表;--index set 只在读取允许文件后构建一次集合。两种模式用同一个 is_allowed()、同一个读取循环和写出路径,方便比较。
这个练习只接受 sample_id,note 两列表头及非空 ID。它保留原始字符串,允许文件每行就是一个 ID,不额外去掉首尾空格。大文件可沿用CSV 流式处理方法逐条读取,但允许列表仍需放入内存。
4. 采集列表版本的性能记录
- 查看脚本帮助,再把分析结果写入
list.prof:
python3 filter_samples.py --help
python3 -m cProfile -o list.prof filter_samples.py \
samples.csv allowed.txt list.csv --index list
正常输出 kept=10000。list.csv 保存筛选结果,list.prof 是分析数据,不是文本报告。-o 会覆盖同名分析文件,读取和分析时使用同一个 Python 环境。
- 分别按累计耗时和自身耗时排序,并查看谁调用了成员查询函数:
python3 - <<'PY_STATS'
from pstats import Stats, SortKey
stats = Stats("list.prof").strip_dirs()
print("=== cumulative time ===")
stats.sort_stats(SortKey.CUMULATIVE).print_stats(12)
print("=== function's own time ===")
stats.sort_stats(SortKey.TIME).print_stats(12)
print("=== membership call sites ===")
stats.print_callers("is_allowed")
PY_STATS
根据 Python 性能分析文档,ncalls 是调用次数,tottime 不含子函数耗时,cumtime 包含子函数耗时。累计时间适合找调用入口,自身时间适合找实际工作点;不要把父子函数的累计时间相加。
本例 is_allowed() 应调用 20000 次。列表查询发生在它内部;在本次测试中它是主要热点。具体秒数、源码行号和排序会随环境变化。如果报告只显示 main() 很慢,继续查看其子函数,或用 print_callers() 确认调用位置。
5. 只构建一次集合,再检查热点
执行集合版本,并对照相同函数:
python3 -m cProfile -o set.prof filter_samples.py \
samples.csv allowed.txt set.csv --index set
python3 - <<'PY_COMPARE'
from pstats import Stats, SortKey
for name in ("list.prof", "set.prof"):
print(f"=== {name} ===")
Stats(name).strip_dirs().sort_stats(SortKey.TIME).print_stats("is_allowed")
PY_COMPARE
两次都应输出 kept=10000,is_allowed() 调用次数也保持 20000。变化的是每次查询的工作量:列表需要逐项比较,集合为重复成员查询提供哈希索引。不要在 is_allowed() 里面反复调用 set(allowed_ids),否则又会重复构建索引。
集合类型文档说明集合保存互不重复的可哈希元素。这里元素是字符串,去掉允许列表的重复项不影响存在性判断。集合不保存原列表顺序,但我们遍历的是 CSV 输入,因此结果顺序保持不变;如果业务需要按允许列表排序或计算重复次数,就不能直接套用这个改法。
6. 校验输出,再测量真实命令
- 验证字节、记录数、顺序、重复 ID 和带换行的备注:
python3 - <<'PY_CHECK'
import csv
from pathlib import Path
assert Path("list.csv").read_bytes() == Path("set.csv").read_bytes()
with open("set.csv", encoding="utf-8", newline="") as file:
reader = csv.reader(file)
assert next(reader) == ["sample_id", "note"]
rows = list(reader)
assert len(rows) == 10000
assert [row[0] for row in rows] == [
f"S{i % 10000:05d}" for i in range(20000) if i % 2 == 0
]
assert rows[0][1] == "记录 0, item\nsecond line"
assert sum(row[0] == "S00000" for row in rows) == 2
print("OK: identical bytes, 10000 rows, order and repeated IDs preserved")
PY_CHECK
应看到 OK: identical bytes, 10000 rows, order and repeated IDs preserved。这里只为核对小型演示数据把输出装入列表,不是大文件处理方式。
- 在不启用 cProfile 的情况下各运行三次,取中位数:
python3 - <<'PY_TIME'
from pathlib import Path
from statistics import median
import subprocess
import sys
import tempfile
from time import perf_counter
with tempfile.TemporaryDirectory(prefix="cprofile-timing-") as directory:
for index in ("list", "set"):
elapsed = []
for run in range(3):
output = Path(directory) / f"{index}-{run}.csv"
start = perf_counter()
subprocess.run([
sys.executable, "filter_samples.py", "samples.csv",
"allowed.txt", str(output), "--index", index,
], check=True, stdout=subprocess.DEVNULL)
elapsed.append(perf_counter() - start)
assert output.read_bytes() == Path("list.csv").read_bytes()
print(f"{index}: median_seconds={median(elapsed):.6f}")
PY_TIME
perf_counter()适合测量经过的时间。这里测量包括解释器启动、文件读写和筛选,计时用输出在临时目录中自动清理。三次本地运行仅用于确认这个改动,固定先列表后集合的顺序、文件缓存和服务器负载都会影响结果,不应据此宣传固定加速倍数。
分析器自身有开销,因此用它定位函数,用未启用分析器的命令比较整体耗时。如果优化后内存上涨,可继续用tracemalloc 快照比较检查索引的内存代价。
7. 常见问题
| 现象 | 处理方法 |
|---|---|
File exists |
输出使用 x 模式;指定一个尚不存在的输出文件。 |
| 表头或记录报错 | 按示例生成两列 CSV,核对列数和空 ID;失败时可能已留下部分输出,检查后换新输出路径重跑,不把残缺文件交给下游。 |
| 优化后行数相同但结果不同 | 先比较完整输出;检查大小写、空白清洗、排序和重复项规则是否被同时改动。 |
| 看不到明显热点 | 使用实际有代表性的输入量;先确认本例确实生成了 20000 行,避免只测启动耗时。 |
cumtime 最大的是文件或网络读取 |
检查等待来源,减少重复 I/O;集合查询只能解决这里演示的查找开销。 |
另一环境无法读取 .prof |
在生成记录的 Python 环境中运行 pstats,或重新采集;分析文件不保证跨版本兼容。 |
本例只在当前进程中采集,不能自动展开子进程内部工作。输出文件从头写入,发生中途错误时不保证完整;它是受控的性能练习,接入正式流水线前应补上输出提交策略。
小结
先保存函数级性能记录,确认重复工作在哪里,再改动对应的数据结构。保留输入顺序和重复记录,用完整输出核对语义,最后关闭分析器测量命令耗时。对这个筛选任务,把允许 ID 转成一次性集合就能减少重复扫描。
- 原文作者:春江暮客
- 原文链接:https://www.bobobk.com/python-cprofile-csv-bottleneck.html
- 版权声明:本作品采用 知识共享署名-非商业性使用-禁止演绎 4.0 国际许可协议 进行许可,非商业转载请注明出处(作者,原文链接),商业转载请联系作者获得授权。