ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Spring Boot集成COS对象存储:下载、删除与碎片清理实战

Spring Boot集成COS对象存储:下载、删除与碎片清理实战

做文件上传功能的时候,很多人会把所有精力放在 upload 方法上,等上线才发现下载、删除、碎片清理这些配套能力一个比一个头疼。我最近在一个 Spring Boot 项目里做腾讯云 COS 对象存储的接入,功能很简单,就是对象下载、删除、还有碎片清理,但踩了一圈坑之后发现这三件事其实是“一条线”上的,处理不好存储桶里全是垃圾数据,账单也会很难看。

这篇文章就把我的完整做法和踩坑过程写出来,包括为什么这么设计、代码怎么组织、批量删除的限制、碎片清理的触发时机,以及我在实际环境里遇到的几个典型报错。适合正在做 COS 接入的后端朋友参考,也适合那些已经把文件丢进存储桶、但不知道后续怎么管理的人。

1. 项目背景与整体设计思路

1.1 为什么下载、删除、碎片清理要一起设计

很多项目第一版只做了上传,COS 里堆了一堆对象,下载走 URL 拼接,删除靠控制台手动操作,碎片更是没人管。短期没问题,一旦业务量上来,就暴露三件事:

  • 下载如果没有服务端签名和流式处理,文件稍微大一点就会触发内存溢出,而且 URL 长期暴露在外面,数据安全不受控。
  • 删除如果只处理单条记录,批量清理、目录清理、幂等设计全都得重做。
  • 分片上传只要失败一次,未完成的 uploadId 就会留在存储桶里,量少无所谓,量大了存储空间和查询性能都会受影响。

所以我在这套服务里的设计原则是:所有对 COS 的操作都走统一的 Service 封装,所有涉及资源清理的逻辑都做成可追溯、可定时执行的独立方法,调用方不需要知道 SDK 细节。

1.2 整体技术栈与工程结构

这次项目的基本技术栈比较主流:

  • Spring Boot 2.7.x
  • 腾讯云 COS Java SDK(cos_api_client)
  • Lombok 简化代码
  • 腾讯云 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, List<String> keys) { ... } // 碎片相关 public List<MultipartUpload> listMultipartUploads(String bucketName, String prefix) { ... } public void abortMultipartUploads(String bucketName, List<MultipartUpload> uploads) { ... } }

说实话,这个封装没有多高级,但它把 SDK 的复杂度限制在了一个类里面,其他业务代码读到的是“下载、删除、清碎片”这种业务语义,不用关心 HTTP 客户端、线程池、签名怎么实现。下面从环境准备开始逐步展开。

2. 环境准备与 SDK 初始化

2.1 依赖引入与版本选择

COS Java SDK 的坐标是:

<dependency> <groupId>com.qcloud</groupId> <artifactId>cos_api</artifactId> <version>5.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 给前端返回临时下载地址

如果业务是给前端一个下载链接,不用让流量走自己的应用服务器,可以直接生成带签名的临时 URL:

public 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-algorithm=sha1&q-sign-time=...,过期自动失效。对前端来说这就是一个普通链接,能下载,但拿到手也无法永久访问。

我一般把过期时间设为 10 分钟。如果业务要求下载链接长期有效,那就得考虑把对象的权限改为公有读,或者用 CDN 回源鉴权,这里不展开。

3.3 下载大文件的内存与稳定性

有朋友会图省事,把 COS 对象一次性读进byte[]再返回:

byte[] data = IOUtils.toByteArray(cosObject.getObjectContent());

小文件没问题,一个文件几十 MB 就危险了。我这边实际遇到过下载 200MB 文件时,JVM 内存直接飙到告警线。所以尽量用流式传输,Controller 里用ResponseEntity<StreamingResponseBody>或者直接向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, List<String> keys) { DeleteObjectsRequest request = new DeleteObjectsRequest(bucketName); request.setKeys(keys); cosClient.deleteObjects(request); }

这个接口有一个硬限制:一次最多删除 1000 个对象。超过 1000 个会报请求过大或参数非法。所以批量删除要分批,比如每批 500 个:

public int deleteBatchWithChunk(String bucketName, List<String> keys) { int size = 500; int total = 0; for (int i = 0; i < keys.size(); i += size) { List<String> 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); List<String> 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 List<MultipartUpload> listAllMultipartUploads(String bucketName, String prefix) { List<MultipartUpload> 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 批量取消分片上传

拿到碎片列表后,逐个调用abortMultipartUpload:

public void abortMultipartUploads(String bucketName, List<MultipartUpload> 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"; List<MultipartUpload> 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 List<MultipartUpload> 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 分钟保护期,过滤initiatedTime

6.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 跑你的代码。这样可以确认接口调用参数、权限策略都没问题,再放到真实业务里。我每次新接入一个存储操作前都会这样自测一遍,很多眼熟的报错其实都是自己参数拼错了。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进