
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 批量生成大量小文件”這個例子完整走了一遍從同步循環到并發升級再到分目錄、限流和清理策略的路徑。過程中我們看到了性能瓶頸來自文件系統元數據操作也看到了并發不是銀彈還總結了文件句柄、磁盤占用、單目錄文件過多等常見問題的排查思路。如果你下次也冒出一個類似的“壞主意”不妨按這個方式處理寫一個最小原型在隔離環境跑一次記錄下真實數據再決定是優化還是放棄。無論結論如何你都會比“只在腦子里想”獲得更多確定的東西。