从“坏主意”到性能优化:Python批量生成大量小文件的实践与避坑指南 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_okTrue) 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, fv1_{i:06d}.txt) with open(file_path, w, encodingutf-8) as f: f.write(ffile index: {i}, hello bad idea\n) elapsed time.perf_counter() - start print(fv1 同步写入 {file_count} 个文件总耗时 {elapsed:.3f} 秒) if __name__ __main__: write_v1(./tmp_bad_idea_v1, file_count10000)这段代码有几个细节值得解释time.perf_counter()用于精确计时比time.time()更适合短时间测量。os.makedirs(..., exist_okTrue)避免手动判断目录是否存在。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, encodingutf-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_okTrue) content hello bad idea with thread pool\n start time.perf_counter() with ThreadPoolExecutor(max_workersworkers) as executor: futures [ executor.submit( write_one_file, os.path.join(target_dir, fv2_{i:06d}.txt), content, ) for i in range(file_count) ] for future in as_completed(futures): future.result() elapsed time.perf_counter() - start print(fv2 线程池并发写入 {file_count} 个文件总耗时 {elapsed:.3f} 秒) if __name__ __main__: write_v2(./tmp_bad_idea_v2, file_count10000, workers8)这里需要注意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, encodingutf-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_okTrue) content hello bad idea with process pool\n start time.perf_counter() with ProcessPoolExecutor(max_workersworkers) as executor: futures [ executor.submit( write_one_file, os.path.join(target_dir, fv3_{i:06d}.txt), content, ) for i in range(file_count) ] for future in as_completed(futures): future.result() elapsed time.perf_counter() - start print(fv3 进程池并发写入 {file_count} 个文件总耗时 {elapsed:.3f} 秒) if __name__ __main__: write_v3(./tmp_bad_idea_v3, file_count10000, workers4)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_okTrue) start time.perf_counter() for i in range(count): with open(os.path.join(target_dir, fsync_{i:06d}.txt), w, encodingutf-8) as f: f.write(fbatch {i}\n) return time.perf_counter() - start def write_thread(target_dir: str, count: int, workers: int) - float: os.makedirs(target_dir, exist_okTrue) def _write(path: str) - None: with open(path, w, encodingutf-8) as f: f.write(batch\n) start time.perf_counter() with ThreadPoolExecutor(max_workersworkers) as executor: futures [ executor.submit(_write, os.path.join(target_dir, fthread_{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, workers8) print(fsync : {t1:.3f}s) print(fthread: {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, fbatch_{i // 1000}) os.makedirs(sub_dir, exist_okTrue) file_path os.path.join(sub_dir, ffile_{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, encodingutf-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_okTrue) start time.perf_counter() content hello with limited concurrency\n with ThreadPoolExecutor(max_workersworkers) as executor: futures [ executor.submit( write_one_with_limit, os.path.join(target_dir, fv4_{i:06d}.txt), content, ) for i in range(file_count) ] for fut in futures: fut.result() print(fv4 并发受限写入 {file_count} 个文件总耗时 {time.perf_counter() - start:.3f} 秒)信号量设置为 32表示同时最多允许 32 个文件处于打开状态。这是一种兼顾并发和资源控制的思路。6.5 监控与清理策略大量生成文件后一定要考虑清理策略。最稳妥的方式是使用独立临时目录并在脚本结束时按需清理import shutil shutil.rmtree(./tmp_bad_idea_v1, ignore_errorsTrue)如果你在运行过程中手动取消脚本残留的临时目录可以用下面的命令快速清理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 批量生成大量小文件”这个例子完整走了一遍从同步循环到并发升级再到分目录、限流和清理策略的路径。过程中我们看到了性能瓶颈来自文件系统元数据操作也看到了并发不是银弹还总结了文件句柄、磁盘占用、单目录文件过多等常见问题的排查思路。如果你下次也冒出一个类似的“坏主意”不妨按这个方式处理写一个最小原型在隔离环境跑一次记录下真实数据再决定是优化还是放弃。无论结论如何你都会比“只在脑子里想”获得更多确定的东西。