做文件上传功能的时候很多人会把所有精力放在 upload 方法上等上线才发现下载、删除、碎片清理这些配套能力一个比一个头疼。我最近在一个 Spring Boot 项目里做腾讯云 COS 对象存储的接入功能很简单就是对象下载、删除、还有碎片清理但踩了一圈坑之后发现这三件事其实是“一条线”上的处理不好存储桶里全是垃圾数据账单也会很难看。这篇文章就把我的完整做法和踩坑过程写出来包括为什么这么设计、代码怎么组织、批量删除的限制、碎片清理的触发时机以及我在实际环境里遇到的几个典型报错。适合正在做 COS 接入的后端朋友参考也适合那些已经把文件丢进存储桶、但不知道后续怎么管理的人。1. 项目背景与整体设计思路1.1 为什么下载、删除、碎片清理要一起设计很多项目第一版只做了上传COS 里堆了一堆对象下载走 URL 拼接删除靠控制台手动操作碎片更是没人管。短期没问题一旦业务量上来就暴露三件事下载如果没有服务端签名和流式处理文件稍微大一点就会触发内存溢出而且 URL 长期暴露在外面数据安全不受控。删除如果只处理单条记录批量清理、目录清理、幂等设计全都得重做。分片上传只要失败一次未完成的 uploadId 就会留在存储桶里量少无所谓量大了存储空间和查询性能都会受影响。所以我在这套服务里的设计原则是所有对 COS 的操作都走统一的 Service 封装所有涉及资源清理的逻辑都做成可追溯、可定时执行的独立方法调用方不需要知道 SDK 细节。1.2 整体技术栈与工程结构这次项目的基本技术栈比较主流Spring Boot 2.7.x腾讯云 COS Java SDKcos_api_clientLombok 简化代码腾讯云 V5 版签名体系工程上我把 COS 相关能力全部收敛到CosFileService这个类里内部注入COSClient按“下载、删除、碎片清理”三个能力域拆分方法不直接让 Controller 碰 SDK 对象。这样做的直接好处是以后换存储厂商时只要改这一个实现类Controller 和业务层不用动。Service RequiredArgsConstructor public class CosFileService { private final COSClient cosClient; // 下载相关 public String getFileUrl(String bucketName, String key, int expireSeconds) { ... } public void downloadToLocal(String bucketName, String key, String localPath) { ... } // 删除相关 public void deleteSingle(String bucketName, String key) { ... } public void deleteBatch(String bucketName, ListString keys) { ... } // 碎片相关 public ListMultipartUpload listMultipartUploads(String bucketName, String prefix) { ... } public void abortMultipartUploads(String bucketName, ListMultipartUpload uploads) { ... } }说实话这个封装没有多高级但它把 SDK 的复杂度限制在了一个类里面其他业务代码读到的是“下载、删除、清碎片”这种业务语义不用关心 HTTP 客户端、线程池、签名怎么实现。下面从环境准备开始逐步展开。2. 环境准备与 SDK 初始化2.1 依赖引入与版本选择COS Java SDK 的坐标是dependency groupIdcom.qcloud/groupId artifactIdcos_api/artifactId version5.6.227/version /dependency这里有个细节老一点的写法是cos_api_client新版已经统一到cos_api如果你在旧教程里看到的是前一个坐标注意换过来。版本号也不是越大越好我用 5.6.227 是因为它在 Spring Boot 2.7 环境下没有依赖冲突而且接口足够稳定。另外需要确认你的项目里没有多个不同版本的httpclient、jackson一起打架。COS SDK 传递依赖里带了旧版httpclient如果项目里已经引入了新版最好用 Maven 的dependencyManagement统一版本。2.2 配置文件准备工作在application.yml里加自定义配置cos: secret-id: ${COS_SECRET_ID} secret-key: ${COS_SECRET_KEY} region: ap-guangzhou bucket-name: my-app-1234567890注意两个点密钥一定走环境变量或者配置中心别硬编码bucketName和region必须匹配否则会出现交叉区域访问失败这个问题我后面讲排查时会再提。2.3 创建 COSClient 配置类用配置类把COSClient交给 Spring 管理Configuration public class CosConfig { Bean public COSClient cosClient(CosProperties properties) { BasicCOSCredentials credentials new BasicCOSCredentials( properties.getSecretId(), properties.getSecretKey()); ClientConfig clientConfig new ClientConfig(new Region(properties.getRegion())); clientConfig.setSocketTimeout(30_000); clientConfig.setConnectionTimeout(30_000); return new COSClient(credentials, clientConfig); } }ClientConfig里两个超时时间值得解释一下默认 Socket 超时只有几千毫秒下载大文件或者网络抖动时很容易报 SocketTimeoutException我调成 30 秒之后大文件下载稳定了很多。connectionTimeout管的是建立连接阶段也别设太小。还要注意COSClient是线程安全的整个项目共用一个实例就够了。不要每次请求都 new 一个那样不仅浪费连接池资源还会把 SecretKey 在内存里反复留存。3. 对象下载功能实现3.1 两种基础下载方式下载对象最常见的有两种拿到输入流自己处理或者直接下载到本地文件。先看流式下载public void downloadToStream(String bucketName, String key, OutputStream outputStream) { GetObjectRequest request new GetObjectRequest(bucketName, key); COSObject cosObject cosClient.getObject(request); try (InputStream inputStream cosObject.getObjectContent()) { byte[] buffer new byte[8192]; int len; while ((len inputStream.read(buffer)) ! -1) { outputStream.write(buffer, 0, len); } outputStream.flush(); } catch (IOException e) { throw new RuntimeException(下载文件失败, e); } }缓冲区 8192 字节是经验值实测对普通文件比较友好。这里强调一点getObjectContent()返回的流必须关掉否则连接不会归还给连接池时间长了连接池耗尽会导致后续请求全部失败。再看到本地文件SDK 直接提供了方法public void downloadToLocal(String bucketName, String key, String localPath) { GetObjectRequest request new GetObjectRequest(bucketName, key); File localFile new File(localPath); File parentDir localFile.getParentFile(); if (parentDir ! null !parentDir.exists()) { parentDir.mkdirs(); } cosClient.getObject(request, localFile); }这个方式适合服务端需要把对象落盘再处理的场景比如生成缩略图后把原图拉下来处理。有个细节目标路径的父目录不存在时会报 FileNotFoundException所以我先手动mkdirs()。3.2 给前端返回临时下载地址如果业务是给前端一个下载链接不用让流量走自己的应用服务器可以直接生成带签名的临时 URLpublic String generatePresignedUrl(String bucketName, String key, int expireSeconds) { Date expiration new Date(System.currentTimeMillis() expireSeconds * 1000L); URL url cosClient.generatePresignedUrl(bucketName, key, expiration); return url.toString(); }为什么用预签名 URL 而不是直接拼接基础 URL因为默认情况下 COS 对象如果是私有读写直接拼接的 URL 访问会返回 403。预签名 URL 会在 URL 参数里带一个有时效的签名比如?q-sign-algorithmsha1q-sign-time...过期自动失效。对前端来说这就是一个普通链接能下载但拿到手也无法永久访问。我一般把过期时间设为 10 分钟。如果业务要求下载链接长期有效那就得考虑把对象的权限改为公有读或者用 CDN 回源鉴权这里不展开。3.3 下载大文件的内存与稳定性有朋友会图省事把 COS 对象一次性读进byte[]再返回byte[] data IOUtils.toByteArray(cosObject.getObjectContent());小文件没问题一个文件几十 MB 就危险了。我这边实际遇到过下载 200MB 文件时JVM 内存直接飙到告警线。所以尽量用流式传输Controller 里用ResponseEntityStreamingResponseBody或者直接向HttpServletResponse的OutputStream写入让流量像水管一样通过而不是全部装进内存。GetMapping(/download) public void download(RequestParam String key, HttpServletResponse response) throws IOException { response.setContentType(application/octet-stream); response.setHeader(Content-Disposition, attachment; filename\ URLEncoder.encode(key, UTF-8) \); cosFileService.downloadToStream(bucketName, key, response.getOutputStream()); }这里 Content-Disposition 的 filename 做 URL 编码很关键否则中文文件名会乱码浏览器下载时直接变成乱码文件名。3.4 断点续传与高性能下载SDK 里还提供一个TransferManager它封装了分片下载和断点续传逻辑。对大文件场景很友好TransferManager transferManager new TransferManager(cosClient); Download download transferManager.download( new GetObjectRequest(bucketName, key), new File(localPath)); download.waitForCompletion();需要注意TransferManager使用完后要调用shutdown()它内部有线程池挂在 Spring 容器里要小心资源释放。如果你只是偶尔下个大文件可以在方法里 new、用完 shutdown如果频繁使用建议单独配置一个TransferManagerBean应用关闭时统一 shutdown。4. 对象删除功能实现4.1 单个对象删除与幂等设计删除单个对象很简单public void deleteSingle(String bucketName, String key) { cosClient.deleteObject(bucketName, key); }有个容易被忽视的点deleteObject在对象不存在时并不会抛异常。这个语义和本地文件系统不太一样它是“幂等删除”。所以业务层不用先判断 exists 再删除直接删就行。但反过来如果你希望通过删除接口返回“实际上有没有删掉东西”那是做不到的别把业务逻辑建立在返回值上。4.2 批量删除对象与 1000 条限制删除大量对象时逐条调用性能太差COS 提供了批量删除接口public void deleteBatch(String bucketName, ListString keys) { DeleteObjectsRequest request new DeleteObjectsRequest(bucketName); request.setKeys(keys); cosClient.deleteObjects(request); }这个接口有一个硬限制一次最多删除 1000 个对象。超过 1000 个会报请求过大或参数非法。所以批量删除要分批比如每批 500 个public int deleteBatchWithChunk(String bucketName, ListString keys) { int size 500; int total 0; for (int i 0; i keys.size(); i size) { ListString chunk keys.subList(i, Math.min(i size, keys.size())); DeleteObjectsRequest request new DeleteObjectsRequest(bucketName); request.setKeys(chunk); cosClient.deleteObjects(request); total chunk.size(); } return total; }为什么要选 500 而不是 1000主要是考虑到单次请求响应体的大小。批量删除的响应会把删除成功、失败的对象列表都带回来如果 key 名称很长1000 条响应体已经不小了。500 条是比较均衡的选择网络传输量小且接口稳定。这不是官方规定是我自己的工程习惯。4.3 删除“目录”和版本控制的坑COS 本身没有真实目录“folder/” 只是一个前缀。要删一个目录下的所有对象实际要做的是“按前缀遍历后逐个删除”。public void deleteByPrefix(String bucketName, String prefix) { ListObjectsRequest request new ListObjectsRequest(); request.setBucketName(bucketName); request.setPrefix(prefix); request.setMaxKeys(1000); ObjectListing listing; do { listing cosClient.listObjects(request); ListString keys listing.getObjectSummaries().stream() .map(COSSummary::getKey) .collect(Collectors.toList()); if (!keys.isEmpty()) { deleteBatchWithChunk(bucketName, keys); } request.setMarker(listing.getNextMarker()); } while (listing.isTruncated()); }这个写法就是“翻页 删除”的组合isTruncated()判断是否还有下一页。但有一个很隐蔽的坑如果存储桶开启了版本控制直接按 key 删除不会真正删掉历史版本只是新增一条删除标记之前的版本还占空间。要彻底清掉需要拿到versionId再删。如果只是普通业务桶不做版本控制的话上面代码就够用了。另外deleteByPrefix删除“目录”时真正常见的情况是删除后马上重新创建同名目录时会发现目录还在。这是因为之前没有真正存在目录对象只是空前缀。删除后重新出现一个folder/对象往往是客户端上传时主动创建的占位对象。5. 碎片清理核心实践5.1 碎片是什么为什么必须清碎片Multipart Upload Fragment指的是使用分片上传时已经开始了InitiateMultipartUpload但没有执行CompleteMultipartUpload或AbortMultipartUpload于是那些已经上传的 Part 就会在存储桶里处于“悬空”状态。最典型的生产场景是前端分片上传了大文件中途用户断网、关闭页面、切换网络或者后端逻辑出异常直接返回上传没完成。这时候服务端没有机会去执行 complete 或 abort那些 Part 就留下了。碎片有两个直接危害一是占用存储空间影响存储成本二是在控制台看到一堆 uploadId 很乱某些第三方管理工具会把这些“未完成上传”当成异常数据展示。5.2 列出未完成的分片上传任务COS 提供了listMultipartUploads接口public ListMultipartUpload listAllMultipartUploads(String bucketName, String prefix) { ListMultipartUpload result new ArrayList(); ListMultipartUploadsRequest request new ListMultipartUploadsRequest(bucketName); request.setPrefix(prefix); request.setMaxUploads(1000); ListMultipartUploadsResult listing; do { listing cosClient.listMultipartUploads(request); result.addAll(listing.getMultipartUploadList()); request.setKeyMarker(listing.getNextKeyMarker()); request.setUploadIdMarker(listing.getNextUploadIdMarker()); } while (listing.isTruncated()); return result; }这里MultipartUpload对象包含getKey()和getUploadId()这两个信息在取消上传时要用到。翻页参数是keyMarker和uploadIdMarker和普通对象列表的marker不同容易写错。5.3 批量取消分片上传拿到碎片列表后逐个调用abortMultipartUploadpublic void abortMultipartUploads(String bucketName, ListMultipartUpload uploads) { for (MultipartUpload upload : uploads) { AbortMultipartUploadRequest request new AbortMultipartUploadRequest( bucketName, upload.getKey(), upload.getUploadId()); cosClient.abortMultipartUpload(request); } }这个调用会把这个 uploadId 下已上传的所有 Part 删掉释放空间。如果某个碎片已经不存在了比如已经被其他任务取消过abort 时一般不会抛异常但如果抛了NoSuchUpload错误可以捕获后忽略因为是幂等操作。批量清理时要小心碎片数量多时如果一个个循环 abort串行效率比较低。可以引入一个简单的线程池并发处理但并发控制在 10 到 20 之间就够了别开太大否则容易触发 COS 的频控限流。5.4 定时清理策略与任务设计碎片清理最好是定时任务而不是等用户触发。我用 Spring 自带Scheduled实现了一个简单的清理任务Component RequiredArgsConstructor Slf4j public class CosFragmentCleanTask { private final CosFileService cosFileService; Scheduled(cron 0 0 2 * * ?) public void cleanFragments() { String bucketName my-app-1234567890; ListMultipartUpload uploads cosFileService.listAllMultipartUploads(bucketName, ); if (uploads.isEmpty()) { log.info(no cos fragments to clean); return; } log.info(found {} cos fragments, start clean, uploads.size()); cosFileService.abortMultipartUploads(bucketName, uploads); log.info(cos fragments cleaned: {}, uploads.size()); } }凌晨两点执行是因为这个时段业务上传量最低清理时不会误伤正在进行的上传。但这里有一个很关键的设计问题如果用户正在上传中碎片列出时也是会包含它的会被定时任务强制 abort导致上传失败。解决办法是在碎片对象里过滤掉最近几分钟内创建的任务。MultipartUpload对象有getInitiatedTime()字段可以拿当前时间减去它来判断。一般设置一个 30 分钟的“保护期”超过 30 分钟还没 complete 的上传任务大概率是已经失败或者放弃的可以清理。public ListMultipartUpload listExpiredMultipartUploads(String bucketName, Duration expireAfter) { Date threshold new Date(System.currentTimeMillis() - expireAfter.toMillis()); return listAllMultipartUploads(bucketName, ).stream() .filter(u - u.getInitiatedTime().before(threshold)) .collect(Collectors.toList()); }这个保护期逻辑是我在实际项目里加的因为遇到过凌晨清理任务把用户正在上传的 5GB 大文件任务 abort 掉的情况用户第二天一上班就反馈上传失败。5.5 碎片清理的持续监控清理任务跑完不代表一劳永逸我习惯在清理任务里额外输出一份“存量报告”比如每次清理后还剩多少个碎片、总量多大。这样能从日志趋势判断上传失败率是不是在上升。如果碎片数量每周都在涨说明前端分片上传的重试机制有问题源头得修。6. 常见问题与排错记录6.1 常见问题速查表我把这次实践中遇到的典型问题整理成了表格方便对着排查。问题现象可能原因排查思路下载时 SocketTimeoutExceptionSocket 超时设置太短或网络链路不稳定调大clientConfig.setSocketTimeout()观察是否稳定复现配置正确但连接报 Region 错误桶所在地域与配置 region 不一致到控制台确认桶所属地域确保代码里一致下载的文件和源文件大小不一致流未完整读完就关闭或者使用了错误的下载接口检查读取循环确认是否调用了getObjectToFile批量删除超过 1000 时报错一次传入对象数量超限按 500 一批分批删除删除后对象还能访问版本控制开启删除仅生成了删除标记按 versionId 删除历史版本或关闭版本控制碎片清理后控制台没变化清理任务执行了但碎片对象级别不对确认列出的是 MultipartUpload 而不是普通对象定时清理误杀正在上传的任务没有过滤最近初始化时间增加 30 分钟保护期过滤initiatedTime6.2 一个印象比较深的排错过程有一个问题很有代表性删除接口本地跑得好好的部署到测试环境就报 403。后来发现是测试环境的COS_SECRET_ID和COS_SECRET_KEY配错了拿了一个没有该桶权限的子账号密钥。这个错让我养成了两个习惯一是配置文件的密钥一定从环境变量读二是测试环境单独用一个有权限的子账号权限范围尽量收窄到指定桶。另一个问题是下载文件时文件本身存在但getObject一直返回NoSuchKey。我打印出 key 才发现客户端上传时把文件名做了 URL 编码比如 “报告 2024.pdf” 变成了 “%E6%8A%A5%E5%91%8A%202024.pdf”而我的下载 key 却用了原始文件名。腾讯云 COS 的对象 key 是一个不感知 URL 编码的字符串用它下载就得原样使用上传时的 key不能先编码再传。这个问题在那之后我再也没有踩过因为我会把 key 统一作为业务 ID 存进数据库不从前端传文件名来构造。6.3 安全相关的几个建议密钥管理一定要认真。我见过有同学把 SecretKey 直接写在 application.yml 里然后代码仓库推到了远端仓库结果第二天云厂商就提示密钥泄露。正确做法是使用云访问管理 CAM 的子账号密钥不要用主账号密钥。子账号只授予指定桶和指定操作的权限。密钥通过环境变量、配置中心或密钥管理服务下发不进代码仓库。定期轮换密钥并开启云厂商的密钥泄露检测告警。下载接口如果走的是预签名 URL过期时间不要太长。10 到 30 分钟是比较合理的区间不要让用户的下载链接能永久保存否则相当于把私有对象变成永久可访问了。7. 实际经验与小技巧分享7.1 我比较满意的一个小设计整套代码里我最满意的地方不是某个具体方法而是把所有 COS 操作都集中在CosFileService里的这种收敛设计。后续要加“复制对象”“修改存储类型”“生成图片缩略图”这些功能时只需要往这个类里加方法业务层完全感知不到 SDK 的变化。项目里另外一个模块需要生成下载链接时我也只暴露了一个getFileUrl()方法内部怎么签名、怎么走鉴权调用方不用关心。这种封装对测试也有好处。单元测试时可以 mockCOSClient因为CosFileService依赖的是 SDK 接口而不是具体实现替换成测试替身很容易。7.2 碎片清理还可以继续扩的东西目前清理任务是在 Spring Boot 进程内用Scheduled跑的适合中小项目。如果服务是多实例部署每个实例都会跑一遍定时任务可能造成重复清理。虽然 abort 是幂等的重复跑最多浪费点请求但严格来说不优雅。更稳妥的方案是把定时任务抽成独立的 Job保证同一时间只有一个实例在执行清理。这个后续可以接 XXL-Job 或者直接用云厂商的定时触发器。下载这边还有一个可以优化的点如果访问量上来预签名 URL 的方式会让每个请求都打到 COS建议在前端加 CDN 或者把热对象转成公有读 CDN 回源能省不少流量成本。不过这个属于后续演进初期不用太纠结。最后再分享一个很实际的技巧在你写删除和清理逻辑时先在 COS 控制台手动上传一个测试文件、开启一个分片上传任务然后用同一个 bucketName 跑你的代码。这样可以确认接口调用参数、权限策略都没问题再放到真实业务里。我每次新接入一个存储操作前都会这样自测一遍很多眼熟的报错其实都是自己参数拼错了。