上周运维转发了一条磁盘告警:生产服务器的 /data 分区使用率到了 92%。我打开监控一看,从 85% 到 92%,只用了一周。什么在涨?用户上传的头像、合同扫描件、导出的报表。这种事情我遇过不止一次,早期项目所有文件都扔在本机磁盘,部署的时候手动备份,扩容就是加数据盘,数据盘满了再迁移,循环往复。后来在 Java 项目里接入阿里云 OSS,把文件存储从应用服务器剥离出去,这套循环才真正停下来。
这篇文章就聊聊我在实际项目里用 Java 集成阿里云 OSS 存储文件的全过程,包括接入前必须搞懂的核心概念、SDK 的引入方式、上传下载删除的完整实操代码,以及那些官方文档里不常写但一定会踩的坑。适合刚准备上对象存储的 Java 后端开发,也适合已经接入了但想回头把整个逻辑梳理一遍的同学。内容基于我实际跑通的代码和踩坑记录,不是把官方文档翻译一遍。
1. 本地文件存储的痛:为什么最后选了OSS
1.1 单体应用里"随手存文件"的问题
很多 Java 项目最开始存文件的方式都很朴素:用户上传一个文件,代码里 FileOutputStream 写到服务器某个目录下,数据库里存一个相对路径,访问的时候通过一个静态资源映射把目录暴露出去。这套方案在小项目、单机部署的时候确实能跑,但问题也是慢慢暴露出来的。
首先是磁盘空间问题。普通云服务器系统盘一般 40G 到 60G,数据盘要看配置。文件一多,尤其是图片、合同扫描件、Excel 导出报表这类东西,是以肉眼可见的速度在涨。磁盘满了以后,上传接口直接报 No space left on device,更麻烦的是日志系统也会跟着挂,排查问题的入口都没了。
其次是备份和迁移的成本。单机文件存储的备份,要么手动打包拷贝,要么写脚本 rsync 到别的机器。如果哪天要迁移服务器,光同步那几万个文件就够折腾半天,中间还可能漏文件。等上了多台应用服务器做负载均衡,问题更明显:用户这次请求打到 A 机器,文件存在 A 的本地磁盘,下次请求打到 B 机器,文件就找不到了。分布式会话可以靠 Redis 解决,分布式文件存储可没法这么搞。
再一个容易被忽略的问题是文件访问的边界。本地文件通过静态资源映射暴露出去,路径是死的,没有下载次数控制、没有分钟级过期链接、没有防盗链。合同文件、个人身份证明这类敏感文件,如果直接放到静态目录,等于在公网上裸奔。
1.2 OSS解决的不只是磁盘问题
阿里云 OSS(Object Storage Service,对象存储服务)是一个键值对形式的云存储系统,把"文件"当成"对象",每个对象有一个全局唯一的键(Key),数据分散存储在阿里云的多台机器上,从设计上就不存在单机磁盘上限的问题。容量基本是"按量付费随便用",单文件最大支持 5TB,这个量级对绝大多数业务系统来说等于无限。
但它解决的不只是磁盘问题。
- 高可用:OSS 底层是多重冗余存储,默认标准存储就有 99.999999999% 的数据持久性,不用自己操心数据备份。
- 访问控制:Bucket 可以设置私有、公共读、公共读写,私有对象还能生成签名 URL,带过期时间,适合合同、账单这类敏感文件。
- 生命周期管理:可以配置规则,比如 30 天前的日志自动转低频存储,90 天后自动删除,省成本。
- 加速与 CDN:对象可以直接绑定 CDN 加速域名,静态资源访问速度比自己服务器快得多。
- 解耦:文件存储和应用服务器彻底分离,应用服务器随便扩容缩容,文件数据不跟着变。
从工程角度看,OSS 最大的价值是让"文件存储"从一个基础设施问题变成了一个 API 调用问题。你的代码只需要关心业务逻辑——把字节流交给 OSS,后面容灾、扩容、迁移一律不用管。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心概念速通:Bucket、Endpoint、ObjectKey和AccessKey
2.1 四个概念一次讲清
我接触过不少同事,代码能写通,但问到 OSS 的几个基础概念经常是模糊的。先把这四个概念理清楚,后面看文档、查报错都会顺很多。
| 概念 | 一句话理解 | 类比 | 典型示例 |
|---|---|---|---|
| Bucket | 存储空间,存放对象的容器 | 仓库里隔出来的一个房间 | my-app-files |
| Object | 存储的文件对象 | 房间货架上的箱子 | 一张图片、一份 PDF |
| ObjectKey | 对象的唯一标识,相当于"路径" | 箱子上贴的编号标签 | images/2024/01/abc.jpg |
| Endpoint | OSS 服务的访问域名 | 仓库大门的地址 | https://oss-cn-hangzhou.aliyuncs.com |
| AccessKey | 访问凭证,包含 AccessKeyId 和 Secret | 进仓库的门禁卡 | LTAI... / K2x... |
这里最容易被搞混的是 Object 和 ObjectKey。很多人一开始把 ObjectKey 当成文件的文件名,实际上它是对象在 Bucket 内的完整逻辑路径。你可以把它设计成 images/2024/01/report.pdf,也可以设计成纯 UUID 字符串 e6b2c3f0-9d4a-4e7b-8f6a-1d2c3b4a5f6e.pdf,具体怎么设计取决于你的业务需求。OSS 没有真正的目录概念,images/2024/01/ 这样的结构只是通过 ObjectKey 的前缀模拟出来的,但控制台和 SDK 都会按前缀给你展示成目录的样子,所以不用纠结。
Bucket 的命名是全局唯一的,你在创建的时候如果提示名称已被占用,换一个即可。Bucket 一旦创建,所属地域(Region)就不能改了,比如创建在华东 1(杭州),它就一直在杭州,后面访问的 Endpoint 也必须是杭州的。
2.2 权限模型与AccessKey的安全姿势
OSS 的权限体系分两层:Bucket 权限和对象权限。
Bucket 有三种访问权限:
| 权限 | 含义 | 适用场景 |
|---|---|---|
| 私有(Private) | 只有授权账号能访问,所有对象默认不可公开访问 | 合同、用户数据、内部文件 |
| 公共读(Public Read) | 对象可被任何人匿名读取,写入需认证 | 网站图片、静态资源、前端静态文件 |
| 公共读写(Public Read/Write) | 任何人可读可写,非常危险 | 几乎不建议使用 |
经验建议:除非是纯静态资源网站,否则一律选私有,然后通过签名 URL 或者 STS 临时凭证给用户授权的访问链接。不要图省事把文件设置成公共读,一旦里面混入敏感数据,后果很难收拾。
AccessKey(AK)是你访问 OSS 的凭证,泄露就等于把整个 Bucket 的管理权限交给了别人。几个安全习惯必须养成:
- AccessKey 永远不要硬编码在代码里,尤其是提交到 Git 仓库之后,哪怕后来删了,历史记录里还有。
- 用环境变量或者配置中心存放,本地开发用
.env文件(记得加.gitignore)。 - 在阿里云 RAM 控制台创建子用户,只授予目标 Bucket 的最小权限,不要直接用主账号 AK 跑业务。
- 生产环境推荐用 STS 临时凭证,时效到期自动失效,权限粒度更细。
3. 环境准备与客户端初始化:把SDK接进来
3.1 Maven依赖与版本选择
阿里云官方 Java SDK 的坐标是 com.aliyun.oss:aliyun-sdk-oss,在 pom.xml 里加上依赖:
xml复制<dependency>
<groupId>com.aliyun.oss</groupId>
<artifactId>aliyun-sdk-oss</artifactId>
<version>3.17.4</version>
</dependency>
版本号我写的是我当时用的版本,你用的时候去 Maven 中央仓库搜一下最新的稳定版即可。有一点要注意:阿里云 SDK 的版本升级偶尔会调整部分 API 的入参或废弃旧方法,所以尽量锁定一个版本,不要用 RELEASE 之类的动态版本号,否则哪天远程构建拉到一个不兼容的新版,你会很被动。
SDK 本身依赖了 org.apache.httpcomponents 和 com.google.code.gson 这些基础库,如果你的项目里已经有其他版本,可能会有冲突。遇到类似 NoSuchMethodError 的报错,优先检查依赖树里有没有被某个中间件另外引入了一份旧版本。
3.2 客户端初始化的正确配置
初始化 OSS 客户端非常轻量,核心代码就几行:
java复制String endpoint = System.getenv("OSS_ENDPOINT");
String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
这里再说细一点:
endpoint是你创建 Bucket 时选定地域的访问域名。杭州节点:https://oss-cn-hangzhou.aliyuncs.com。注意它不带 Bucket 名称。OSSClientBuilder().build()创建出来的客户端是线程安全的,整个应用共享一个实例就够了,没有必要每个请求都 new 一个。频繁创建销毁客户端,会增加大量不必要的连接建立开销。- 客户端使用完毕(应用关闭前)要调用
ossClient.shutdown()释放资源。如果在 Spring 项目中使用,可以做成一个@Bean交给容器管理,在@PreDestroy里关闭。
Spring Boot 里的封装方式大概长这样:
java复制@Configuration
public class OssConfig {
@Bean(destroyMethod = "shutdown")
public OSS ossClient() {
String endpoint = System.getenv("OSS_ENDPOINT");
String accessKeyId = System.getenv("OSS_ACCESS_KEY_ID");
String accessKeySecret = System.getenv("OSS_ACCESS_KEY_SECRET");
return new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret);
}
}
把 destroyMethod 指定成 "shutdown",Spring 容器关闭时就会自动调用客户端关闭方法,不用你自己写钩子。
3.3 单元级别的连通性验证
初始化完成后,先不要急着写业务代码,花一分钟验证一下网络和凭证。最简单的办法是获取 Bucket 信息:
java复制BucketInfo info = ossClient.getBucketInfo(bucketName);
System.out.println("Bucket 所在地域: " + info.getBucket().getLocation());
如果凭证或 Endpoint 有问题,这一步就会抛出异常。常见的异常信息开头包括:
com.aliyun.oss.OSSException:服务端返回的错误,包含 HTTP 状态码和错误码,比如 403 AccessDenied、404 NoSuchBucket。com.aliyun.oss.ClientException:客户端的问题,比如连接超时、网络无法到达。
我建议把这个验证代码写成一个测试用例放到 test 目录里,后面所有同事接手项目时都可以跑一下,快速确认本机环境和测试环境配置是否正确。比起等上传接口报错再排查,这个成本低得多。
4. 上传文件的几种姿势:从简单上传到元数据控制
4.1 简单上传:直接传本地文件
最常见的需求是把服务器本地的文件传到 OSS。这个场景用 putObject 方法,传入 Bucket 名称、ObjectKey 和 File 对象:
java复制String bucketName = "my-app-files";
String objectKey = "contracts/2025/03/PO-20250301.pdf";
String localFilePath = "/data/tmp/PO-20250301.pdf";
ossClient.putObject(bucketName, objectKey, new File(localFilePath));
上传成功后,这个对象就在 Bucket 里了。如果你要做处理结果的校验,SDK 会返回一个 PutObjectResult,里面有对象的 ETag(服务端计算的内容哈希值),可以用来做完整性校验。
一个细节:putObject 传入 File 时,SDK 会自动从文件读取大小并设置 Content-Length,服务端校验没问题。但如果你传的是一个流,就必须自己保证元数据里的 Content-Length 准确,否则上传请求会一直阻塞直到流耗尽,这就是下一节要说的情况。
4.2 流式上传:处理非本地文件
很多时候文件不在本地磁盘,而是用户上传的内存流、HTTP 拉下来的远程流,或者来自其他服务的数据流。这时候可以直接 putObject 传 InputStream:
java复制String objectKey = "images/2025/03/payment-voucher.png";
InputStream inputStream = new URL("https://example.com/temp/payment-voucher.png").openStream();
ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentLength(remoteFileSizeInBytes);
metadata.setContentType("image/png");
ossClient.putObject(bucketName, objectKey, inputStream, metadata);
inputStream.close();
这里踩过一个大坑:如果传入的 InputStream 不能准确获取 available() 或者流长度未知,而你又没有设置 ContentLength,SDK 会尝试先把这个流完整读一遍来算长度,然后再重读一遍来上传。对于只能读取一次的流(比如某些包装流),这会导致上传请求直接报错或者内容为空。所以流式上传时,务必手动设置 metadata.setContentLength(),长度的来源是你自己下发写文件的逻辑,比如 ByteArrayOutputStream.size()、File.length(),或者远程 HTTP 响应的 Content-Length。
4.3 上传时设置Content-Type等元数据
这个坑特别容易踩,但报错又不明显。默认情况下,如果上传对象时不设置 Content-Type,OSS 会统一按 application/octet-stream 处理。用户通过浏览器直接访问你的对象链接时,浏览器看到这个 MIME 类型会直接触发下载,而不是在浏览器里预览。
比如你上传了一个 PDF 合同文件,希望用户点链接直接预览而不是下载,就需要在上传时明确 Content-Type:
java复制ObjectMetadata metadata = new ObjectMetadata();
metadata.setContentType("application/pdf");
metadata.setContentEncoding("UTF-8");
metadata.setContentDisposition("inline");
metadata.setUserMetadata(Map.of("source", "contract-service"));
ossClient.putObject(bucketName, objectKey, new File(localFilePath), metadata);
setContentDisposition 设置为 inline 是让浏览器内联展示,attachment 则是强制下载。如果是图片、音视频这类需要展示或播放的文件,这个头很关键。
还支持自定义元数据 setUserMetadata,标准写法是键值对以 x-oss-meta- 开头(SDK 会自动处理前缀),你可以存一些业务标识,比如文件来源、上传用户 ID,后续在下载时能通过 OSSObject 读回来。但要注意元数据不能替代业务数据库,不适合放大量或复杂数据。
5. 下载、删除、列举:日常文件生命周期管理
5.1 下载文件与流关闭的坑
有上传就有下载,OSS 下载文件的 API 是 getObject:
java复制String objectKey = "contracts/2025/03/PO-20250301.pdf";
OSSObject ossObject = ossClient.getObject(bucketName, objectKey);
try (InputStream inputStream = ossObject.getObjectContent()) {
// 保存到本地
Files.copy(inputStream, Path.of("/data/tmp/download.pdf"), StandardCopyOption.REPLACE_EXISTING);
} catch (IOException e) {
// 处理异常
}
注意 ossObject.getObjectContent() 返回的是一个网络流,背后占着一个 HTTP 连接。如果流里的数据不是全部读取完,或者没有调用 close(),连接池里的连接就会一直处于被占用状态。等到连接池满了,后面所有请求都会阻塞。
所以务必使用 try-with-resources 或者 finally 中关闭流,不要等到 GC 帮你回收。我在同事代码里见过不下三次因为下载后没关流,导致生产环境"间歇性连接超时"的案例。
也有直接获取对象元数据、不下载内容的场景,用 getObjectMetadata:
java复制ObjectMetadata metadata = ossClient.getObjectMetadata(bucketName, objectKey);
long size = metadata.getContentLength();
String contentType = metadata.getContentType();
这个方法很实用,比如在下载前先检查文件是否存在、大小是否匹配,避免下载整个对象后发现数据异常。
5.2 删除与批量删除
单个文件删除用 deleteObject:
java复制ossClient.deleteObject(bucketName, objectKey);
批量删除用 DeleteObjectsRequest:
java复制List<String> keys = List.of("contracts/2025/03/a.pdf", "contracts/2025/03/b.pdf");
DeleteObjectsRequest request = new DeleteObjectsRequest(bucketName).withKeys(keys);
DeleteObjectsResult result = ossClient.deleteObjects(request);
List<String> deletedKeys = result.getDeletedObjects();
这里有个行为上的变化要提一下:阿里云 OSS 之前默认删除操作是"安静模式",也就是不返回已删除的对象列表;现在返回结果会包含被静默删除的对象。再就是批量删除的接口虽然方便,但也有边界,单次最大传入 1000 个 Key,超过就得分批调用。
删除操作还有个注意点:如果 Bucket 开了版本控制,删除对象不会真正抹掉数据,只是打上一个删除标记,历史版本还留在那里,照样计费。这类场景删除后在控制台或通过版本查询还能看到,别误以为没删掉。
5.3 列举对象:按前缀模拟目录
列举 Bucket 里的对象可以用 listObjects:
java复制String prefix = "contracts/2025/03/";
ObjectListing listing = ossClient.listObjects(bucketName, prefix);
for (OSSObjectSummary summary : listing.getObjectSummaries()) {
System.out.println(summary.getKey() + " 大小: " + summary.getSize() + " 最后修改: " + summary.getLastModified());
}
prefix 参数就是前文说的"模拟目录"逻辑。传入 contracts/2025/03/,返回的就是这个前缀下的所有对象。
但这个是分页的,一页默认最多 1000 条。如果对象总数超过 1000,或者你只想要某一个具体位置之后的条目,需要用到 marker:
java复制String nextMarker = listing.getNextMarker();
if (nextMarker != null) {
ObjectListing more = ossClient.listObjects(new ListObjectsRequest(bucketName)
.withPrefix(prefix)
.withMarker(nextMarker));
}
这段逻辑在大量对象时容易写成死循环,我的建议是写一个 while (true) 循环,每次判断是否有 nextMarker,没有就 break,并且加个最大页数保护,防止数据异常导致无限循环。
6. 生产环境中的硬骨头:分片上传、超时、权限与内网Endpoint
6.1 超过100MB的大文件用分片上传
直接 putObject 一个几 GB 的大文件,从代码角度能跑,但生产上不建议这么干。原因有两个:一是单次请求耗时太长,中间网络抖动导致失败就要整个重新传;二是 OSS 服务端对单请求的时限和资源有约束,超时风险高。
分片上传(Multipart Upload)把一个大文件切成多个 part,分别上传,最后发一个合并请求让服务端拼接。好处是失败只重传失败的那一片,还可以并发上传提高吞吐量。
简化版的分片上传代码:
java复制// 1. 初始化分片上传,拿到 uploadId
InitiateMultipartUploadRequest initRequest = new InitiateMultipartUploadRequest(bucketName, objectKey);
InitiateMultipartUploadResult initResult = ossClient.initiateMultipartUpload(initRequest);
String uploadId = initResult.getUploadId();
// 2. 按 5MB 一片切割并并发上传
List<PartETag> partETags = new ArrayList<>();
ExecutorService executor = Executors.newFixedThreadPool(8);
List<FilePart> parts = splitFile(localFile, 5 * 1024 * 1024);
for (FilePart part : parts) {
Future<PartETag> future = executor.submit(() -> {
UploadPartRequest uploadRequest = new UploadPartRequest();
uploadRequest.setBucketName(bucketName);
uploadRequest.setKey(objectKey);
uploadRequest.setUploadId(uploadId);
uploadRequest.setInputStream(part.inputStream);
uploadRequest.setPartSize(part.size);
uploadRequest.setPartNumber(part.partNumber);
UploadPartResult uploadPartResult = ossClient.uploadPart(uploadRequest);
return uploadPartResult.getPartETag();
});
partETags.add(future.get());
}
executor.shutdown();
// 3. 合并分片
CompleteMultipartUploadRequest completeRequest = new CompleteMultipartUploadRequest(bucketName, objectKey, uploadId, partETags);
CompleteMultipartUploadResult completeResult = ossClient.completeMultipartUpload(completeRequest);
partETags 必须按 partNumber 升序排列后再提交合并请求,不然服务端可能无法正确拼装。分片大小除了最后一片,其他每片至少 100KB,一般建议 5MB 到 50MB 之间。文件越大,分片可以适当调大,减少分片数量从而减少并发管理的开销。
如果你的文件动辄几个 GB,并且网络环境不稳定,可以考虑用 OSS SDK 里针对 uploadFile 的断点续传封装,它基于分片上传实现,可在进程崩溃、网络中断后从上次断点继续,对业务方完全透明:
java复制UploadFileRequest uploadFileRequest = new UploadFileRequest(bucketName, objectKey, localFilePath);
uploadFileRequest.setPartSize(10 * 1024 * 1024);
uploadFileRequest.setTaskNum(5);
uploadFileRequest.setEnableCheckpoint(true);
UploadFileResult result = ossClient.uploadFile(uploadFileRequest);
断点续传开启后,会在本地生成一个 checkpoint 记录文件,记录每个分片的上传状态。注意这个记录文件也会占磁盘空间,并且如果上传失败,它不会自动清理,需要你自己在 finally 中处理。
6.2 客户端超时参数与重试策略
默认配置下的 OSS 客户端在弱网环境里表现比较"脆",连接超时、读取超时都可能直接抛异常。尤其是内网直连、跨地域访问的场景,超时阈值要单独调。
用 ClientBuilderConfiguration 来设置:
java复制ClientBuilderConfiguration config = new ClientBuilderConfiguration();
config.setConnectionTimeout(5000); // 建立连接超时 5 秒
config.setSocketTimeout(10000); // socket 读取超时 10 秒
config.setConnectionRequestTimeout(3000);// 从连接池获取连接超时
config.setMaxConnections(1024); // 最大连接数
config.setMaxErrorRetry(3); // 失败重试次数
OSS ossClient = new OSSClientBuilder().build(endpoint, accessKeyId, accessKeySecret, config);
setMaxConnections 要结合你的业务并发量来设置。如果应用上面挂着大量并发上传下载请求,连接数太小会导致请求排队。但也不要盲目调大,每个 OSS 连接都会占用文件描述符和内存,太大反而把应用自身资源耗尽。
重试策略默认是 3 次,对网络抖动型错误有帮助。但要注意:对于已经服务端接收的写请求,重试可能导致重复写入同一 Object。幂等性靠 ObjectKey 唯一性保证,所以强烈建议业务上为每次上传生成唯一的 ObjectKey,从根源上避免重复数据。
6.3 内网Endpoint和外网Endpoint怎么选
如果你的应用部署在阿里云 ECS 上,OSS Bucket 也在同一地域,一定要用内网 Endpoint,也就是在公网 Endpoint 中间加一个 -internal:
| 场景 | Endpoint 示例 | 流量费用 |
|---|---|---|
| ECS 访问同地域 OSS(内网) | https://oss-cn-hangzhou-internal.aliyuncs.com |
免流量费 |
| 公网访问 OSS | https://oss-cn-hangzhou.aliyuncs.com |
下行流量按量计费 |
| 跨境 / 不同地域访问 | 使用公网或加速域名 | 流量费 + 可能的加速费 |
这个坑我亲眼见过:运维图省事,在 ECS 上配了公网 Endpoint,每天光 OSS 下行流量费就是几十块钱。对于一次下载 10MB 的合同文件、一天几万次下载的业务,这个费用差异非常明显。
判断是否走了内网的简单办法:在 ECS 上执行 ping 内网 Endpoint 域名,能解析出 10.x.x.x、100.x.x.x 这类私网 IP 就对了;解析出公网 IP 说明配置有问题。
还有一点:内网 Endpoint 只能在阿里云 VPC 网络内部访问,本地开发环境不能用。所以代码里建议把 Endpoint 做成配置项,不同环境配不同的值,本地用公网,测试和生产 ECS 用内网。
6.4 权限相关的典型报错与排查
接入 OSS 之后,遇到最多的问题集中在权限和配置上。下面这几个错误码,我基本每个月都能见到:
| 错误码 | 现象 | 最可能的原因 |
|---|---|---|
AccessDenied |
403 Forbidden | RAM 子用户没有目标 Bucket/Object 的权限,或访问私有对象时未加签名 |
NoSuchBucket |
404 Not Found | Bucket 不存在,或者 Endpoint 的地域和 Bucket 所在地域不一致 |
NoSuchKey |
404 Not Found | ObjectKey 拼写错误,或对象已被删除 |
SignatureDoesNotMatch |
403 SignatureDoesNotMatch | AccessKey 不匹配、Endpoint 使用错误,或本地系统时间不准确 |
InvalidAccessKeyId |
403 InvalidAccessKeyId | AccessKeyId 配置错误、被禁用、或格式不对 |
最经典的坑是 NoSuchBucket:你在杭州区域创建了 Bucket,但 Endpoint 写成了北京(oss-cn-beijing.aliyuncs.com),OSS 到北京区域找这个 Bucket,自然是找不到的。Bucket 名称和 Endpoint 的地域必须匹配。
另一个常见的误配是本地和服务器时间差太多。签名 URL 是根据本地时间生成的,如果本地时间比 OSS 服务端慢了几分钟,生成签名请求后 OSS 判断请求已过期,直接拒绝访问。检查一下系统时间同步配置,ntpdate 或者 chrony 保持时间同步是基本功。
排查这类问题时,我的习惯是先把原始异常打全:ossClient 抛出的异常分为服务端异常 OSSException 和客户端异常 ClientException,分别查看 getErrorCode() 和 getStatusCode(),一般很快就能定位到是权限、配置还是网络问题。不要只看异常 message 的前几个字,很多真实错误信息被截断了。
7. 一点务实建议:把OSS调用封装成自己的存储层
最后分享一个我在项目中始终坚持的做法。业务代码里不要直接到处调用 ossClient.putObject,而是统一封装一个 FileStorageService 接口,内部实现用 OSS,接口暴露给业务方时方法名和参数完全贴合业务语义:
java复制public interface FileStorageService {
String upload(byte[] content, String originalFileName, String contentType);
byte[] download(String fileKey);
void delete(String fileKey);
String generatePresignedUrl(String fileKey, long expireSeconds);
}
这个封装层的价值在于,未来你从 OSS 切换到其他对象存储(比如腾讯云 COS、华为云 OBS、MinIO)时,只需要替换实现类,业务代码一行都不用改。另外,所有统一的逻辑,比如 ObjectKey 生成规则(日期 + UUID)、日志打印、异常转业务异常,都可以收敛在这个层里,不会散落在各个 Service 中。
一个小技巧:上传前统一生成 ObjectKey 时,不要直接用原始文件名。中文文件名在 URL 拼接和签名时容易出乱码,重名还会互相覆盖。用 日期/业务类型/UUID 这种结构,可读性好,又不会冲突。原始文件名可以通过前面说的 setUserMetadata 存到元数据里,下载时再取出来恢复文件名。
OSS 接入本身不复杂,难点全在细节里。把概念理清、代码骨架搭好、坑提前避开,这套存储方案可以很稳定地跑上好几年。
