Python os.walk() 实战:目录遍历、日志清理与性能优化

发布时间:2026/9/10 8:56:26
Python os.walk() 实战:目录遍历、日志清理与性能优化 接手别人留下的爬虫项目时我最头疼的不是解析逻辑而是面对那棵动辄好几层、散落上万个小文件的目录树。客户让你统计磁盘占用、按日期清理过期日志、把指定类型的文件拷出来——如果靠手动递归去写第一版代码往往就在权限异常和路径拼接上翻车。后来我把标准库的os.walk()用成了这些场景的默认答案一行调用三个返回值目录再深也能稳稳遍历完。这篇东西就围绕os.walk()展开先讲清楚它的递归机制和参数含义再用三个可以直接改来用的实战脚本说明典型场景最后聊聊我踩过的坑和它在性能上的边界。适合刚学完Python基础、开始写文件处理脚本的读者也适合想系统梳理遍历方案的老手。1. 递归遍历目录这件事os.walk()为什么是默认答案1.1 手动递归为什么不可取我见过不少新手自己写递归遍历最典型的版本长这样import os def walk_dir(path): for name in os.listdir(path): full_path os.path.join(path, name) if os.path.isdir(full_path): walk_dir(full_path) else: print(full_path) walk_dir(/tmp/data)这个写法本身没有错但它把太多细节暴露给了调用方你得自己判断条目是不是目录自己处理递归深度还要自己捕获权限异常。os.listdir()加os.path.isdir()的组合对每个文件至少要发起两次系统调用目录一大性能就往下掉。更麻烦的是如果你忘了对目录层数做限制一旦遇到文件系统挂载点或符号链接循环脚本可能跑到路径超限才停下来。os.walk()的价值就在于它把这些脏活全收了它内部替你完成“进入目录—列内容—判断子目录—递归”的完整流程暴露出来的只是一个干净的三元组。你要做的就是接收它。1.2 root、dirs、files三件套与一个典型遍历流程os.walk()返回一个生成器每次迭代给出一个三元组(root, dirs, files)root当前正在遍历的目录路径字符串类型。dirs当前目录下的子目录名称列表注意是名字不是完整路径。files当前目录下的文件名称列表同样只是名字。看一个直观例子。假设目录结构是project/ ├── src/ │ ├── main.py │ └── utils/ │ └── helper.py ├── README.md └── setup.py执行for root, dirs, files in os.walk(project): print(ROOT:, root) print(DIRS:, dirs) print(FILES:, files) print(---)输出顺序为ROOT: project DIRS: [src] FILES: [README.md, setup.py] --- ROOT: project/src DIRS: [utils] FILES: [main.py] --- ROOT: project/src/utils DIRS: [] FILES: [helper.py]注意两点。第一dirs和files里的都是纯名字想拿完整路径必须用os.path.join(root, name)。第二dirs不是只读的在topdownTrue的情况下修改它可以控制接下来要进入哪些子目录这个技巧后面专门讲是os.walk()最容易被低估的能力。2. 参数细节topdown、onerror、followlinks的行为差异os.walk()的函数签名是os.walk(top, topdownTrue, onerrorNone, followlinksFalse)。新手通常只传第一个参数但后面三个参数在特定场景里能救命。2.1 topdownFalse从叶到根的主要用途默认topdownTrue表示从上往下遍历先输出顶层目录再递归进入子目录。反过来topdownFalse会先走到最深层再逐层往外返回。这个“从下往上”的顺序最典型的使用场景是删除一整棵目录树。删除目录的前提是目录为空所以必须先删掉目录里的文件、再删子目录最后删父目录顺序必须从叶子到根。如果从头往下删你会遇到“目录非空”的报错。import os for root, dirs, files in os.walk(/tmp/old_project, topdownFalse): for name in files: os.remove(os.path.join(root, name)) os.rmdir(root)这个脚本会从最深层开始删光文件后把空目录一层层剥掉。当然日常清空目录用shutil.rmtree()更省事但如果你希望在删除前记录日志、跳过某些文件或者需要对每个目录做额外判断这种手写方式更有掌控感。2.2 onerror权限错误不能总是静默os.walk()默认策略是“遇到无法访问的目录就跳过”。跳过的结果就是你少遍历了一部分文件而程序毫无感知。这在统计磁盘占用或搜索文件时很危险你以为扫完了其实漏了一整块。处理方式是传入onerror参数它接收一个函数这个函数会收到OSError实例。最常见的用法是打印日志import os def handle_error(err): print(f无法访问: {err.filename}, 错误: {err.strerror}) for root, dirs, files in os.walk(/var/www, onerrorhandle_error): for name in files: print(os.path.join(root, name))err.filename里通常是出错的那个目录路径err.strerror是权限错误等信息。把这里换成logging.warning就可以在长任务里留下完整审计记录不至于出问题时连原因都查不到。2.3 followlinks跟随符号链接的代价followlinks默认是False也就是说不跟随符号链接进入目录。这个默认值非常重要。在Linux/macOS上符号链接很常见——ln -s /tmp/data /tmp/data/self_link创建一个指向自身的链接。如果你把followlinks设为Trueos.walk(/tmp/data, followlinksTrue)会顺着这个链接无限递归下去最终因路径过长抛异常或耗尽资源。有个细节很多人不清楚当followlinksFalse时指向目录的符号链接会被放进files列表而不是dirs列表。也就是说它被当成一个普通文件处理根本不会被递归进入。只有改为True这些链接才会被当成目录并继续深入。所以除非你确认目录结构里没有任何环路否则不要轻易打开这个开关。遇到确实需要跟随链接的场景我建议在回调里对路径深度做限制同时记录日志。3. 三个高频实战清理日志、统计磁盘占用、拷贝部分文件这一节是能直接抄作业的部分。三个场景分别对应文件处理里最常见的需求统计、清理、拷贝。3.1 实战A按扩展名统计磁盘占用接手一台日志服务器时第一件事通常是搞清楚磁盘空间被谁占了。用os.walk()遍历所有文件按扩展名汇总大小import os from collections import defaultdict target_dir /var/log size_by_ext defaultdict(int) file_count 0 for root, dirs, files in os.walk(target_dir): for name in files: full os.path.join(root, name) try: ext os.path.splitext(name)[1].lower() or (无扩展名) size_by_ext[ext] os.path.getsize(full) file_count 1 except FileNotFoundError: pass print(f共扫描 {file_count} 个文件) for ext, size in sorted(size_by_ext.items(), keylambda x: -x[1])[:15]: print(f{ext:15s} {size / 1024 / 1024:10.2f} MB)这里我做了一个防御性处理文件可能在你遍历的间隙被其他进程删除os.path.getsize()就会抛FileNotFoundError用try/except跳过即可。输出结果按大小倒序一眼就能看出哪种类型的文件最占空间。3.2 实战B删除30天前的旧日志先预演清理日志不能上来就删我的习惯是先“预演”一遍打印出将要删除的文件清单确认无误后再执行真实删除。import os import time cutoff time.time() - 30 * 24 * 3600 # 30天前 dry_run True # 改成 False 则真正执行删除 count 0 for root, dirs, files in os.walk(/app/logs/task): for name in files: if not name.endswith(.log): continue full os.path.join(root, name) try: mtime os.path.getmtime(full) except FileNotFoundError: continue if mtime cutoff: count 1 if dry_run: print(f[预演] 待删除: {full}) print(f 最后修改: {time.ctime(mtime)}) else: os.remove(full) print(f已删除: {full}) print(f共找到 {count} 个超过30天的日志文件)time.time() - 30 * 24 * 3600计算出30天前的时间戳os.path.getmtime()拿到文件最后修改时间小于这个阈值就说明文件够老了。dry_run这个开关是我处理任何批量删除任务的标配宁可多跑一次预演也不想让误删变成事故。3.3 实战C按规则拷贝文件并重建目录层级第三种场景是把源目录里的非临时文件拷贝到备份目录同时保持目录结构不变。关键在于用os.path.relpath计算相对路径再在目标目录下重建相同层级import os import shutil src_dir /home/user/project dst_dir /backup/project for root, dirs, files in os.walk(src_dir): for name in files: if name.endswith(.tmp) or name.endswith(.bak): continue full os.path.join(root, name) rel os.path.relpath(full, src_dir) target os.path.join(dst_dir, rel) os.makedirs(os.path.dirname(target), exist_okTrue) if not os.path.exists(target): shutil.copy2(full, target)rel是文件相对源目录的路径比如src/utils/helper.pytarget就是目标目录下的对应路径。os.makedirs(os.path.dirname(target), exist_okTrue)保证多级目录不存在时自动创建exist_okTrue让它不因目录已存在而报错。copy2能保留文件的修改时间和元数据做备份时比copyfile更合适。4. 踩坑记录遍历中修改dirs、编码问题、文件竞态4.1 原地修改dirs剪枝的两种写法只有一种有效这是os.walk()最隐蔽的一个坑但也是它最强大的功能之一。当topdownTrue时os.walk()每次会读取当前dirs列表来决定下一轮要遍历哪些子目录所以你可以“剪枝”——在循环体里过滤掉不需要进入的目录。正确的写法是原地修改用[:]切片赋值for root, dirs, files in os.walk(/home/user): # 原地修改进入下一层目录前把不需要的目录剔除 dirs[:] [d for d in dirs if d not in (.git, node_modules, __pycache__)] for name in files: print(os.path.join(root, name))我见过有人写成dirs [d for d in dirs if ...]这种重绑定的写法是无效的。原因在于os.walk()内部在生成下一轮迭代时引用的是它自己保持的那个列表对象而你重新赋值只是把局部变量指向一个新的列表内部引用根本看不到。所以必须用dirs[:] ...直接在原列表上做修改。这个技巧在跳过巨型依赖目录、只遍历业务代码时特别好用。当年我第一次遍历整个前端工程没做剪枝结果被node_modules里几万个文件拖到怀疑人生。4.2 Windows终端中文乱码与输出编码Windows下跑os.walk()如果打印的文件名包含中文控制台经常报UnicodeEncodeError: gbk codec cant encode character。这不是os.walk()的问题而是终端编码和文件名编码不一致。解决方式有两种。一是运行时设置环境变量set PYTHONIOENCODINGutf-8 python your_script.py二是在脚本里动态调整标准输出编码Python 3.7及以上可以直接import sys sys.stdout.reconfigure(encodingutf-8)放到脚本开头重新用os.walk()遍历并打印中文路径就不会再报错了。这个坑不影响程序逻辑但耽误调试时间尤其是你第一次在Windows上跑Linux源码目录的时候。4.3 遍历时文件被删除、路径拼接等竞态问题在实时变化的目录上做遍历文件随时可能被其他进程删除。最典型的是日志目录处理某个文件时它刚好被logrotate轮转走了open()或os.remove()就会抛异常。我的建议很简单所有涉及文件操作的地方都做好防御。不光是os.path.getsize()还包括os.path.getmtime()、os.remove()、open()按照场景加上try/except FileNotFoundError或try/except OSError。另一个细节是路径拼接一定要用os.path.join(root, name)。虽然很多代码里直接写root / name也能跑但到了Windows上会得到C:\data\folder\file和C:\data/folder/file混在一起的路径很多系统工具不接受这种格式。os.path.join会根据操作系统自动选择分隔符这是跨平台底线。5. 十万级文件场景os.walk的底层机制与优化5.1 os.walk基于scandir比listdir快在哪Python 3.5之后os.walk()内部从os.listdir()换成了os.scandir()。scandir的改进在于它在读取目录项时能顺带拿到文件类型等信息而不是把每个条目的名字都返回后再额外调用一次stat来确认它是不是目录。这意味着os.walk()在遍历超大目录时比手写os.listdir()加os.path.isdir()的组合快不少。这个差距在文件数量达到十万级以上时非常明显我实测过一个包含约20万文件的目录用os.walk()遍历到全部路径比listdir方案快了接近一倍。另外os.walk()是一个生成器它不会把所有文件路径一次性加载到内存。你边遍历边处理内存占用基本恒定这和os.listdir()一次性返回所有条目再逐个判断的做法有本质区别。5.2 十万级文件下stat是瓶颈何时该自己写scandir虽然os.walk()本身快但如果你在循环里对每个文件都执行os.path.getsize()或os.path.getmtime()这些操作每次都是一次系统调用。10万个文件就是10万次stat累积起来仍然可观。对绝大多数场景我建议先别急着优化用os.walk()写完跑一遍再说。只有当你明确感知到性能瓶颈并且确认瓶颈在“逐个取文件属性”上时才考虑自己用os.scandir()直接写遍历逻辑import os def fast_scan(root): for entry in os.scandir(root): if entry.is_dir(follow_symlinksFalse): yield from fast_scan(entry.path) else: yield entryscandir返回的DirEntry对象自带entry.stat()而且是带缓存的——同一个entry重复调用stat()不会重复发起系统调用。这一点在既需要文件大小、又需要修改时间、还需要判断文件类型的场景里节省的系统调用数量非常可观。但这种写法放弃了os.walk()的topdown、onerror等现成能力相当于重新实现一遍轮子。我的建议是目录文件数量在十万级以下os.walk()完全够用如果到了百万级且确实需要统计全量文件属性再考虑换成scandir自写遍历。5.3 sorted控制遍历顺序让输出可复现os.walk()返回的dirs和files顺序由底层文件系统决定通常不是字典序。如果你需要生成一份顺序稳定的清单或者希望按文件名排序输出可以直接对列表排序for root, dirs, files in os.walk(/tmp/data): dirs.sort() files.sort() for name in files: print(os.path.join(root, name))注意list.sort()是原地排序正好符合dirs需要原地修改的要求所以不用担心剪枝失效。这里和4.1节的坑不冲突sort()修改的是列表内部元素顺序没有重新绑定对象。如果你想按修改时间倒序处理文件可以这样排序for root, dirs, files in os.walk(log_dir): files.sort( keylambda f: os.path.getmtime(os.path.join(root, f)), reverseTrue )不过这会在每个目录里都触发一次stat访问适合目录规模不大的场景。如果全目录文件超过几十万更稳妥的方式是先收集再统一排序。6. 与Path.rglob()怎么选一条清晰的分界线6.1 Path.rglob()的最简用法和适用边界pathlib引入了面向对象的路径操作后很多人习惯用Path.rglob()做递归查找。它的语法确实更简洁尤其适合按模式匹配文件的场景from pathlib import Path for p in Path(project).rglob(*.py): print(p)还有Path.glob(**/*.py)效果与rglob(*.py)一致。如果你只需要“查找所有符合某个后缀/模式的文件”rglob一行就能搞定不需要处理root、dirs、files三元组。但rglob的短板也很明显它返回的是匹配模式的文件路径如果你要“遍历所有目录、对每个目录单独做一件额外的事”它会变得别扭。更关键的是在较老的版本里它无法优雅地跳过某些目录——如果你想在遍历node_modules之前把它剪掉rglob并没有直接提供这种挂钩。6.2 需要控制目录结构时os.walk()仍然更合适当遍历逻辑不只是一个简单的模式匹配而是需要“对每个目录本身做处理”时os.walk()的手感更好。举几个典型场景删除目录树需要topdownFalse按叶子到根的次序处理。统计每个子目录的大小你在for root, dirs, files循环体里可以按root为维度聚合数据。遍历时跳过某些目录通过原地修改dirs实现。在遍历过程中记录访问权限错误通过onerror回调实现。这些能力在rglob里要么没有要么需要额外拍一堆逻辑。所以我的判断标准是如果只是“找文件”用rglob如果是对目录结构做整体操作用os.walk()。6.3 选型对照一个表格解决多数纠结维度os.walk()Path.rglob()遍历粒度按目录层级逐个交出root、dirs、files只交出匹配到的文件/目录Path目录剪枝支持dirs[:] [...]需要配合自写逻辑从下往上遍历支持topdownFalse不支持权限错误处理支持onerror回调出错时直接抛异常返回值类型字符串路径Path对象可直接用Path API适用场景目录结构操作、全量遍历、统计、清理按文件名/后缀模式查找另外补充一点Python 3.12之后pathlib新增了Path.walk()用法和os.walk()几乎一致但返回的是Path对象from pathlib import Path for root, dirs, files in Path(project).walk(): ...如果你所在的项目已经开始全面拥抱pathlib可以考虑优先用Path.walk()。但存量代码里如果是字符串路径为主os.walk()仍然是通用性最强的方案二者在性能上没有本质差异。最后分享一个我个人的小习惯写任何os.walk()脚本我都会先加一个“预演模式”——只打印路径的遍历顺序和前20条结果确认遍历范围符合预期再放开处理逻辑。这个习惯帮我避开了好几次误删和遍历范围过大导致的性能问题。os.walk()本身不复杂但把它用好看靠的是这些藏在细节里的把握。