接手过一个面向多端的跨平台管理系统早期上传全部走应用服务器再转发到存储一到活动高峰期应用服务器的带宽和 CPU 直接被打满上传下载互相挤占资源线上告警不断。后来把上传链路整体迁到对象存储上采用“客户端直传 服务端签名”的方案才真正把文件这块从业务系统里解耦出去。这篇文章就以基于阿里云 OSS 实现分布式文件上传为主线把架构选型、环境准备、签名接口设计、关键上传方式、线上排障经验完整讲一遍给正在做文件服务中台化、或者被大文件上传折磨过的技术同学一个可直接落地的参考。1. 为什么分布式上传要换成“客户端直传 OSS”而不是服务器中转1.1 服务器中转的瓶颈在哪里不少人第一反应是“上传文件不就是一个 POST 接口的事吗”。确实如果系统并发量低、文件体积小应用服务器当中转层完全没问题。但在分布式系统里文件上传一旦变成“客户端 → 网关 → 应用服务 → 存储”这种链路瓶颈会非常明显。首先是带宽占用。应用服务器的公网带宽是按量计费的同样是 1000 个用户同时上传 10MB 文件如果都经过应用服务器服务器要承受 10GB 的入口流量再叠加业务接口本身的数据流量带宽很容易被打满。转发过程还需要在内存或磁盘中做临时缓冲遇到大文件几 GB 的内存就被文件流吃掉了直接影响其他接口的响应时间。更麻烦的是分布式架构下应用服务通常有多个实例上传到某个实例的临时文件对其他实例不可见如果后续处理要在另一个实例上执行还得先把文件在服务器间同步一遍成本和复杂度都不小。1.2 三种方案横向对比中转、直传、云原生网关我把常见的三种上传链路放在一起做了个对比这里直接给出我之前选型时梳理的结论方案文件流向应用服务器压力并发上限安全控制适合场景服务器中转客户端 → 应用服务器 → 存储大带宽内存双占用受限于服务器规格中间层可做权限过滤小型系统、文件量少且小客户端直传 OSS客户端 → OSS服务端只参与签名极小仅承担签名接口高OSS 自带负载能力依赖 STS 临时凭证与策略中大型分布式系统网关/代理层客户端 → 上传网关 → OSS网关承担流量应用层不参与取决于网关规格可在网关层统一管控需要额外拦截/合规检查直传方案的核心思路是把文件数据流和应用业务流拆开。客户端拿到服务端签发的临时凭证直接跟 OSS 交互完成上传应用服务完全不碰文件内容只负责“发证”和“校验”。这样即使文件量再大压力也在 OSS 侧业务服务器不会成为瓶颈。1.3 直传方案引入的新问题方案切换不是免费的。直传看起来很美好但它把原来藏在应用服务器内部的责任转移到了客户端和 OSS 之间随之而来有三个问题必须解决第一个是凭证安全。客户端不再通过服务端转发所以不能把持久的 AccessKey 直接给前端否则一旦暴露整个 Bucket 都面临风险。这里必须要引入临时凭证机制。第二个是权限粒度。不同用户能传什么目录、能传多大、能不能覆盖文件都需要在服务端签名阶段就控制好而不是在业务代码里再做一次校验。第三个是链路可观测性。文件直传 OSS 后服务端看不到上传进度和结果必须依赖 OSS 的事件通知、回调机制或者客户端上报来感知文件是否完整送达。这三个问题决定了整个方案的实现细节后面每一章都会对应解决。2. 环境准备从 Bucket 规划到最小权限模型2.1 Bucket 与目录规划在分布式系统中建 Bucket 前建议先做一次存储结构规划。我之前见过团队把所有项目的文件塞进一个 Bucket目录层级混乱生命周期管理完全没法做最后想清理临时文件都不知道哪些是临时的。我习惯的做法是按业务域和访问属性拆分 Bucket。比如“用户头像”和“订单凭证”“系统日志”分开因为它们的访问频次、敏感程度、保留周期完全不同。头像可以公有读私有写订单凭证必须私有权限日志可能需要频繁清理。混在一个 Bucket 里权限和生命周期策略都会互相干扰。目录则采用“业务域 / 日期 / 用户维度”的层级设计例如uploads/order/{yyyyMMdd}/{userId}/{fileName}。这样有几个好处通过生命周期规则配置不同前缀的策略非常方便排查问题时能快速定位某一天某个用户上传的文件也方便做跨地域复制时按前缀过滤。2.2 RAM 子账号与 AccessKey 的最小化授权创建好 Bucket 之后接下来是账号体系。主账号的 AccessKey 无论如何都不能下发给应用甚至不建议直接用主账号 AK 在后端签名风险面太大。正确的做法是创建一个 RAM 子账号只授予这个子账号操作 OSS 的权限而且权限范围要精确到“允许它扮演 STS 角色、允许对指定的 Bucket 做上传操作”而不是给一个“所有 OSS 操作都放行”的宽泛授权。我当时给这个子账号配的权限策略核心就两条{ Version: 1, Statement: [ { Effect: Allow, Action: sts:AssumeRole, Resource: acs:ram:*:*:role/oss-upload-role }, { Effect: Allow, Action: [ oss:PutObject, oss:InitiateMultipartUpload, oss:UploadPart, oss:CompleteMultipartUpload, oss:AbortMultipartUpload, oss:ListMultipartUploads ], Resource: acs:oss:*:*:example-bucket/uploads/* } ] }注意看我最后那一行的Resource写的是uploads/*也就是说这个子账号只能操作 Bucket 里uploads/前缀下的文件。目录规划在这里的价值就体现出来了——你没有办法用权限去约束不存在边界的存储空间。2.3 SDK 依赖初始化与 Endpoint 选择后端服务接入 OSS SDK 时有一个容易被忽略的点一个服务进程里应该共享一个全局的 OSSClient而不是每次上传都 new 一个实例。OSSClient 内部维护了连接池重复创建会直接耗尽连接资源。以 Java 后端为例一般这样初始化// 在应用启动时创建一次整体作为单例使用 OSSClientBuilder builder new OSSClientBuilder(); OSS ossClient builder.build( endpoint, accessKeyId, accessKeySecret ); // 设置连接池参数 ClientBuilderConfiguration config new ClientBuilderConfiguration(); config.setConnectionMaxIdleTime(1000L); config.setConnectionTimeout(5000);Endpoint 的选择也很关键。如果服务器部署在华东区域Bucket 也建在华东那 SDK 的 Endpoint 就用华东节点的内网地址走内网流量不花钱而且延迟低。不要把公网 Endpoint 用在服务端到服务端的调用上公网链路会引入不必要的抖动和费用。区分方式很简单SDK 文档里一般会给出外网和内网两套域名内网域名通常带internal标识。3. 服务端签名STS 临时凭证的下发流程与接口设计3.1 为什么不能把持久 AK 直接给客户端我见过不少半吊子的直传实现后端接口直接把 AccessKeyId 和 AccessKeySecret 返回给前端让前端拿着这两个值初始化 SDK 去上传。这样做的后果是AK 一旦被薅走别人就可以用你的身份遍历 Bucket 里所有文件甚至覆盖已有对象。而且主 AK 或 RAM 子账号 AK 是长期有效的密钥泄露后即使发现了换掉的成本也会牵扯到所有正在使用它的程序。正确的方式是走 STS。阿里云的 STS 服务允许你用主账号或具备 AssumeRole 权限的子账号临时为另一个身份签发一组 AccessKeyId、AccessKeySecret 和 SecurityToken有效期可以设置为 15 分钟到 12 小时过期自动失效。即便前端把这组凭证泄露出去有效期一到就废了攻击面大大缩小。3.2 后端 AssumeRole 换取临时凭证的完整实现后端签名接口的本质就是把子账号持有的 AK通过 STS 的 AssumeRole 接口换出一组短期凭证再下发给客户端。这里直接给出我的实现思路public MapString, String getStsToken(String userId, String bizType) { // 1. 构造角色会话 AssumeRoleRequest request new AssumeRoleRequest(); request.setRoleArn(roleArn); // RAM 角色 ARN request.setRoleSessionName(userId); // 会话名建议用用户标识 request.setDurationSeconds(900L); // 有效期单位秒15分钟 // 2. 通过策略参数控制该次凭证权限范围 String policy buildUserPolicy(userId, bizType); request.setPolicy(policy); AssumeRoleResponse response stsClient.assumeRole(request); Credentials credentials response.getCredentials(); // 3. 返回给前端 return Map.of( accessKeyId, credentials.getAccessKeyId(), accessKeySecret, credentials.getAccessKeySecret(), securityToken, credentials.getSecurityToken(), expiration, credentials.getExpiration() ); }重点是那个buildUserPolicy它可以在每次签发时动态生成一条临时策略把权限范围进一步收窄。比如某用户只允许往uploads/{userId}/前缀下上传文件不允许读取别人的目录不允许删除任何对象。这样就算多个客户端同时拿到不同用户的临时凭证也只能操作自己的那份空间这就是分布式场景下“租户隔离”在存储层的一种落地方式。3.3 签名接口入参与返回结构设计签名接口本身要做的校验不比上传少。不能设计成一个“谁来了都给凭证”的裸奔接口那样等于开着大门欢迎攻击者。我的入参设计大致包含这些字段字段类型说明userIdString当前登录用户标识从鉴权上下文获取bizTypeString业务类型控制上传到哪个前缀fileNameString原始文件名用于服务端生成最终对象名fileSizeLong文件大小用于限制超大文件contentTypeString文件类型用于合法性校验后端拿到这些参数后依次做校验文件类型黑名单过滤比如禁止可执行文件、大小上限校验比如图片限制 5MB、视频限制 2GB、频控校验用户每分钟申请签名次数不能超过 N 次。全部通过后才调用 STS并把本次授权的对象名、上传有效期一并返回给前端。这里有一个经验之谈对象名的最终决定权一定要放在服务端而不能相信前端传值。比如前端说文件名是../../etc/passwd或者直接给你一个覆盖别人目录的路径如果不做处理就可能在 OSS 上造成路径穿越或越权覆盖。我的做法是后端用 UUID 或雪花 ID 重新生成文件名只保留原文件的扩展名用户原始文件名单独存到业务数据库里不参与存储路径拼接。4. 上传能力的层层拆解简单上传、分片上传、断点续传与秒传4.1 普通文件上传PutObject 的用法与参数拿到临时凭证后客户端就可以直接操作 OSS 了。小文件比如几十 MB 以内的图片最简单的方式就是 PutObject前端 SDK 里的一段典型写法如下import OSS from ali-oss; const client new OSS({ region: oss-cn-hangzhou, accessKeyId: sts.accessKeyId, accessKeySecret: sts.accessKeySecret, stsToken: sts.securityToken, bucket: bucketName }); // 上传文件 await client.put(objectName, file, { Content-Type: file.type, headers: { x-oss-storage-class: Standard, x-oss-server-side-encryption: AES256 } });注意x-oss-server-side-encryption这个头它开启的是服务端加密。文件上传到 OSS 后由 OSS 服务端在落盘时加密读取时自动解密用户无感知。对于包含敏感信息的文件即使 Bucket 被意外公开数据也无法直接被看到属于必要的基础防护。PutObject 适合小文件但它有一个隐含上限单个文件不能超过 5GB而且这个接口是一次性完成的中间断了就要全部重来。所以对于大文件就必须用分片上传。4.2 大文件分片上传MultipartUpload 的核心流程分片上传MultipartUpload的原理是把一个文件切成多个 part分别上传全部上传成功后调用一次 CompleteMultipartUpload 接口让 OSS 合并且生成最终对象。它的优势有几个支持并发上传提升吞吐、支持断点续传、单个分片失败只需要重传那一片。实现流程分成四步调用ossClient.initiateMultipartUpload获取 uploadId。把文件按固定大小切片建议 5MB 到 100MB规则要求每个 part 除了最后一个以外必须大于等于 5MB。用多线程并发上传每个 part记录每个 part 的 ETag 和 PartNumber。所有 part 上传成功后调用completeMultipartUpload把 part 列表一并提交OSS 合并时按 PartNumber 顺序拼接。这里最值得讲的问题是并发度设置。并发太高会让客户端内存暴涨太低又浪费 OSS 的大带宽优势。我的经验是在 Web 端分片并发控制在 3 到 5 比较稳妥在服务端批量迁移场景下并发可以提高到 10 到 20。内存计算公式大概是“并发数 × 分片大小”比如 5 并发 × 20MB 分片峰值内存就是 100MB这个数字要提前评估能否承受。4.3 断点续传与秒传从本地检查点到 MD5 命中断点续传是分片上传的自然延伸。OSS SDK 里提供了断点续传的能力原理是每次上传前把已上传的分片信息和 uploadId 记录在本地 checkpoint 文件里续传时读取该文件跳过已经完成的分片只上传剩余部分。需要注意一个坑断点续传的 checkpoint 文件如果在客户端被清理了续传就无从谈起。另外分片上传如果长时间不调用 Complete 接口OSS 侧会积累大量未完成的 Multipart 记录占用 Bucket 配额所以要定期清理可以通过生命周期规则自动清理超过 N 天的未完成分片。秒传是另一个实际使用频率很高的能力。OSS 本身没有“秒传”接口但我们可以通过文件的唯一标识来模拟客户端在选好文件后先计算文件的 MD5把它作为对象名的一部分上传到 OSS。上传之前先调用 OSS 的 HeadObject 接口查看这个对象是否已经存在如果存在且大小一致就直接认为上传成功跳过整个传输过程。这个方案特别适合“同一用户重复上传同一文件”的场景。比如用户反复上传同一张产品图用 MD5 做标识后第二次上传就是毫秒级返回。注意 MD5 的计算在前端做消耗一定 CPU对于超大文件可以先按文件大小 最后修改时间做一次预判断不匹配再算 MD5。4.4 Web 端直传绕不开的 CORS 与预检请求Web 端用浏览器直传 OSS 时有一个几乎每个人都会踩的配置CORS。浏览器安全策略决定了如果你的站点域名和 OSS Bucket 域名不一致跨域请求默认会被拦截而且文件类型稍复杂一点浏览器还会先发送一个 OPTIONS 预检请求。我见过一个团队在调试时明明上传代码看着没问题浏览器控制台却报一个跨域错误折腾了整整半天最后发现是 Bucket 的 CORS 规则里没有允许ETag这个响应头。这里把经验总结一下CORS 配置项建议取值来源你的站点域名不要写成*加开放允许 MethodsPUT、GET、POST、DELETE、HEAD允许 Headers*或按需列出x-oss-*系列暴露 HeadersETag、x-oss-request-id缓存时间600 秒x-oss-request-id这个头一定要暴露因为后续排查问题时你向技术支持提供的每一个请求 ID 都靠它。我在线上问题复盘时就靠这个定位过几次慢请求。5. 线上踩坑实录从权限冲突到连接池耗尽的完整排障链5.1 第一类故障CORS 配置不当导致浏览器上传全部失败这是最常见、也最容易被误判的一类问题。现象是用 curl 和 Postman 上传都正常浏览器一上传就报Access to XMLHttpRequest ... has been blocked by CORS policy。排查思路不要一上来就改代码。先打开浏览器开发者工具看 Network 面板里的请求如果一个 PUT 请求前面带着一条 OPTIONS 预检请求并且 OPTIONS 返回了 403那基本就是 CORS 规则没配对。定位到具体返回头看是否缺少Access-Control-Allow-Origin或者Access-Control-Allow-Headers里没有包含前端上传时设置的自定义头。我当时的修复方式是重新配置 Bucket 的 CORS 规则来源只添加线上域名和测试域名方法允许 PUT 和 GET暴露头加上ETag。改完大概一分钟后生效刷新页面就正常了。这里要特别建议不要把来源配成*因为*会放开所有站点的跨域访问等于把上传能力开放给任何人风险太大。5.2 第二类故障RAM 策略和 Bucket Policy 的授权冲突另一类诡异问题是临时凭证拿得很顺利但上传时偶尔失败错误信息是AccessDenied而且诡异的是同一个接口在测试环境完全正常到了生产环境就开始出幺蛾子。排查这类问题时不能只盯着 STS 签发的临时策略还要检查 Bucket 本身的 Policy。OSS 的权限判断是“多策略叠加”的结果RAM 用户策略、STS 临时策略、Bucket Policy 等都会参与最终判定如果 Bucket 的私有权限策略里明确写了一条 Deny那即使 STS 策略允许最终结果依然是拒绝。我们的真实案例是运维同事在 Bucket Policy 里加了一条“禁止所有来源的 DeleteObject”规则本意是防止误删结果这条 Deny 策略因为作用范围包含了uploads/*直接把上传后的 CompleteMultipartUpload 也拦下来了。排查过程是先看了 OSS 访问日志发现 Deny 来自 Policy再逐一比对 Bucket Policy 的每一条规则才找到问题。解决方法是把 Deny 策略的作用范围从整个 Bucket 缩小到某个不需要写操作的特殊前缀并在规则备注里写明意图。5.3 第三类故障OSSClient 连接池耗尽与文件句柄泄漏连接池耗尽这类问题通常在服务端做批量迁移或者中转时出现。现象很典型刚开始上传正常运行几个小时后偶发超时再过一段时间直接大面积Connection pool is exhausted。排查链路是这样的先看进程内 OSSClient 实例数如果发现了上百个 OSSClient 对象基本就是“每次上传都 new 一个客户端”的典型误用。OSSClient 内部的连接池默认连接数是有限制的每 new 一个实例就等于额外创建一组连接池连接不开释放最终把系统资源全部耗尽。解决方案分两步把 OSSClient 改成单例全局共享一个实例同时给连接池设置合理的空闲回收时间。我这边最终参数是maxConnections200、idleConnectionTime60000ms改完后再观察连接池曲线非常平稳。5.4 第四类坑分片大小低于 5MB 引发的隐性问题这个坑在文档里有明确说明但还是很容易踩。OSS 规定 MultipartUpload 的每个 Part 除了最后一个以外大小必须大于等于 5MB。如果你的分片逻辑把 10MB 的文件分成了 10 个 1MB 的分片那除了最后一片前面 9 片全部会报错。更隐蔽的是有的代码在处理“最后一个分片”时没注意它可能是唯一的分片。如果整个文件本身就小于 5MB理论上根本不需要走分片上传直接 PutObject 就行。所以我现在的逻辑是文件小于 80MB 直接简单上传大于 80MB 才走分片分片大小取 20MB既避开了 5MB 限制又不会产生太多分片导致 Complete 请求体过大。文件大小上传方式分片大小说明 80MB简单上传-减少不必要的复杂度80MB ~ 1GB分片上传20MB并发 3~5 1GB分片上传50MB并发适当提高到 8~106. 线上运维与体验优化限流、安全与生命周期管理6.1 签名接口的限流与上传异常监控文件上传链路稳定运行后新的问题变成了“如何不让个别人把上传通道当成无限存储用”。毕竟客户端直传 OSS 后文件内容不再经过业务服务服务端对“谁传了什么”的感知能力变弱了更需要在签名入口做限流和审计。我的做法有三层第一层签名接口做用户维度的频控比如每分钟最多申请 60 次签名、每天最多上传 200 个文件超出直接拒绝。第二层对临时凭证的 policy 里加上上传文件大小的硬限制超过 2GB 的请求直接由 OSS 侧拒绝而不是等到传输了一半才发现。第三层开启 OSS 的访问日志记录把x-oss-request-id、对象名、用户 ID 关联起来每天定时分析看是否有异常上传行为。这里提一个容易忽略的点OSS 的日志默认是不开启的需要手动开启并指定日志保存的 Bucket。这个成本很低但价值很高出了事故没有日志就是“盲人摸象”。6.2 目录生命周期管理与冷热分层上传到 OSS 的文件不会一直存在很多都是临时文件。比如用户上传完成但最终没有提交订单那张商品图就成了孤儿数据。如果不管它Bucket 会以肉眼可见的速度膨胀存储费用逐月爬升。我的做法是给不同前缀的目录设置不同的生命周期规则目录前缀生命周期策略uploads/temp/创建 1 天后自动删除uploads/avatar/永久保留但 90 天后转为低频访问uploads/archive/30 天后转为归档存储180 天后删除生命周期规则在控制台上配置即可也可以在 SDK 中通过BucketLifecycleRule设置。注意规则是按前缀匹配的所以上文中“目录规划”那一节提到的层级设计在这里就体现出价值了——如果不分层你就没法对不同类型的文件实施不同的保存策略只能一锅端地“全删”或“全留”。6.3 后续演进上传状态闭环与多活容灾最后聊聊我在这套方案后续迭代中打算做的两个方向。第一个是上传状态闭环。直传方案下服务端不知道文件什么时候传完所以我会在前端上传完成后回调一个业务接口通知后端“文件已上传对象名是什么MD5 是什么”后端再在业务数据库里落地这条文件的元数据。更进一步可以开启 OSS 的事件通知通过消息队列把上传完成事件推送给下游自动触发图片处理、内容审核等任务这套事件驱动的链路很适合做文件处理中台。第二个是多活容灾。如果业务要求跨地域容灾可以在 OSS 的 Bucket 上配置跨区域复制把uploads/前缀下的文件实时同步到另一个地域的 Bucket。客户端上传写入主地域正常情况下读取走主地域主地域不可用时读取切到备地域实现存储层的高可用。这个配置是异步的有短暂延迟但绝大多数业务场景都能接受。经验就分享到这里最核心的一条还是把文件数据流和应用业务流彻底分开让 OSS 承担存储和专业负载能力让应用服务专注于权限控制和状态管理。按这个思路去做分布式文件上传从架构上就立于不败之地剩下的就是各个细节的打磨了。