1. 先搞清楚 Asset 管理的边界在哪1.1 为什么删除和复制比上传更容易翻车在 Earth Engine 里做遥感分析大家习惯把注意力放在影像合成、分类算法、精度验证上资产Asset管理往往被当成上传完就完事的边角料。但只要你在一个项目里连续跑上几周尤其是做批量时序产品、反复试参数的中间结果、给不同团队分发数据副本就会遇到一个很现实的问题projects/xxx/assets下面的东西越堆越多命名越来越乱存储配额一天天逼近上限最后不得不做一次彻底的清理和归档。而清理这件事恰恰是整个流程里最容易出事的环节——上传失败了可以重传计算错了可以重跑但一次误删是补不回来的。我用 Earth Engine 处理过不少批量化任务中间结果的体积往往比最终成果大十倍不止一个 30 景影像的中间集合可能就吃掉几十 GB。删除和复制这两件事本质上是在跟不可逆操作和权限/配额边界打交道而不是跟算法打交道。这也是为什么我更愿意把这两件事写进脚本里而不是在界面上一顿点脚本可以加确认、可以加日志、可以先 dry-run 打印路径再执行而手点没有这些保险。这篇文章面向三类人一是刚接触 Earth Engine、想在 Code Editor 里手动删掉几个测试资产的初学者二是已经跑了一段时间、需要批量清理项目空间的实践者三是用 Python 做流水线、希望把资产运维也纳入自动化的人。JavaScript 和 Python 两套调用方式我会分别写清楚包括参数差异、报错位置和几个我踩过的坑。1.2 JavaScript 与 Python 两套调用方式到底差在哪很多人第一次从 Code Editor 转到 Python 环境时会误以为只要把deleteAsset改成delete_asset就行。大体上确实如此但细节差异会直接决定你的脚本能不能跑通。核心区别有三个第一是鉴权初始化Code Editor 里是登录态直接可用Python 里必须显式ee.Initialize()并指定云项目第二是命名风格JS 是驼峰listAssets、copyAsset、getAssetPython 是下划线list_assets、copy_asset、get_info写错了直接AttributeError第三是返回值和异常处理JS 侧习惯回调解构Python 侧是返回值加异常抛出批量脚本里必须自己包 try/except。功能JavaScriptCode EditorPythonee 包说明删除资产ee.data.deleteAsset(id, callback)ee.data.delete_asset(id)删除文件夹会连带子资产复制资产ee.data.copyAsset(src, dst, overwrite)ee.data.copy_asset(src, dst, overwriteFalse)overwrite 仅对单幅 image 有效重命名/移动ee.data.renameAsset(src, dst, overwrite)ee.data.rename_asset(src, dst, overwriteFalse)同项目内重命名跨项目等同移动列出子资产ee.data.listAssets(parent)ee.data.list_assets(parent, page_size, page_token)返回结果分页必须循环取获取元信息ee.data.getAsset(id)ee.data.get_info(id)判断资产是否存在、看类型命令行earthengine rm / cp / ls同左适合一次性手动操作注意Python 侧ee.data的多数接口是同步阻塞的没有做重试封装。批量脚本里循环调用几十次以上时网络抖动或配额限制会直接抛异常务必自己加 try/except 和退避等待。2. 核心机制拆解Asset 路径、权限与配额2.1 Asset ID 的完整层级结构Earth Engine 的资产标识是一套清晰的树形路径标准写法是projects/{cloud-project}/assets/{自定路径}。这里的第一个坑就是很多人写成了users/xxx/assets/xxx或者省略了projects/前缀。老的 legacy 项目确实支持users/形式的路径但新的云项目一律要求projects/开头两者混用会导致资产明明存在却提示找不到的情况。我在给同事排查问题时十次里有三次是路径前缀写错了。树形结构意味着资产有文件夹和叶子两种形态。文件夹folder只负责组织层级本身不存数据叶子资产可能是 image、image_collection、table 或 classifier。listAssets只返回直接子级不递归。这一点非常关键如果你想清理projects/p1/assets/tmp/下面所有东西先对其listAssets拿到一批 ID再对每个 ID 判断类型如果是 folder 还要再进去一层。写批量清理脚本时我通常实现一个深度优先的递归函数先收集所有叶子节点再统一删除这样能避免删除顺序问题。判断类型我用的方法是ee.data.getAsset(id)返回对象里的type字段取值形如IMAGE、IMAGE_COLLECTION、TABLE、FOLDER、CLASSIFIER。Python 侧ee.data.get_info(id)返回字典字段名是type。这条信息在删除前一定要打出来看一眼因为deleteAsset对不同类型的处理并不完全一样。2.2 权限和配额才是真正的隐形天花板删除和复制失败九成以上不是语法问题而是权限或配额问题。先说权限Earth Engine 的资产权限分得比较细读取、写入、管理是三档。你要删除一个资产需要对该资产本身以及它的父路径都有管理权限只对文件有权限但父目录没有同样会报权限错误。跨项目复制更严格源项目需要读权限目标项目需要写权限而且目标云项目必须已经完成 Earth Engine 的注册和启用——空有一个云项目 ID 是没用的。再说配额这是最容易被忽略的一块。每个账号/项目在资产数量、总存储量上都有上限删除操作本身也受 API 调用频率约束。批量删除几百个资产时如果循环里没有任何间隔很容易在中途开始连续失败而且错误信息往往只写请求失败不会明确告诉你是限流。我的做法是每处理 20 到 30 个资产暂停一两秒并且统计成功/失败数量失败项单独落盘重试。至于具体的配额数值官方文档会调整不要把它硬编码进脚本用分批 重试的策略就能适配变化。还有一条容易被忽略的规则正在被导出任务写入的资产不能删除。如果你提交了一个 Export.image.toAsset 任务任务还没结束就去删目标资产删除请求会被拒绝或者删完后任务又把资产写回来。正确顺序是先ee.data.cancelTask(taskId)取消任务等状态确认终止后再删。2.3 删除与复制在语义上的根本差别deleteAsset是原子性的破坏操作没有事务回滚。据我的实际体验删除后官方并没有提供可自助恢复的长期回收站删错了基本只能重新上传或重新计算。所以我在所有删除脚本里都强制加了一道 dry-run 开关默认只打印将要删除的 ID 列表和总大小确认无误后再把开关打开执行。copyAsset则是新建副本不是移动也不是引用。它会把源资产的元数据、属性、几何/影像数据完整复制一份到目标路径占用额外存储空间。跨项目复制时源资产和目标资产属于两个不同的所有者权限不会自动继承复制完成后通常需要重新授权给协作者。另外overwrite这个参数只对单幅 image 类型的资产有效对 image_collection、table、folder 传True仍然会报目标已存在。这一点在文档里写得很含蓄我在批量复制影像集合时被它卡过一次后来改成先判断目标是否存在存在就先删再复制的笨办法反而最稳。顺便说一个便利点同项目内的路径调整用renameAsset比复制删除快得多也更省存储因为它本质上只是改了路径引用。只有跨项目时才必须走复制。3. JavaScript 版实操删除与复制3.1 单个资产的删除与结果确认在 Code Editor 里删一个资产最直接的做法是用回调形态。下面这段我常用来做单点清理逻辑很简单先查资产是否存在、是什么类型再执行删除最后统一打印结果。好处是每一步都有输出出问题能立刻定位在哪一环。// 单个资产删除先确认存在性再执行 var assetId projects/your-project/assets/tmp/ndvi_2020_test; ee.data.getAsset(assetId, function(info, err) { if (err) { print(资产不存在或无权访问:, err); return; } print(类型:, info.type); print(大小(字节):, info.sizeBytes); ee.data.deleteAsset(assetId, function(result, e) { if (e) { print(删除失败:, e); } else { print(已删除:, assetId); } }); });这段代码里有两个细节值得说。第一getAsset的错误回调在资产不存在时也会触发所以报错不一定是真的出错可能只是路径写错了打印出来对照一下就能分辨。第二sizeBytes字段对文件夹通常不返回有效值只有叶子资产才有意义别指望用它统计整个文件夹的体积。提示Code Editor 里的print输出不会持久保存页面刷新就没了。批量操作前把要删的 ID 列表print出来复制到文本文件里留档这是最低成本的保险。3.2 递归删除文件夹里的全部内容GEE 的deleteAsset作用在 folder 上时会连同其下的子资产一起删除。这个行为很方便但也危险——因为它没有二次确认。我一般不用它直接删大文件夹而是自己写递归先收集、再确认、后删除过程可控。下面的函数会用显式栈遍历整棵子树避免深度过大时栈溢出同时把所有路径先收集成列表。// 递归收集某个文件夹下的所有叶子资产 ID function collectLeaves(rootId) { var leaves []; var stack [rootId]; while (stack.length 0) { var current stack.pop(); var resp ee.data.listAssets({ parent: current, pageSize: 1000 }); var assets (resp resp.assets) ? resp.assets : []; assets.forEach(function(a) { if (a.type FOLDER) { stack.push(a.id); } else { leaves.push(a.id); } }); } return leaves; } var target projects/your-project/assets/tmp; var toDelete collectLeaves(target); print(待删除数量:, toDelete.length); print(清单:, toDelete); // 确认无误后取消下面两行的注释 // toDelete.forEach(function(id) { // ee.data.deleteAsset(id, function(r, e) { if (e) print(失败, id, e); }); // });这里要注意listAssets的分页问题。虽然我传了pageSize: 1000但一次调用返回的结果仍可能被服务端截断返回对象里会有nextPageToken。上面的简化版适合子资产数量在千级以内的情况如果你的目录动辄上万条就必须在循环里判断nextPageToken并继续请求否则会漏删。漏删比多删好但盘点时发现明明删过怎么还有同样让人头疼。3.3 复制资产与跨路径分发的写法复制在 JS 里就是一行copyAsset但要考虑三件事目标是否已存在、是否需要覆盖、复制后权限怎么处理。我通常把存在就删和复制串起来做成一个幂等操作这样脚本重复执行不会报错。var src projects/proj-a/assets/products/lulc_2022; var dst projects/proj-b/assets/incoming/lulc_2022; ee.data.getAsset(dst, function(info, err) { if (!err info) { print(目标已存在先删除); ee.data.deleteAsset(dst, function(r, e) { if (e) { print(删除旧目标失败:, e); return; } doCopy(); }); } else { doCopy(); } }); function doCopy() { ee.data.copyAsset(src, dst, true, function(result, e) { if (e) { print(复制失败:, e); } else { print(复制完成:, dst); } }); }跨项目复制的耗时和资产体积强相关单幅 image 一般几秒到几十秒影像集合可能要几分钟。Code Editor 里发起后不要立刻刷新页面否则回调可能丢失你会误以为失败了然后又发一次导致目标路径冲突。我的经验是大资产复制前先print一句开始等到成功回调出现再操作别的。提示复制不会带走源资产上基于角色的共享设置。如果这个资产要给外部协作者用复制完记得在资产详情里重新添加权限别假设它跟着过来了。4. Python 版实操把清理写进流水线4.1 初始化与环境准备的几个关键点Python 侧第一件事是初始化而且必须显式带项目。不传project参数的写法在旧版本还能跑现在基本会直接报错或者落到默认项目上导致你在错误的项目里删东西——这是我最不想看到的场景。初始化有三种常见方式本机交互式授权、服务账号密钥、以及复用已有的凭据文件。批处理脚本我强烈推荐服务账号因为无人值守时交互式授权一定会卡住。import ee # 方式一本机一次性授权首次会打开浏览器 ee.Initialize(projectyour-project) # 方式二服务账号适合定时任务、批量脚本 # import json # from ee import ServiceAccountCredentials # creds ServiceAccountCredentials( # sa-key.json, # scopes[https://www.googleapis.com/auth/earthengine] # ) # ee.Initialize(credentialscreds, projectyour-project)服务账号要提前在云项目的权限里加上相应角色否则能初始化成功但删不动东西。我遇到过一个很典型的场景脚本能list_assets却delete_asset报错排查半天发现是服务账号只有读权限。分辨方法很简单先跑只读接口验证连通性再跑写接口验证权限两步分开测比一次性跑全流程再猜错误位置高效得多。4.2 分页遍历与批量删除的完整脚本Python 侧的分页必须手动处理list_assets返回的是字典assets是当前页nextPageToken是下一页的令牌。下面这个函数是我实际项目里在用的版本做了三件事递归展开文件夹、跳过指定白名单、统计体积。删除部分用 try/except 包住失败项写入文件方便二次处理。import ee import time import json ee.Initialize(projectyour-project) def list_children(parent, page_size1000): 拿一个目录下的全部直接子资产自动翻页 result [] token None while True: kwargs {page_size: page_size} if token: kwargs[page_token] token resp ee.data.list_assets(parent, **kwargs) result.extend(resp.get(assets, [])) token resp.get(nextPageToken) if not token: break return result def walk(root, skip_keywords()): 递归展开返回所有叶子资产 leaves [] stack [root] while stack: cur stack.pop() for a in list_children(cur): aid a[id] if any(k in aid for k in skip_keywords): continue if a.get(type) FOLDER: stack.append(aid) else: leaves.append({id: aid, type: a.get(type), size: a.get(sizeBytes, 0)}) return leaves def delete_batch(items, dry_runTrue, interval0.2): ok, failed [], [] for it in items: aid it[id] if dry_run: print([DRY-RUN] 将删除:, aid, it[size], 字节) continue try: ee.data.delete_asset(aid) ok.append(aid) print(已删除:, aid) except Exception as e: failed.append({id: aid, err: str(e)}) print(失败:, aid, e) time.sleep(interval) return ok, failed root projects/your-project/assets/tmp targets walk(root, skip_keywords(keep_, golden_)) print(共发现, len(targets), 个待处理资产) print(总体积约, round(sum(t[size] for t in targets) / 1e9, 2), GB) ok, failed delete_batch(targets, dry_runTrue) # 先空跑 # ok, failed delete_batch(targets, dry_runFalse) # 确认后再执行 with open(delete_failed.json, w) as f: json.dump(failed, f, ensure_asciiFalse, indent2)这段脚本里有几个我反复优化的点。skip_keywords白名单是硬需求因为清理脚本最常见的悲剧是顺手把基准数据集也带走了加一个命名约定比如重要资产统一以keep_开头就能避免。interval参数看起来不起眼但批量删除上千个资产时没有间隔的话中途必然开始连续报错。写完ok/failed落盘也是必要的失败了不用从头再找。4.3 复制与移动什么时候该用哪个Python 侧的copy_asset与rename_asset分工很明确。同一项目内调整目录结构、改命名规范用rename_asset几乎瞬间完成不额外占用空间。跨项目交付数据才用copy_asset。下面是一个带前置检查的复制封装能直接放进流水线里当工具函数。def asset_exists(asset_id): try: ee.data.get_info(asset_id) return True except Exception: return False def safe_copy(src, dst, overwrite_imageTrue): 幂等复制目标存在则按类型决定是否覆盖 overwrite 只对单幅 image 有效集合/表必须先删 if asset_exists(dst): info ee.data.get_info(dst) if info.get(type) IMAGE and overwrite_image: print(目标为 image直接覆盖) else: print(目标已存在且不支持覆盖先删除) ee.data.delete_asset(dst) time.sleep(1) ee.data.copy_asset(src, dst) print(复制完成:, src, -, dst)调用时注意copy_asset是同步阻塞的。复制一个大影像集合可能几分钟到十几分钟中间没有任何进度输出看上去像卡死。我的处理办法是把它放到独立线程或者干脆拆成多个小集合分批复制每批完成打印一行至少能判断是不是还在动。如果是几十 GB 级别的集合更好的思路是不复制直接让使用方在你原来的项目里获得读权限省时省空间。另外Python 侧还有一条命令行通道值得记住earthengine cp、earthengine rm、earthengine ls。做一次性手工操作时命令行比写脚本快得多而且在 CI 环境里也方便串进 shell 脚本。只是命令行同样没有 dry-run敲之前最好先用ls看清楚。5. 常见报错与排查速查表5.1 高频错误对照与处置建议下面这张表是我这几年遇到过、也帮别人排查过的典型问题汇总。看报错信息本身往往不够要结合哪一步失败来定位是列目录就失败权限/路径问题还是删除时失败配额/父子权限问题还是复制时失败目标冲突/跨项目权限问题。现象或报错关键字最常见原因处置方式Asset not found路径前缀错、少了projects/、拼写错先用getAsset/get_info验证再复制确认过的路径Permission denied对资产或父目录无管理权限服务账号只读检查账号角色确认父路径也有权限Cannot delete non-empty folder类提示部分场景下目录未清空先递归列出子资产逐个删再删目录本身批量删到一半开始连续失败触发调用频率限制加间隔、分批、失败项单独重试already exists目标路径已被占用且类型不支持覆盖先查类型非 image 时先删再复制复制大集合长时间无响应同步阻塞没有进度反馈分批复制或改为授权共享而非复制删除后资产又出现有导出任务正在写同一路径先cancelTask并等任务终止复制成功但协作者看不到权限不随复制继承复制后在目标资产上重新授权注意报错信息里出现Invalid argument时别急着改代码先检查资产 ID 是不是包含了首尾空格、中文标点或者多余斜杠。我在从表格里复制 ID 时栽过好几次肉眼完全看不出来。5.2 几个只有实际做过才会记住的细节第一个细节关于删除顺序。删文件夹时如果你既删了父目录又删了子资产第二次操作一定会报不存在。批量脚本里我一般只收集叶子节点从不把目录本身放进删除列表让目录留空或者最后单独处理这样即使中途失败重跑也不会因为父目录没了而整体崩掉。第二个细节关于资产类型判断的时机。listAssets返回的type字段在部分情况下可能缺失或者与getAsset的结果不一致尤其是较老的 legacy 资产。稳妥做法是对拿不准的资产单独调一次getAsset/get_info确认别完全依赖列表里的类型字段。这个成本很低但能避免把整个影像集合当成单幅影像去覆盖。第三个细节关于时间。跨时区协作的项目里资产名里带日期是很常见的约定但如果你用日期做清理条件比如删除 90 天前的中间结果注意updateTime是服务端时间别用本地时间去比。我见过一次因为时差导致本该保留的当天产物被算成过期虽然只是中间结果重跑也花了大半天。第四个细节关于日志。所有删除操作我都要求脚本输出ID 类型 体积 时间戳四要素写进本地文件。这份日志在做成本核算时特别有用——你能清楚看到哪一类中间结果最占地方下次就能从流程上少生成一些而不是每次都靠删。6. 工程化建议让资产管理可回滚、可复现6.1 用命名规范和 dry-run 把风险挡在执行之前技术手段能解决的问题最好别靠人的记性。我给所有项目的资产目录定了三条规矩assets/incoming/只放原始上传assets/tmp/放可随时清理的中间结果assets/products/放正式成果且禁止脚本触碰。清理脚本的作用域永远只开在tmp/上物理隔离比任何白名单都可靠。这条规矩推行之后团队里再没出现过误删正式成果的情况。dry-run 不是走过场。我的做法是让脚本在 dry-run 模式下除了打印清单还输出聚合统计总数、总体积、按类型分组的数量。这个统计本身就是决策依据——你可能会发现待删的 800 个资产里有 700 个是几百 KB 的小表真正占空间的只有几十个大影像集合。那就没必要全删聚焦处理大头即可既降低风险也节省调用次数。再补一个实用技巧延迟删除。正式执行前先把待删清单写入一个 JSON 文件并打上时间戳隔一天再跑实际删除。这一天里如果有人反馈某个中间结果还要用改清单就行。这个缓冲期在多人协作的项目里价值极高成本几乎为零。6.2 把资产运维纳入版本管理与可复现流程脚本一旦稳定就应该进代码仓库而不是散落在各个人的本地目录里。我通常把资产运维脚本和业务脚本放在同一个仓库的不同目录下共享同一份配置项目 ID、路径前缀、白名单关键字这样换人接手时不用重新摸索。配置文件里我习惯把项目 ID和根路径抽成变量脚本里绝不出现硬编码的完整路径迁移到新项目时只改一处。复现性方面我的经验是任何一次批量删除或复制都应该能从日志反推出当时删了什么、为什么删、谁执行的。这三个信息里前两个靠脚本日志第三个靠提交记录。做到了这一点出问题时至少能快速定位影响范围而不是一群人对着空目录互相猜测。最后分享一个我在实际使用中形成的习惯每次大清理之前先跑一遍清单生成 体积统计 dry-run三连把结果贴到项目记录里等确认后再执行删除。多花的这几分钟换来的是不用在深夜因为一个误删的路径而重新排队跑几十个小时的计算任务。至于复制我的原则更简单——能不复制就不复制优先用权限共享解决分发问题毕竟存储配额是有限的而权限是几乎零成本的。