"I got a bad idea.." 这种念头,估计每个开发者在写代码或准备测试时都冒出来过。很多时候我们会下意识地压制它,觉得正规方案应该更专业、更稳妥。但在我最近的实践里,恰恰是这个看起来不怎么样的坏主意,帮我用最短路径验证了一类性能场景的边界。
这篇文章想借一个典型场景展开:使用 Python 快速生成大量小文件。这个需求听起来简单,背后却藏着一堆容易被忽略的细节,比如文件句柄、磁盘 IO、并发模型和目录结构。我将从第一版“肉眼可见不优雅”的脚本开始,逐步分析它的性能表现和坑点,然后给出更合理的优化方向。如果你也经常要准备测试数据,或者需要验证文件迁移、备份工具在小文件场景下的表现,这篇内容会比较适合你。
1. 背景与核心概念
1.1 开发语境下的“坏主意”是什么
这里的 bad idea,并不是指逻辑错误、安全漏洞或明显不可行的方案,而是指那些“看起来不够优雅、不够标准化,甚至有点笨”的临时思路。比如:
- 测试数据不够,直接写一个循环生成 2 万个临时文件。
- 想验证接口性能,不求助于压测工具,而是用脚本慢速发请求。
- 临时排查线上问题,不去搭监控,而是打印一堆日志再分析。
这类思路之所以被称为“坏主意”,是因为它们在工程化视角下有很多问题:没有复用性、缺少参数校验、性能不一定好、不够安全。但换一个角度看,它们又是低成本探索的代名词。很多复杂问题,恰恰是在这种“先跑起来再说”的过程中暴露出来的。
1.2 场景还原:为什么要一次生成大量小文件
文件系统层面的小文件性能问题,在网络存储、对象存储、日志归档、数据迁移等场景中非常常见。例如:
- 测试对象存储工具在海量小文件场景下的上传耗时。
- 验证备份脚本对几十万个文本文件的扫描效率。
- 模拟一段业务日志目录,供日志采集 Agent 消费。
- 评估文件同步工具在文件数量巨大时的分片策略。
在这些场景里,你需要先“造一批数据”。手动复制文件太慢,系统命令在某些环境里又不一定可用,于是最直接的想法就是:写一个 Python 脚本,循环 open、write、close。这个方案听起来像典型的“坏主意”,因为它没有考虑性能,也没有考虑文件系统限制。但它足够直观,也足够快速。
1.3 为什么要保留坏主意
我并不是鼓励把所有不规范的思路直接搬到生产环境,而是建议在方案评估初期,保留一个“验证窗口”。理由是:
- 成本低:一个最小的循环脚本,可能只需要十几行代码,跑一次就能得到真实数据。
- 能暴露边界:文件数量的上升会带来什么问题,只有真实测试才会告诉你。
- 帮助你建立性能直觉:纸上谈兵猜不出 10000 个小文件要写多久,但跑一遍就有了感性认识。
- 为正式方案提供对照基线:后续无论使用并发、分目录还是换存储,都可以和最初的“坏主意”版本做对比。
因此,本文会完整演示这条路径:写一个看起来很不专业的脚本,看它跑出什么结果,再分析慢在哪里,最后找到改进方向。
2. 环境准备与实验目标
2.1 运行环境说明
本文示例以 Python 脚本为主,使用标准库os、time、concurrent.futures,不需要安装第三方依赖。示例环境为 Python 3.10 以上版本,Windows、Linux、macOS 均可运行,但不同操作系统的文件系统行为会略有差异。
需要说明的是,不同磁盘类型对结果影响非常大:机械硬盘写入大量小文件时,随机寻道开销明显;SSD 相对快很多;而 Linux 的 tmpfs 或 Windows 虚拟内存盘则完全在内存中运行,速度最快。因此,下面给出的耗时数据只作为相对对比参考,你需要在你的实际环境中重新跑一遍。
2.2 实验目标
我们希望回答下面几个问题:
- 用最朴素的 for 循环写 10000 个小文件,到底需要多久?
- 换成线程池并发,性能是否一定提升?
- 换成进程池,会不会更快?
- 不同方案在文件数量增大后,各自会遇到什么问题?
- 从工程角度,最优方案是不是只剩“减少文件数量”一条路?
带着这些问题,我们开始动手。
2.3 项目结构与产物
为了保持实验清晰,我建议把脚本分别保存为独立文件,每次运行生成独立的临时目录。整体结构如下:
bad_idea_lab/ ├── file_writer_v1.py # 第一版:同步循环写文件 ├── file_writer_v2.py # 第二版:线程池并发写文件 ├── file_writer_v3.py # 第三版:进程池并发写文件 ├── benchmark.py # 简单对比脚本 └── tmp_bad_idea_v1/ # 运行后自动生成,用完可删除这样做的好处是每个版本独立可运行,方便对比耗时,也方便清理垃圾文件。
3. 第一版坏主意:同步循环写文件
3.1 思路分析
第一版方案最简单:通过os.makedirs建立目录,然后使用 for 循环逐个创建文件,每个文件只写入几行文本。这是一个非常经典的“坏主意”模板,因为它完全没有考虑性能,也没有处理可能出现的句柄和磁盘占用问题。
但正是这种粗暴方案,能最直观地反映文件系统处理小文件时的基础成本。
3.2 完整代码
# 文件路径:file_writer_v1.py import os import time def create_dir(target: str) -> None: """创建目标目录,已存在时忽略错误。""" os.makedirs(target, exist_ok=True) def write_v1(target_dir: str, file_count: int = 10000) -> None: """第一版:同步循环写入 file_count 个小文件。""" create_dir(target_dir) start = time.perf_counter() for i in range(file_count): file_path = os.path.join(target_dir, f"v1_{i:06d}.txt") with open(file_path, "w", encoding="utf-8") as f: f.write(f"file index: {i}, hello bad idea\n") elapsed = time.perf_counter() - start print(f"v1 同步写入 {file_count} 个文件,总耗时 {elapsed:.3f} 秒") if __name__ == "__main__": write_v1("./tmp_bad_idea_v1", file_count=10000)这段代码有几个细节值得解释:
time.perf_counter()用于精确计时,比time.time()更适合短时间测量。os.makedirs(..., exist_ok=True)避免手动判断目录是否存在。with open(...)保证文件正常关闭,这是最低限度的规范。- 文件名使用
:06d格式化,保证排序时不会出现v1_10排在v1_9前面的情况。
3.3 运行方式
在终端中执行:
python file_writer_v1.py运行结束后,当前目录会出现tmp_bad_idea_v1目录,里面包含 10000 个 txt 小文件。脚本会输出总耗时。
3.4 预期结果与现象分析
以我的测试环境为例,单次运行大概会输出:
v1 同步写入 10000 个文件,总耗时 18.662 秒注意,这个数字仅供参考。在机械硬盘上可能超过 1 分钟,在高端 SSD 上可能只要几秒。真正值得关注的是:10000 个文件本身并不算多,但同步写入时间已经明显超出预期。
这说明大量小文件的写入瓶颈,通常不在于文件内容本身的大小,而在于文件系统需要为每个文件分配 inode、维护目录项、更新元数据。这些操作在机械硬盘上会带来随机寻址开销,在 SSD 上虽然好一些,但依然是有成本的。
所以第一版脚本第一次跑完,我们就已经得到了一个关键结论:“批量创建小文件”绝不是普通循环能高效完成的任务。
4. 数据异常:到底慢在哪
4.1 从现象看规律
如果继续增加文件数量,比如从 10000 增加到 50000,会发现耗时并不是线性增长,而有可能是接近线性甚至超线性增长。原因在于:
- 单目录下的“目录项”文件会越来越大。
- 文件系统在查询和插入目录项时,需要维护索引结构。
- 磁盘剩余空间减少后,分配数据块时可能变得更复杂。
这也是为什么很多文件迁移工具强调“按时间或前缀分目录存储”,而不是把所有文件堆在同一个目录下。
4.2 文件系统层面的成本拆解
一个小文件从创建到写入完成,大概要经历以下步骤:
- 在目录中检查文件名是否存在。
- 分配 inode。
- 在目录结构中插入新的目录项。
- 分配数据块。
- 将用户态数据复制到内核页缓存。
- 关闭文件时,可能需要刷新元数据到磁盘。
其中,第 3 步和第 4 步在大批量小文件场景下最容易成为瓶颈。尤其当目录中文件数量达到上万甚至十万级别,目录项相关的查找和维护成本会迅速上升。
4.3 为什么不能只凭“慢”来下结论
第一版脚本跑得慢,不代表 Python 语言慢,也不代表循环写法差。为了找出真正原因,我们需要控制变量。比较直观的做法是:
- 同样写 10000 个文件,但提前生成好内容,只测 open/write/close 的开销。
- 改成把 10000 行内容写入同一个文件,对比总耗时。
- 分别测试空文件和小文件中写入若干字节的差异。
通过这些对照,你会发现单文件写入本身非常快,真正的开销在于每个独立文件带来的系统调用和元数据操作。这也是后续所有优化方向的出发点。
5. 坏主意升级:并发写文件对比
5.1 线程池版本
既然同步循环慢,很多人会立刻想到“用多线程提速”。这个思路在文件 IO 场景下有一定道理,因为文件读写时线程经常处于等待状态,Python 的 GIL 通常会在 IO 等待时释放,所以并发线程可以重叠一部分等待时间。
下面是一个线程池版本:
# 文件路径:file_writer_v2.py import os import time from concurrent.futures import ThreadPoolExecutor, as_completed def write_one_file(file_path: str, content: str) -> None: """写入单个文件。""" with open(file_path, "w", encoding="utf-8") as f: f.write(content) def write_v2(target_dir: str, file_count: int = 10000, workers: int = 8) -> None: """线程池并发写入 file_count 个小文件。""" os.makedirs(target_dir, exist_ok=True) content = "hello bad idea with thread pool\n" start = time.perf_counter() with ThreadPoolExecutor(max_workers=workers) as executor: futures = [ executor.submit( write_one_file, os.path.join(target_dir, f"v2_{i:06d}.txt"), content, ) for i in range(file_count) ] for future in as_completed(futures): future.result() elapsed = time.perf_counter() - start print(f"v2 线程池并发写入 {file_count} 个文件,总耗时 {elapsed:.3f} 秒") if __name__ == "__main__": write_v2("./tmp_bad_idea_v2", file_count=10000, workers=8)这里需要注意future.result()的调用。它一方面用于获取结果,另一方面也能让底层异常被抛出。如果某个文件写入失败,比如磁盘满或权限不足,脚本不会静默失败。
5.2 进程池版本
进程池相比线程池,能够利用多核 CPU,但也会带来进程创建和进程间通信的开销。在单纯写文件场景下,进程池不一定比线程池更优,但它是一个值得对比的参考:
# 文件路径:file_writer_v3.py import os import time from concurrent.futures import ProcessPoolExecutor, as_completed def write_one_file(file_path: str, content: str) -> None: """写入单个文件。""" with open(file_path, "w", encoding="utf-8") as f: f.write(content) def write_v3(target_dir: str, file_count: int = 10000, workers: int = 4) -> None: """进程池并发写入 file_count 个小文件。""" os.makedirs(target_dir, exist_ok=True) content = "hello bad idea with process pool\n" start = time.perf_counter() with ProcessPoolExecutor(max_workers=workers) as executor: futures = [ executor.submit( write_one_file, os.path.join(target_dir, f"v3_{i:06d}.txt"), content, ) for i in range(file_count) ] for future in as_completed(futures): future.result() elapsed = time.perf_counter() - start print(f"v3 进程池并发写入 {file_count} 个文件,总耗时 {elapsed:.3f} 秒") if __name__ == "__main__": write_v3("./tmp_bad_idea_v3", file_count=10000, workers=4)5.3 对比结果与隐藏问题
在我本地的 Linux + SSD 环境上,10000 个文件的大致表现是:
| 方案 | 耗时量级 | 说明 |
|---|---|---|
| 同步单线程 | 15 ~ 25 秒 | 最差,但最稳 |
| 线程池 8 并发 | 5 ~ 10 秒 | 提升明显 |
| 进程池 4 并发 | 6 ~ 12 秒 | 有一定提升,但不如线程池显著 |
需要强调,这个对比并不严谨。文件系统缓存、磁盘剩余空间、文件大小、并发数都会影响结果。但从趋势上看,并发确实能改善写入耗时。
但并发也带来新的隐藏问题:
- 线程数过大时,系统同时打开大量文件句柄,可能触及
ulimit限制。 - 并发度太高时,磁盘寻道和系统调度开销可能反而增加。
- 如果文件名生成逻辑有问题,可能出现覆盖写入,导致实际写入文件数少于预期。
- 程序运行过程中一旦被中断,会留下一堆半成品文件,下次清理成本很高。
所以并发不是银弹,它只是把瓶颈从“串行等待”转移到了“资源竞争”。
5.4 用数据判断,不凭感觉
为了让对比更严谨,可以写一个简单的benchmark.py,把同步和线程池版本放到同一个进程里跑,并分别计时:
# 文件路径:benchmark.py import os import time from concurrent.futures import ThreadPoolExecutor, as_completed def write_sync(target_dir: str, count: int) -> float: os.makedirs(target_dir, exist_ok=True) start = time.perf_counter() for i in range(count): with open(os.path.join(target_dir, f"sync_{i:06d}.txt"), "w", encoding="utf-8") as f: f.write(f"batch {i}\n") return time.perf_counter() - start def write_thread(target_dir: str, count: int, workers: int) -> float: os.makedirs(target_dir, exist_ok=True) def _write(path: str) -> None: with open(path, "w", encoding="utf-8") as f: f.write("batch\n") start = time.perf_counter() with ThreadPoolExecutor(max_workers=workers) as executor: futures = [ executor.submit(_write, os.path.join(target_dir, f"thread_{i:06d}.txt")) for i in range(count) ] for fut in as_completed(futures): fut.result() return time.perf_counter() - start if __name__ == "__main__": n = 5000 t1 = write_sync("./bench_sync", n) t2 = write_thread("./bench_thread", n, workers=8) print(f"sync : {t1:.3f}s") print(f"thread: {t2:.3f}s")运行结果可能类似:
sync : 9.412s thread: 4.887s这再次说明,在批量小文件场景中,合理并发是有效的优化方向之一。但到底选线程池还是进程池,要结合实际环境来决定。
6. 优化思路:从坏主意走向可行方案
6.1 根本性优化:减少文件数量
性能最好的“文件写入方案”,是不创建那么多文件。如果业务允许,可以考虑把多个小文件合并成一个文件,或者使用压缩包、数据库、队列等方式替代。例如:
- 日志场景可以使用追加式日志文件,而不是每天生成成千上万个 txt。
- 数据交换场景可以使用 JSON Lines 或 Parquet 文件,将多条记录放在一个文件内。
- 临时测试数据可以直接用 tar 或其他打包工具生成。
减少文件数量不仅能提升写入速度,还能降低后续备份、迁移、清理的成本。
6.2 分目录存储
如果业务确实需要独立的小文件,那么尽量避免把所有文件塞进同一个目录。可以根据时间、哈希值或业务 ID 划分子目录。例如:
import os base_dir = "./data" for i in range(10000): sub_dir = os.path.join(base_dir, f"batch_{i // 1000}") os.makedirs(sub_dir, exist_ok=True) file_path = os.path.join(sub_dir, f"file_{i:06d}.txt") # 写入 file_path每个目录只放 1000 个文件,目录项规模小,查找和维护开销明显降低。你还可以用首字母、日期等更合理的分片规则。
6.3 使用更快的存储介质
如果目标是验证业务逻辑而不是测试磁盘性能,可以把临时数据放到 tmpfs 等内存文件系统上。这样文件写入操作不会真正落到磁盘,速度会大幅提升。
Linux 下的示例:
mkdir /dev/shm/bad_idea_data python file_writer_v1.py /dev/shm/bad_idea_dataWindows 下可以临时使用内存盘工具,或直接把数据放在 SSD 的临时目录中。需要提醒的是,内存文件系统的数据在系统重启后会丢失,不适合存放重要数据。
6.4 控制并发打开的文件句柄数
即使使用线程池,也不要无脑设置几百个线程。文件句柄是系统资源,过高并发可能导致OSError: [Errno 24] Too many open files。可以通过信号量限制同时写入的文件数:
import threading import os import time from concurrent.futures import ThreadPoolExecutor semaphore = threading.Semaphore(32) def write_one_with_limit(file_path: str, content: str) -> None: with semaphore: with open(file_path, "w", encoding="utf-8") as f: f.write(content) def write_v4(target_dir: str, file_count: int = 10000, workers: int = 8) -> None: os.makedirs(target_dir, exist_ok=True) start = time.perf_counter() content = "hello with limited concurrency\n" with ThreadPoolExecutor(max_workers=workers) as executor: futures = [ executor.submit( write_one_with_limit, os.path.join(target_dir, f"v4_{i:06d}.txt"), content, ) for i in range(file_count) ] for fut in futures: fut.result() print(f"v4 并发受限写入 {file_count} 个文件,总耗时 {time.perf_counter() - start:.3f} 秒")信号量设置为 32,表示同时最多允许 32 个文件处于打开状态。这是一种兼顾并发和资源控制的思路。
6.5 监控与清理策略
大量生成文件后,一定要考虑清理策略。最稳妥的方式是使用独立临时目录,并在脚本结束时按需清理:
import shutil shutil.rmtree("./tmp_bad_idea_v1", ignore_errors=True)如果你在运行过程中手动取消脚本,残留的临时目录可以用下面的命令快速清理:
rm -rf tmp_bad_idea_v1 tmp_bad_idea_v2 tmp_bad_idea_v3也可以在脚本开头自动清理旧目录,避免下次运行时出现文件数叠加。
7. 常见问题与排查思路
7.1 高频问题清单
| 问题现象 | 常见原因 | 解决思路 |
|---|---|---|
报错OSError: [Errno 24] Too many open files | 并发打开文件句柄过多,超过系统限制 | 使用信号量限制并发数,减少max_workers |
| 写入完成后文件数少于预期 | 文件名重复或任务被中断 | 使用唯一编号或uuid,增加中途异常处理 |
| 磁盘空间快速耗尽 | 未预估单个文件大小和总文件数乘积 | 先计算总大小,设置磁盘占用上限 |
| 单目录文件过多导致操作卡顿 | 目录项维护成本升高 | 按前缀或批次拆分子目录 |
| 线程池版本反而更慢 | 并发数设置过高,或磁盘性能有限 | 降低并发数,对比不同并发度 |
| 脚本中断后残留大量垃圾文件 | 未设计清理机制 | 使用统一临时目录,脚本退出时清理 |
| 不同机器上耗时差异巨大 | 磁盘类型、文件系统、缓存策略不同 | 明确记录环境信息,再对比数据 |
7.2 文件句柄耗尽的排查步骤
如果遇到句柄耗尽,可以按以下顺序排查:
- 检查当前系统打开文件限制。Linux 下执行
ulimit -n。 - 检查脚本是否所有文件都使用了
with open(...)。如果用了手工open()却忘记close(),很容易泄漏句柄。 - 检查线程池或进程池是否清理完成,是否需要显式
shutdown()。 - 调大系统限制需要谨慎,推荐优先调整代码并发逻辑。
7.3 为什么线程池写文件比同步快
Python 的 GIL 对 CPU 密集型任务影响较大,但文件读写涉及系统调用,线程在等待 IO 时会释放 GIL,因此线程池能有效重叠多个文件的等待时间。如果你的磁盘和操作系统能支持并发 IO,线程池通常就能带来明显提升。
7.4 为什么进程池没有想象中快
进程池虽然能利用多核,但每个进程都需要独立初始化 Python 解释器,进程间任务分发也有通信成本。对于简单的小文件写入,这些开销可能抵消并发收益。更重要的是,磁盘 IO 的瓶颈往往不在 CPU,而在于设备的响应能力。多进程不能凭空提高磁盘吞吐。
8. 工程实践:如何对待开发中的坏主意
8.1 先用最小原型验证
当你冒出“坏主意”时,不必急着否定它。先问自己:实现这个原型需要多久?如果是十分钟之内,完全可以写出来跑一遍。原型的作用不是成为最终方案,而是帮你尽早看到真实数据和潜在问题。
本文中的 v1 脚本就是典型的最小原型。它没有参数校验,没有优雅的错误处理,甚至没有考虑文件命名冲突。但它只用了不到二十行代码,就让我们获得了第一手性能数据。
8.2 用指标代替直觉
讨论方案好坏时,不要总说“感觉这样更快”“这样更稳”。建议记录以下几个维度的指标:
- 总耗时。
- 文件数量。
- 单个文件大小。
- 磁盘空间占用。
- 系统打开文件数量。
- 并发线程数或进程数。
这些指标构成了一个可复现的实验环境。后续优化时,你只需要改变一个变量,就能判断它是否有效。
8.3 在隔离环境中实验
“坏主意”之所以危险,是因为一旦直接在正式目录或生产环境运行,后果可能不可控。比如用脚本在线上业务目录里生成海量文件,会快速消耗磁盘空间,甚至影响业务读写。
正确的做法是:
- 使用独立的临时目录。
- 估算总文件大小并预留充足空间。
- 在测试环境或容器中运行。
- 确保脚本可以随时中止,并设计清理方案。
8.4 把坏主意沉淀为文档或 issue
很多团队只记录最终方案,不记录被淘汰的“坏主意”。这其实是一种浪费。一次实验得到的性能数据、排错过程、失败原因,可能比最终上线的那几行代码更有价值。
建议在项目的docs目录或 issue 中,简单记录:
- 当时为什么提出这个方案。
- 原型如何实现。
- 测试数据是什么。
- 为什么最终没有采用或如何优化。
- 对后续项目的借鉴意义。
这些“坏主意记录”,往往能帮助团队避免重复踩坑。
8.5 从坏主意中提炼规范
坏主意实验做完后,不要止步于“原来这样不行”。进一步思考:什么样的情况下,这个方案是可行的?什么情况下必须避免?
回到本文的场景,可以提炼出以下经验:
- 如果只是临时造几十个文件,循环写没问题。
- 如果文件数达到几千甚至数万,优先考虑分目录和并发。
- 如果文件数达到十万以上,单机脚本可能已经不适合,需要借助专门工具或分布式方案。
- 无论哪种场景,都要设计好文件清理机制。
这些经验比“不要使用坏主意”更有操作价值。
9. 总结
回到最初的标题:I got a bad idea。在实际开发中,我们真的不需要害怕这种想法。真正需要注意的是“如何处理坏主意”——是直接压下去,还是给它一个低成本验证窗口,再从验证结果里提炼出有效信息。
本文通过“用 Python 批量生成大量小文件”这个例子,完整走了一遍从同步循环到并发升级,再到分目录、限流和清理策略的路径。过程中我们看到了性能瓶颈来自文件系统元数据操作,也看到了并发不是银弹,还总结了文件句柄、磁盘占用、单目录文件过多等常见问题的排查思路。
如果你下次也冒出一个类似的“坏主意”,不妨按这个方式处理:写一个最小原型,在隔离环境跑一次,记录下真实数据,再决定是优化还是放弃。无论结论如何,你都会比“只在脑子里想”获得更多确定的东西。