资讯动态

递归遍历与并发上传:本地文件夹高效迁移到OSS的实战指南

发布时间:2026/10/24 7:06:09 来源:尧图企业网站定制
最近在做一个文件归档的需求。本地一个目录里面有三万多张图片、几百个PDF小册子、十几个子文件夹有的文件名带空格还带中文目录层级最深大概七层。需求很明确把这个目录整体搬到云端对象存储OSS上去目录结构必须原样保留。我一开始没太当回事上传文件夹嘛写个循环挨个传不就行了结果真正做到一半就发现事情远没有想象中那么简单——文件数量多到不能一次性全部列出来否则内存直接爆掉传大文件用最简单的直传传一半断了就得从头再来Windows下的路径分隔符和OSS的key对不上传上去目录树全是乱的。折腾了一轮之后最后用递归算法把整个目录树遍历清楚再配合并发上传和断点续传才算稳定跑通。这篇就聊一聊这个递归上传文件夹到OSS的实现思路和踩坑过程。1. 整体方案设计递归遍历加并发上传1.1 为什么递归是正确答案上传文件夹这个需求核心难点不是“上传”而是“怎么把本地目录结构完整地表达成OSS里的一串key”。OSS没有真正意义上的文件夹它只有一个扁平的对象键Object Key。所谓的“目录”其实是key里带斜杠前缀形成的视觉分组。所以问题就变成了给定一个本地目录树怎么生成所有文件的、带完整相对路径的key列表。这个问题的自然解法就是递归。目录本身就是一种递归结构一个文件夹里要么是文件要么是子文件夹子文件夹里又是同样的规则。用递归函数去扫描目录树遇到文件就交给上传逻辑遇到子目录就继续往深处走逻辑清晰、代码量少而且天然支持任意深度的嵌套结构。比这更“快捷”的思路也有比如一次性把整个目录下的所有文件路径都拉出来但这样先收集再处理在文件量大的时候非常浪费内存——几万个路径的字符串列表看着不多但如果文件量到几十万、上百万呢递归方式边扫描边处理内存占用几乎恒定。当然很多人会问Python里不是有os.walk吗它不也能遍历目录树吗os.walk底层确实也是递归遍历但它是封装好的生成器遍历顺序、过滤逻辑都是固定的不够灵活。比如我想排除某些子目录、只上传特定后缀的文件、按文件名排序保证执行顺序稳定用os.walk也能做但代码会绕一些。递归函数自己写等于把整个遍历的控制权攥在自己手里后面想加什么规则都很顺手。1.2 目录映射本地路径怎么变成OSS的key这是整个方案里最容易翻车的一步。本地路径是E:\data\2024\report.pdf这样的格式而OSS的key通常要求/作为分隔符并且一般不带盘符。两者之间需要一个明确的映射规则。我的规则很简单以你指定的根目录为基准去掉根目录本身剩下的相对路径直接作为key。比如根目录是E:\data那么E:\data\2024\report.pdf对应的key就是2024/report.pdf。如果根目录本身也想作为一个顶层“文件夹”出现在OSS里那就在前面拼上根目录的名字比如data/2024/report.pdf。这两种规则各有适用场景我建议用参数控制而不是写死后面想切换很方便。这个映射过程里有一个必须处理的细节分隔符统一。Windows路径用反斜杠\OSS统一用正斜杠/如果直接拿原始路径去拼接key传上去的目录树会变成一坨奇怪的“反斜杠文件夹”。所以无论什么平台拿到相对路径后都要先做一次replace(\\, /)处理。1.3 整体流程拆解整个工具最后拆成了四个模块职责非常清楚扫描模块递归遍历本地目录产出(本地绝对路径, OSS key, 文件大小)三元组。过滤模块在扫描的同时判断文件是否该上传过滤临时文件、隐藏文件、排除目录等。上传模块负责真正把文件传上去内部处理分片、重试、并发。状态模块负责增量判断对比云端元数据跳过不需要重新上传的文件。这四个模块按顺序串起来主流程就是扫描到文件 → 检查是否需要上传 → 加入上传队列 → 线程池并发执行 → 记录结果。用递归函数把扫描和过滤绑在一起上传交给独立模块避免一个函数里既遍历又上传后面调优会很痛苦。2. 递归遍历的落地细节2.1 递归函数的三要素递归函数写起来不难但要想写得稳健必须想清楚三件事终止条件、当前层处理逻辑、递归调用方式。我贴一段核心代码这个就是整个工具的地基import os def scan_folder(root_path, base_key, filter_funcNone): 递归扫描文件夹产出 (file_path, object_key, size) 的元组列表 root_path: 本地根目录绝对路径 base_key: 当前目录对应的OSS key前缀 filter_func: 过滤函数接收 file_path返回 True 表示需要上传 # 终止条件一路径不存在或不是目录 if not os.path.isdir(root_path): return # 当前层处理逻辑 try: entries list(os.scandir(root_path)) except PermissionError: print(f[跳过] 无权限访问目录: {root_path}) return # 排序保证执行顺序稳定日志可复现 entries.sort(keylambda e: e.name) for entry in entries: # 相对路径拼接当前目录名 文件名 rel_name os.path.join(base_key, entry.name).replace(\\, /) if entry.is_dir(follow_symlinksFalse): # 递归调用进入子目录 yield from scan_folder(entry.path, rel_name, filter_func) elif entry.is_file(follow_symlinksFalse): # 过滤逻辑 if filter_func and not filter_func(entry.path): continue size entry.stat().st_size yield (entry.path, rel_name, size)注意几个关键点。第一os.scandir的结果要先list再排序因为scandir本身返回的是一个迭代器直接排序会报错。排序有什么用如果你中断了任务想续跑固定的遍历顺序能让日志里的文件顺序一致排查问题的时候方便太多。第二递归调用传的是entry.path而不是拼出来的字符串因为entry.path是真实路径后续打开文件不会出错。第三yield from一层层往上抛最终这个函数本身是一个生成器调用方可以懒加载不用一口气全拿进内存。为什么是“终止条件当前层递归调用”这个结构因为目录树本质上就是一棵多叉树递归遍历就是在做深度优先搜索。先把当前目录里的每一个元素处理完再处理子目录顺序上更像是“先根遍历”。这个顺序对我们区别不大因为每个文件之间是独立上传的但保持顺序稳定对日志分析和断点续传非常有帮助。2.2 用os.scandir而不是os.listdir很多人在这一步还在用os.listdir然后对每个路径再调一次os.path.isfile或者os.path.isdir。这样不是不行但性能上亏大了。os.listdir只返回名字列表你为了判断它是文件还是目录还得对每个名字再发起一次系统调用去stat。如果一个目录里有2000个文件就等于多跑了2000次stat。os.scandir是Python 3.5引入的接口它在拿到目录项的同时就把文件类型信息一并带回来了。entry.is_file()和entry.is_dir()在大部分情况下不需要额外的系统调用直接使用scandir内部缓存的类型信息。实测在一个包含上万个文件的目录里用scandir比listdirstat快一倍以上。这种性能差距在单目录里不明显但文件夹嵌套多、文件数量大的时候差距非常可观。还有一个细节entry.is_dir(follow_symlinksFalse)和entry.is_file(follow_symlinksFalse)这里为什么加follow_symlinksFalse因为如果不加遇到符号链接指向的目录is_dir()会返回 True然后递归进去万一符号链接指回上一级目录就会造成无限递归最后栈溢出。加上follow_symlinksFalse之后符号链接一律按普通文件处理或者可以根据需求直接过滤掉。做文件上传工具默认不跟随符号链接是最安全的因为我们要的是“真实目录结构”而不是“被链接撑大的假结构”。2.3 过滤规则与排序上传工具跑一次可能涉及几万甚至几十万个文件不可能什么文件都往OSS上塞。我在工具的过滤模块里默认排除了以下几类临时文件文件名以~开头或结尾的比如~$财务报告.docx。隐藏文件以.开头的比如.DS_Store、.git。系统文件Thumbs.db、desktop.ini、$RECYCLE.BIN。工具自身生成的日志和清单文件防止下次扫描把自己传上去。空的临时目录。过滤函数的签名要保持简单接收一个文件路径返回True或False。这样以后想按后缀过滤、按大小过滤、按修改时间过滤都不用改动主扫描结构只换filter函数就行。我把这个设计写在代码注释里别人接手也看得懂。2.4 空目录怎么处理一个容易被忽略的细节空目录。OSS的key是纯文件概念你往OSS里传一个文件a/b/c.txt会自动生成a/b/这样的“父目录”但如果你本地有个空目录a/b/里面没有任何文件那么传输结束后这个目录在OSS里根本不存在。如果业务上必须保留空目录有两个办法。一个是在空目录里创建一个隐藏的占位文件比如.keep把它也传上去。另一个是直接往OSS里PUT一个以/结尾的、长度为0的空对象也就是自造一个“目录对象”。这两种方案在控制台看效果基本一样。我的建议是除非业务明确要求保留空目录否则不用处理因为在绝大多数场景下空目录没有实际意义留着反而增加干扰。3. 上传模块的调优实践3.1 小文件直传、大文件断点续传遍历拿到文件列表之后紧接着的问题就是怎么传。OSS SDK通常提供两种上传方式普通上传PUT Object和分片上传Multipart Upload。普通上传适合小文件一次请求搞定简单直接。分片上传适合大文件它会先把文件切成若干块分别上传最后合并。分片上传最大的好处不只是突破大小限制而是天生支持断点续传——如果一个分片上传失败只需要重新传失败的那一块已经传好的分片可以留着。实际参数配置我是这样处理的from oss_sdk import Auth, Bucket, ResumableUpload # 这个示例是示意性的不同OSS厂商SDK的API略有差异但思路完全一致 threshold 50 * 1024 * 1024 # 50MB以上走分片上传 part_size 10 * 1024 * 1024 # 每个分片10MB判断逻辑很简单文件大小小于50MB直接调put_object大于等于50MB用resumable_upload。这个阈值不是拍脑袋定的我统计过目标目录里的文件分布几千个小图片加起来才几百MB但几个ISO镜像文件每个都有2GB以上。接着用默认阈值50MB划分90%的文件走直传只有少数大文件走分片整体效率最高。如果你目录里全是超大文件可以把阈值调低让更多文件走断点续传路线你的容错能力会更强。分片大小也有讲究。分片太小会导致分片数量过多OSS的合并请求会变慢分片太大则断点续传粒度变粗失败后要重传的数据更多。10MB到50MB之间是比较常用的区间我选了10MB纯属个人习惯。3.2 并发控制线程池配信号量上传是IO密集型操作瓶颈在网络延迟和带宽不在CPU。所以用线程池比用进程池更合适线程的创建开销更小也不需要考虑序列化结果。但并发数绝对不能无限加大——并发太高要么打满带宽导致排队要么被OSS的流量控制限流反而整体变慢。我这里用ThreadPoolExecutor加上信号量做双层控制from concurrent.futures import ThreadPoolExecutor, as_completed import threading max_workers 8 semaphore threading.Semaphore(4) # 控制同时占用的上传槽位 def upload_with_limit(file_tuple): with semaphore: return upload_one(file_tuple) executor ThreadPoolExecutor(max_workersmax_workers) tasks [executor.submit(upload_with_limit, ft) for ft in scan_result] for future in as_completed(tasks): future.result() # 有异常会在此处抛出为什么有了线程池还要加信号量线程池的max_workers8控制的是“最多同时有8个线程在跑”但如果每个线程内部还有等待队列或缓冲区实际同时发生的上传请求可能会更多。信号量相当于在业务层再加了一道闸门保证真正同时打给OSS的请求数不超过4个。实测下来4~8路的并发在普通办公网络下最稳定既能把带宽用满又不会触发限流。如果是在服务器上跑、带宽很充裕可以适当调高。线程池 信号量这个组合看起来多了一层但它把一个“最多同时多少个请求”的问题从线程模型里解耦出来。以后想调整并发度只改信号量数值就行不用去动线程池。这段代码实测下来非常稳强烈推荐。3.3 失败重试与退避策略上传三万个文件中间不出现几次超时或者5xx错误几乎不可能。网络波动、服务端局部过载、连接被重置都是常态。所以上传模块必须内建重试机制。重试不能是无脑重试否则会造成重试风暴。我的策略是单文件最多重试3到5次。每次重试间隔指数退避比如1秒、2秒、4秒、8秒……每次翻倍最多不超过30秒。只对可恢复的错误重试网络超时、连接重置、5xx错误。权限错误403、文件不存在404这类问题重试多少次都一样立即失败并记录日志。指数退避的本质是给服务端留出恢复时间。第一次重试等1秒第二次2秒第三次4秒——如果服务端只是瞬时抖动很快就能恢复如果服务端确实过载了等得越久越好而不是用固定的短间隔疯狂重试。这一招在大量文件的场景下效果立竿见影。我最初用的固定3秒重试上传到一半经常出现连续报错改成指数退避之后大部分错误第二次重试就成功了整体失败率从几个百分点降到千分之一以下。3.4 进度回调与日志几万个文件的上传任务跑起来少则几十分钟多则几个小时不给用户反馈是很折磨人的。我加了一个进度回调每隔几秒打印一次当前进度、文件总数、失败数量、当前速度。进度更新的逻辑放在一个独立线程里避免阻塞上传线程。日志方面我坚持每个文件都记录一行文件路径、key、大小、耗时、成功/失败。这样任务跑完之后拿着日志一过滤就知道哪些文件失败了、卡在哪里、需要不需要补传。日志文件本身也要放在上传目录之外否则下一轮扫描会把自己传上去。4. 增量上传与状态记录4.1 对比云端元数据跳过已上传文件第一次全量上传之后目录里可能只改了几个文件。如果每次执行都无脑全量重新传一遍浪费时间不说OSS的请求费用也白白烧掉。所以增量逻辑是必须的。最简单的增量判断是看文件大小和修改时间。扫描到本地文件之后先调用OSS的head_object接口获取云端对象的元数据对比本地的st_size和st_mtime。如果大小相同且修改时间没变就认为这个文件不需要重新上传跳过。这样做的原理是OSS在对象创建时自动记录了Last-Modified时间虽然它和本地文件的修改时间可能有时区差但只要你上传时把本地的mtime写进自定义元数据里判断就会非常准确。在扫描模块里加一个判断代码大概长这样from oss_sdk import ObjectInfo def should_upload(local_path, object_key, local_size, local_mtime): try: info bucket.head_object(object_key) except NotFound: return True # 云端不存在必须上传 # 比较大小和自定义元数据里的mtime remote_size info.size remote_mtime float(info.metadata.get(mtime, 0)) return local_size ! remote_size or abs(local_mtime - remote_mtime) 1为什么自定义元数据里要存mtime而不是直接比较Last-Modified因为Last-Modified是上传时间和本地文件的修改时间压根不是一回事。如果不存自定义元数据那么只要文件没改每次执行都会因为“修改时间变了”而重新上传增量逻辑就废了。这是一个非常容易踩的坑我在正式跑之前就因为这个浪费了一轮半小时的全量传输。4.2 清单文件方案head_object方案虽然简单但每次判断都要发起一次网络请求。文件量到几万的时候扫描阶段就会多出几万次请求略显浪费。更高效的做法是维护一份本地清单文件manifest.json记录每个文件对应的key、大小、mtime。扫描时先读manifest如果某项记录的key、大小、mtime和本地一致就认为这个文件已上传直接跳过。只有扫描结束之后才把这份manifest更新写入这次实际执行的上传结果。这个方案的巧妙之处在于它把“判断是否需要上传”从远程调用变成了本地对比速度提升非常明显而且不依赖云端接口调试也方便。这个方案有一个前提第一次上传时必须保证manifest和云端是同步的否则后续全都会错误跳过。所以第一次执行全量上传之后要立刻生成一份完整的manifest并且上传到OSS里存一份。以后不管是换机器还是重建本地环境先把manifest下载下来增量逻辑就能恢复。4.3 文件删除同步的取舍既然做了增量上传很多人顺理成章会想本地删掉的文件OSS里是不是也应该删掉做成真正的双向同步我的建议是除非需求明确要求“镜像同步”否则不要做自动删除。删除是高风险操作一旦误删云端文件可能直接没了。更稳的做法是把扫描结果里“本地不存在但云端存在”的key收集起来输出一份“差异报告”由人决定是否手动清理。我做工具的时候就把删除功能默认关闭只生成差异清单。上传是“把本地变到云端”删除是“把云端变成本地”这是两个完全不同的方向方向错了代价可能是一整年的数据。5. 常见问题与排错实录5.1 权限问题跑上传任务最常见的错误就是AccessDenied。出现这个报错先别急着改代码90%的情况是访问控制权限不够。三个地方挨个查AccessKey是否正确注意不要在代码里硬编码用环境变量传入或者用SDK默认的凭证链。是否有对应Bucket的写入权限访问控制策略里要允许PutObject、MultipartUpload相关操作。Endpoint是否填对不同区域的Endpoint不同填错了看起来是权限错实际上根本没找到桶。我的建议是访问控制原则的最小授权新建一个专用账户只给目标Bucket的写权限不要用管理员账号跑上传脚本。因为脚本一旦有bug后果只在单个Bucket内可控不会波及其他数据。5.2 目录层级错乱与分隔符坑上传完之后去OSS控制台一看目录树乱七八糟常见有两种症状。一种是文件全部堆在根目录没有层级。这种多半是key拼接的时候用了os.path.sep或者直接用了反斜杠到了OSS里一个斜杠都没有所有key都变成平级了。解决方法是严格把分隔符替换成/。另一种是目录多了一层或者少了一层。多一层往往是因为把根目录的绝对路径也拼进key里了比如E:\data变成了E:/data/2024/report.pdf在OSS里会看到盘符开头的“文件夹”。少一层大概率是用os.path.basename拿到的是文件名而不是相对路径丢了上级目录。遇到这种问题我一般直接把程序输出的一条key打印出来和本地路径对比一看就知道哪里错了。5.3 文件名特殊字符与编码文件名里有空格、中文、#、%这类字符时在OSS控制台和部分客户端里可能会显示异常或者点开链接时报错。这通常不是上传的问题而是展示端对key做了URL解码/编码的差异。处理办法是上传时保持key为原始字符UTF-8不做URL编码但ContentType要设置正确特别是有中文名的HTML文件要让服务端返回带charsetutf-8的头部信息否则打开可能乱码。如果客户端工具非要URL编码的key才能正常显示那就要在生成预签名URL时统一编码规则不要在存储端改。存储端保持“原汁原味”展示端按需编码这个分层原则能避免很多诡异问题。5.4 上传很慢或内存占用高上传很慢先确认瓶颈在哪儿。如果带宽本身跑满了并发再高也没用如果带宽不满但上传速度上不去大概率是并发不够或者单个请求耗时太长。可以先小范围测一下固定传100个各种大小的文件记录总耗时然后调整并发数对比找到最优值。内存占用高常见原因是读取文件时一次性把整个文件读进内存比如f.read()一把梭。几GB的大文件这么搞内存直接撑爆。正确做法是用块读取每次读固定大小或者交给SDK内部处理SDK的分片上传模块本身就支持流式处理不要自己手动read()整个文件再传。5.5 递归上限与栈溢出Python默认递归限制是1000层。一般人的目录层级到不了1000但如果目录树设计得特别深比如自动化构建生成的node_modules嵌套几百层递归函数就会报RecursionError。遇到这种情况有两个选项。一个简单暴力的sys.setrecursionlimit(5000)但这只是把上限调高不能根治问题而且递归太深时真正的风险是C栈溢出程序会直接段错误。另一个更好的选项是把递归改成显式的栈迭代也就是自己维护一个列表把待处理的目录压栈循环处理。迭代版不会遇到递归深度问题代码逻辑也更稳。我后来给工具加了这个选项遇到深目录树就自动切换到迭代模式。5.6 通用检查清单最后放一份我自己排错时用的检查清单每次上传任务出问题按这个顺序过一遍基本能定位检查项排查方法常见原因权限用最小文件手动上传测试AccessKey权限不足、Endpoint错误key格式打印第一条key与本地路径对比分隔符未替换、多拼或少拼层级大文件失败看日志中失败文件的大小分布分片阈值配置不合理、带宽超限增量失效检查自定义元数据mtime元数据未写入、本地mtime未记录内存暴涨观察进程内存占用趋势一次性读文件、扫描结果全部积压无限递归检查目录中是否有符号链接未禁用符号链接跟随日志不完整查看日志文件是否被覆盖上传目录和日志目录冲突最后再聊两句把文件夹搬到OSS这件事难点从来不在“上传”本身而在“你对自己的目录结构了解多少”。不要急着写代码先花十分钟统计一下文件总量、最大文件、目录层级、有没有特殊字符再决定用哪种上传方式和并发数。大部分翻车现场都是因为前期没摸清目录情况写出来的工具只能处理“理想文件夹”。我个人踩过最大的坑就是增量上传的mtime比较——第一次跑全量的时候没把本地mtime写进云端元数据导致之后每一次执行都要重传全部文件。后来改了元数据方案几万文件的任务增量场景下几分钟就能跑完。所以如果你也要做类似工具务必在一开始就把“元数据设计”想清楚别等跑完整量再来补。递归算法本身很简单一个函数就写完但它给整个工具定了一个稳定的骨架。在这套骨架上并发、断点续传、增量对比都是独立的零件哪个坏了拆哪个后面维护起来特别省心。如果你现在也在做一个类似的上传工具照着这个思路来应该能少走不少弯路。

读完文章,也想定制专属网站?

尧图设计师 24 小时内与您沟通定制方案

免费获取报价 →
↑