前阵子接了个自动化运维的活儿要把服务器上打包好的静态资源文件夹整体传到OSS对象存储里目录结构必须和本地完全一致。文件总量不多不少十几万个文件三四层嵌套目录里面有图片、JS打包产物、样式表还夹着两个空的子目录。第一反应是这还不简单遍历目录再逐个put上去真动手的时候才发现细节全藏在“保持目录结构”和“不落文件”这两个要求里。这篇文章就把我完整做下来的过程拆开讲核心是递归算法怎么一层层啃下目录树以及围绕上传做的那些配套处理。内容覆盖方案选型、遍历实现、并发上传、常见坑排查代码以Python为例基本可以直接抄走改改参数用。1. 需求拆解与方案选型1.1 从需求出发文件夹上传到底难在哪单项文件上传没什么好说的API一调就完事。难的是“文件夹”这三个字。文件夹不是一个真实存在的实体在文件系统里它是一棵由目录和文件组成的树。你要让OSS这边也长出一模一样的树本质上是在做一次结构迁移。这个需求拆开看其实有三个难点。第一怎么完整拿到目录树里所有文件路径。这步做不干净后面什么都白搭漏一个文件就要返工。第二怎么把本地路径映射成OSS上的对象键Object Key。本地有个/data/static/img/banner.png你希望对象键是static/img/banner.png而不是一长串绝对路径。第三怎么保证大批量上传时不卡死、不超时、不半路断掉。上传不是单发操作十几万文件一把梭中间任何一个失败都得有重试机制兜底。目录结构天然就是一个树形结构父目录下面有子文件、子目录子目录下面又有更深的层级。处理这种结构最直观的思路就是递归——函数在处理完当前目录下的文件之后对每个子目录再一次调用自身逐层往下探。这就是我用递归算法做目录遍历的直接原因。1.2 递归算法的适用性与优势可能有同学会问非要递归吗我能不能用os.walk一层层扫或者用栈模拟一个迭代版本这里得把几个方案摆在一起比一比。手动迭代栈用list当栈把目录压进去循环处理。能做成避免递归深度问题但代码写起来要维护一个显式的状态栈可读性差一些。隐藏着容易出错的点比如入栈顺序、层级标识维护一不注意就乱了。现成的os.walk这算是官方给的“递归生成器”它自己内部已经处理好了遍历逻辑用起来确实方便。但它返回的是绝对路径要转换成相对路径还得自己算一遍而且在遍历中做过滤、占位目录收集这类定制逻辑代码会绕一点。自己写递归函数思路和目录结构完全对应一层函数调用对应一层目录传参时顺手带上当前相对路径路径映射的代码逻辑就和文件夹的实际结构同构了。后期加过滤规则、加空目录收集都很好扩展。文件系统目录的深度在绝大多数场景下都是有上限的常规项目里能超过二十层就算很深了。Python默认的递归深度是1000层实际操作中不太可能触发栈溢出。所以自己写递归代码又清晰又不会出幺蛾子是我最终选择的方式。1.3 整体方案设计遍历、清单、上传三段式模块怎么分我强烈建议把遍历和上传拆开先遍历出完整清单再根据清单去消费上传不要在同一个递归里“边走到边传”。理由很实在。第一边遍历边传的话一旦上传某一步报错你是继续遍历还是停下来处理状态会变得很拧巴要么文件漏传要么重复处理。第二先出清单可以提前统计出文件总数、总大小这样我们能做得起进度条也说得准“还要传多久”。第三多线程上传的时候清单就是天然的任务队列消费起来非常舒服。所以我的整体设计分三段第一步递归遍历生成文件相对路径列表和空目录列表第二步对空目录做占位创建第三步用线程池并发消费文件列表。后面几节挨个细说。2. 递归遍历把树形目录装进清单2.1 核心递归实现与相对路径透传递归函数写起来并不复杂三个要素卡住就行了终止条件、处理逻辑、递归调用。在这个场景里终止条件是“当前目录下没有子目录可进”处理逻辑是“收集当前层的文件名并拼接相对路径”递归调用则是“遇到子目录就带上新的前缀进去继续扫”。下面这段是我实际用的遍历核心代码我把它单独抽出来讲。import os from pathlib import Path def list_all_files(root_dir): 递归遍历目录返回所有文件的相对路径列表 result [] def _recursive_search(current_dir, current_prefix): # 终止条件目录本身有效才有继续的意义 if not os.path.isdir(current_dir): return for item in os.scandir(current_dir): item_path os.path.join(current_dir, item.name) if item.is_file(): # 处理逻辑拼接相对路径 relative_path f{current_prefix}/{item.name} if current_prefix else item.name result.append(relative_path) elif item.is_dir() and not item.is_symlink(): # 递归调用目录层级加深前缀也加深 next_prefix f{current_prefix}/{item.name} if current_prefix else item.name _recursive_search(item_path, next_prefix) _recursive_search(root_dir, ) return result注意current_prefix这个参数它是整段代码的灵魂。一开始调用时传空字符串代表根目录下还没有前缀。每次进入一层目录就把当前目录名拼到前缀后面。比如扫到img目录时前缀变成img再扫到banner.png时相对路径就是img/banner.png。这个前缀一层层传下去很自然地建立了本地目录和OSS对象键之间的映射关系。用os.scandir而不是os.listdir是因为scandir拿到的DirEntry对象直接带is_file()、is_dir()方法不需要额外再os.path.join之后调一次stat系统调用遍历大目录时性能差别很明显。2.2 文件过滤与特殊目录处理跑第一版的时候我就踩了坑脚本一把梭把.git目录、node_modules缓存目录、临时生成的__pycache__全部传上去了。这种目录既大又没意义传到OSS上纯属污染存储空间。所以要单独拎出来讲过滤逻辑它直接决定你清单的“质量”。常规做法是维护一个排除名单在递归遇到目录时先做判断命中的目录直接跳过不进递归分支。EXCLUDE_DIRS {.git, .svn, __pycache__, node_modules, .idea} def _recursive_search(current_dir, current_prefix, exclude_dirsNone): exclude_dirs exclude_dirs or EXCLUDE_DIRS ... elif item.is_dir() and item.name not in exclude_dirs and not item.is_symlink(): ...除了排除名单还有几个特殊场景要拿主意。软链接目录。默认item.is_dir()会顺着软链接返回True如果不加not item.is_symlink()判断就可能把链接指向的目录整个扫进来轻则多传一堆无用文件重则陷入循环引用导致递归出不来。安全起见一律跳过软链接。隐藏文件。这类文件要不要传取决于你的业务。静态资源一般不需要隐藏文件我就在清单构建时加了个开关默认跳过以点开头的文件和目录。还有个和空目录相关的点。OSS的对象存储是扁平key-value模型严格来说它没有“目录”这个实体控制台里看到的文件夹是通过对象键的/分隔符虚拟出来的。目录结构是依赖对象键前缀呈现的比如static/img/这个“目录”之所以存在是因为有一堆对象键带有static/img/这个前缀。如果某个目录是空的那它在OSS上就没有任何“影子”你打开对象存储控制台时根本看不到它。要保留空目录就得手动创建一个以/结尾的占位对象。这个逻辑我放在遍历函数里一起做掉。def collect_files_and_dirs(root_dir, exclude_dirsNone): files, dirs [], [] ... # dirs 里存的是所有相对目录路径配合占位对象键使用空目录要不要保留别拍脑袋决定最好是资源方明确要求。我这次的需求方明确说了“目录结构一个都不能少”所以空目录必须保留。如果只是做静态资源配置备份那空目录其实无所谓没必要额外创建占位对象增加清理工作量。2.3 递归深度的现实约束递归有一个天然争议点就是层级深了会不会爆栈。聊这个前得先分清两件事Python的递归深度限制和文件系统对路径长度的限制。Python里sys.getrecursionlimit()默认是1000层这个数值对于文件系统遍历来说其实非常安全。因为操作系统对单个路径组件的长度和总路径长度都有限制。以Linux为例单文件路径总长度上限通常是4096字节。如果一层目录名平均20个字符那理论最大嵌套深度也就两百层上下。在这种现实约束下递归不会先爆掉Python的深度限制反而是文件路径先超限。当然真遇到极端情况也不是没辙。可以先把socket换成迭代版本用显式栈模拟递归过程代码会绕一点但彻底摆脱了栈深度的限制。我这次没有这么做因为三四层嵌套的目录实在没必要为了理论上的极端情况牺牲代码的直观性。2.4 生成上传清单的完整代码把上面所有点整合起来我实际的清单生成函数长这样。这一步我会做两件事先统计文件总数和总大小用于控制台进度展示再收集空目录准备做完上传之前的占位。def build_upload_manifest(root_dir, exclude_dirsNone, skip_hiddenTrue): root_dir Path(root_dir).resolve() manifest { files: [], # 相对路径列表 dirs: [], # 空目录相对路径列表 total_size: 0, } def _walk(current_dir, prefix): if skip_hidden and current_dir ! root_dir and current_dir.name.startswith(.): return for entry in sorted(os.scandir(current_dir), keylambda e: e.name): if entry.is_symlink(): continue entry_path os.path.join(current_dir, entry.name) if skip_hidden and entry.name.startswith(.): continue if entry.is_file(): rel_path f{prefix}/{entry.name} if prefix else entry.name manifest[files].append(rel_path) manifest[total_size] entry.stat().st_size elif entry.is_dir(): if entry.name in (exclude_dirs or set()): continue rel_dir f{prefix}/{entry.name} if prefix else entry.name # 先判断是否空目录空目录进入 dirs 用于后续占位 subdirs [e for e in os.scandir(entry_path) if e.is_dir()] subfiles [e for e in os.scandir(entry_path) if e.is_file()] if not subdirs and not subfiles: manifest[dirs].append(rel_dir) _walk(entry_path, rel_dir) _walk(root_dir, ) return manifest这里我额外做了排序sorted一下让文件按名称顺序进入清单。好处有两个日志看起来舒服排查哪个文件没传时不用在几十万条记录里乱翻处理时顺序可控给后续并发分批提供基础。清单生成之后先打个日志看一眼字段数量确认无异常再进行上传。这一步的成本很低但能帮你避免把问题文件直接怼到线上存储空间里。3. 递归上传从单文件到并发批处理3.1 单文件上传的标准姿势清单到手接下来就是上传环节。OSS的SDK一般都会提供从本地文件直接上传的接口不用先把文件读进内存再传。以Python环境为例常规写法是这样import oss2 from pathlib import Path AUTH oss2.Auth(your_access_key_id, your_access_key_secret) BUCKET oss2.Bucket(AUTH, https://oss-cn-your-region.example.com, your-bucket-name) LOCAL_ROOT Path(/data/site-static) REMOTE_ROOT site-static # OSS 上的目标前缀 def upload_single_file(local_root: Path, relative_path: str) - bool: local_path local_root / relative_path object_key f{REMOTE_ROOT}/{relative_path} if REMOTE_ROOT else relative_path try: # 这里 SDK 会自己处理文件读取和网络传输 BUCKET.put_object_from_file(object_key, str(local_path)) return True except oss2.exceptions.OssError as e: print(f[FAIL] {relative_path} - {e.status}, {e.message}) return Falseobject_key的组装这里有一个必须注意的地方本地相对路径是img/banner.png如果REMOTE_ROOT是site-static最终的对象键就是site-static/img/banner.png。包一个前缀的意义在于你可以在同一个bucket里同时存放多个项目的内容互不干扰然后用对象键前缀做逻辑隔离。单文件上传的返回状态里最需要留意的就是HTTP 200之外的情况。SDK对4xx、5xx响应会直接抛异常但也有一个情况不会抛——分片上传的响应码。后面讲大文件时会再展开。3.2 空目录占位与key映射规则清单里的dirs列表是在上传前处理的。空目录的占位方式就是往bucket里放一个空对象这个对象的key以/结尾。def ensure_empty_dirs(): # manifest 里收集到的空目录相对路径 for rel_dir in manifest[dirs]: object_key f{REMOTE_ROOT}/{rel_dir}/ if REMOTE_ROOT else f{rel_dir}/ # 创建占位对象内容为空 BUCKET.put_object(object_key, b) print(f[DIR] {object_key})这就是对象存储里的“伪目录”约定OBS和OSS这些对象存储平台都遵循这个惯例以斜杠结尾的对象在控制台里显示成文件夹但本质只是一个普通对象。删除这些占位对象目录网格里对应的“文件夹”就消失了。执行顺序上占位对象要在并发传文件之前先建好。不然客户端下载资源时某些工具扫描目录树时可能因为“空目录不存在”导致后续切换路径的逻辑出错。先把骨架搭好再填血肉逻辑上更顺。3.3 并发上传与进度统计单文件上传写好后你当然可以for循环一个个传。但要传十几万个文件的时候单线程的效率低到让人怀疑人生。这里常规优化方式是线程池并发。并发数怎么定不是越大越好。OSS服务端有单IP连接数和单bucket的QPS限制直接把并发拉到100最可能的后果是触发服务端限流报一堆403或者连接重置。我这次实际压过8个线程已经很稳文件平均体积小的场景甚至能跑到很快线程数开到30的时候就开始出现超时和限流错误还得做更多重试得不偿失。并发上传的核心代码如下from concurrent.futures import ThreadPoolExecutor, as_completed def upload_all(manifest, max_workers8): total len(manifest[files]) done_count 0 with ThreadPoolExecutor(max_workersmax_workers) as executor: future_map { executor.submit(upload_single_file, LOCAL_ROOT, rel_path): rel_path for rel_path in manifest[files] } for future in as_completed(future_map): rel_path future_map[future] try: success future.result() except Exception as e: success False print(f[EXC] {rel_path} - {e}) done_count 1 if done_count % 5000 0: print(f[PROGRESS] {done_count}/{total} finished) if not success: # 失败先记录最后统一重试 failed_list.append(rel_path)每5000个文件打印一次进度是我试下来比较舒服的频率。打印太频繁会把终端刷成筛选器打印太稀疏又觉得自己在跑一个没有心跳的任务。文件清单一次性submit到线程池内存上要有个数。一个相对路径字符串算它100字节十万个文件也就10MB上下完全扛得住。没必要搞太复杂的生产者消费者模式清单本身就是最简单可靠的任务队列。3.4 大文件的分片与断点续传并发处理普通文件没问题但如果有超过100MB的包要专门拎出来用分片上传。对象存储的标准做法是把一个大文件切成若干片分别上传最后再调用complete接口合并成一个对象。直接用put_object_from_file传大文件除了占用内存和网络带宽还有一个实际风险连接中断后整个文件要重新传。分片上传的好处是一片传失败只需要重传这一片而不需要从头再来。现在主流的OSS SDK都内置了断点续传封装用起来非常简单def upload_large_file(local_path: str, object_key: str): oss2.resumable_upload( BUCKET, object_key, local_path, multipart_threshold100 * 1024 * 1024, # 超过100MB自动走分片 part_size10 * 1024 * 1024, # 每片10MB num_threads3, )分片大小的设置要结合文件总量和网络质量来定。10MB一片、3个线程并发是比较通用的组合。如果网络带宽很高可以把单片提高到20MB减少分片数量降低服务端的合并开销带宽较差时建议减少并发线程数避免把连接占用满导致别的请求排队。这里提醒一句分片上传的失败重试逻辑和普通put不太一样SDK内部已经处理了重传单片的行为你不需要在外层再套一层重试。如果在外层重复提交整个resumable_upload可能会导致分片状态被重置反而浪费进度。4. 常见的坑与排查实录4.1 403权限错误上传跑起来最常遇到的就是403签名错误。我调试时有一次怎么查都觉得AccessKey没错后来发现是AccessKey配的权限只有读权限没有写权限。OSS的访问控制策略会区分读、写、列举、删除等动作一个看起来“能用的Key”不代表有上传权限。排查思路很简单先确认AccessKey ID和Secret没有拼错、没有多余空格再用SDK自带的接口调一次HeadBucket确认能连通最后检查权限策略是否包含PutObject和InitiateMultipartUpload权限。这个顺序能过滤掉绝大多数403。4.2 中文文件名与特殊字符中文文件名、文件中的#号、问号、空格这些字符上传时如果不做好处理很容易出问题。我这次的文件名里恰好有一个图片带了中文还有一个带#第一时间就翻车了。原因在于对象存储的key允许Unicode字符但如果自己手动拼URL或者在某些SDK接口里不指定正确编码服务端对URL解码就会出错。用SDK原生的put_object_from_file接口就不会遇到这个问题SDK内部会处理好URL编码。千万别手贱自己去拼http://bucket.endpoint/key这种URL再传。中文文件名的另一个坑在OS层面文件系统编码和OSS端约定的编码不一致可能导致相对路径拼接后看起来是好的但上传后对象键里的字符乱码。解决方案是在上传之前统一用str类型的相对路径不要混用bytes。4.3 目录层级错乱本地目录结构传入OSS后层级错乱十有八九是拿绝对路径当对象键了。比如把/data/site-static/img/banner.png整个作为key结果bucket根目录下直接长出一串嵌套目录data/site-static/img/banner.png和你想要的site-static/img/banner.png差了整整一层。根因就是相对路径计算出错。我一开始用os.path.relpath计算时因为LOCAL_ROOT没有先resolve()带了一个软链接路径进去导致relpath算出来带了绝对路径的前缀。解决方式也简单开头加一行Path(root_dir).resolve()把路径标准化之后再计算相对路径。4.4 超时与连接池耗尽十几万个文件上传线程池卡死不动了。查日志发现大量请求卡在TimeoutError再往底层看是连接池里的连接因为长时间占用被服务端断开了但SDK侧没有及时感知。这类问题有几个经验参数OSS SDK一般有connect_timeout、read_timeout参数可以调不要使用默认值。我把read_timeout调到了60秒给慢网络情况下的传输留足余量同时线程池的并发数不要轻易拉高我在同一网络环境下测过8线程稳定但30线程就开始出现大量超时。连接池耗尽的表现就是日志里一堆“timeout”然后程序卡死不动这时候先降并发再看是否需要调整底层连接池大小。4.5 常见问题速查表把经常遇到的坑整理成一张表后面再跑类似任务直接对着表排查。现象可能原因解决方式403 AccessDeniedAccessKey权限不足或签名错误核对Key、检查写权限、HeadBucket连通测试上传后对象键带完整绝对路径相对路径计算错误根目录先resolve()再用relpath或递归传prefix空目录在控制台看不到没有创建占位对象对空目录创建以/结尾的空对象中文文件名乱码手动拼URL或混用bytes路径直接用SDK接口统一用str路径大量TimeoutError并发过高、连接被断开降低线程数、调大read_timeout重复上传大量已存在文件断点续传逻辑或清单重复上传前检查对象是否存在或确保递归不重复收集软链接目录导致递归异常没有过滤symlink用is_symlink()过滤后再判断目录4.6 一个值得单独讲的重试策略上传过程的失败重试我建议不要无脑在单文件层面for循环重试而是先用一遍跑完所有清单把失败的文件单独收集到一个列表里全部跑完之后再统一重试失败的子集。这样做的原因是批量上传的失败往往有“传染性”和“并发性”比如某一时段网络抖动可能一次性失败几百个文件。如果你在每个单文件里重试三次这几百个失败文件的上传时间会被拉长好多倍。我只做了两轮重试第二轮再失败的文件列表打印出来人工判断是对应资源确实有问题还是网络持续不稳定。跑完两轮成功率能到99.99%剩下那几个真有问题的单独处理比盲目反复重试效率高得多。5. 实测数据与经验总结5.1 实测场景与数据说一组我实际跑出来的数据。测试目录是三万多张图片、八千多个JS和CSS产物、加若干个空目录文件总数约5万个总大小约42GB。其中最大的一个文件是2.8GB的离线包其余大部分是几十KB到几MB的中小文件。8线程并发采用分片上传接口处理超过100MB的文件其余走单文件接口。跑完一轮耗时大约40分钟。瓶颈主要在网络带宽和文件数量上CPU基本没有压力。如果再把线程数翻到16耗时反而没有减少多少但报错率上升了我观察到的原因应该是服务端对同一bucket的并发连接数有限制到了一定threshold后新连接要排队或直接被拒绝。所以这个并发参数建议你拿到自己真实网络环境里小规模试跑一轮再确定别人的最优参数不一定适合你的场景。5.2 实操心得与扩展思路这次任务做完我脑子里其实留了一个很深的印象递归算法在这里不是一种炫技而是和人脑理解目录结构的方式完全一致写起来自然排错也好排。递归的每一层就对应文件系统的一层目录出问题时顺着调用栈能直接定位到对应目录这比维护一个显式栈直观太多。这个脚本做完之后我没有让它只服务这一次。后来扩展了几个功能分享一下思路。一是增量上传上传前先通过list接口把远端已有对象键拉下来做一个set本地清单中的文件如果已在set里且大小一致就跳过几十万文件全量重传的场景直接省掉大半时间。二是失败列表持久化把未上传成功的相对路径写到本地csv之后再次执行脚本时读入csv直接重试避免每次失败都要全量再扫一遍。三是对接消息通知任务完成后发一条webhook到值班群让整个流程可以无人值守跑。如果你手头也要做类似的事情我的建议是别急着从写上传代码开始先花半小时把目录树长什么样、有哪些特殊目录、有没有超大文件这些情况摸清楚再动手写递归遍历。目录结构摸透了后面上传只是往清单里套接口而已。真正麻烦的从来不是上传这个动作而是怎么把本地目录结构忠实地翻译到对象存储的key空间里而这正是递归算法最擅长做的事情。