2.1 直传设计:客户端绕过后端
场景:一秒上传的小文件,为什么等了十秒
直传不是我一上来就想用的,是被用户教出来的。网盘内测时,有人传一张 3MB 的截图,点击上传到看到结果要等十秒——明明是内网级别的快。抓包发现:文件先从客户端走到后端,后端写本地磁盘,再从本地读到 COS,一来一回走了三程。客户端和 COS 之间的网络明明很通畅,却非要绕我家后端这台小水管。
三程变一程,是直传的全部动机:客户端拿到凭证后直接 PUT 到 COS,后端只在开始和结束各出现一次。除了快,还顺带解决了三个问题:后端不占带宽、不占磁盘、不需要处理超时重试。上传的可靠性责任交给 COS 的 SDK,人家写得比我好。
方案:三种上传路径的取舍
选型时我比较过三条路,结论是简单文件用直传,大文件用分片直传,后端中转只保留一个特殊场景:
| 方案 | 耗时(5MB 小文件) | 后端负载 | 适用场景 |
|---|---|---|---|
| 后端中转 | 三程,约 10 秒 | 高,占带宽占磁盘 | 需要服务端预处理的场景 |
| 客户端直传 | 一程,约 2 秒 | 极低,只发凭证 | 绝大多数文件 |
| 表单 POST 上传 | 一程,约 2 秒 | 极低 | 页面表单,无 JS 环境限制 |
再比较两种直传协议。COS 对外提供两种上传接口,选错会埋坑:
| 接口 | 语义 | 我们的选择 |
|---|---|---|
| PUT Object | 整体上传一个对象 | 小于 20MB 的文件 |
| Multipart Upload | 分片初始化、上传、完成 | 大于 20MB 的文件 |
为什么 20MB 是分界线?太小文件走分片得不偿失,初始化和完成各一次请求比传文件本身还慢;太大大文件走 PUT,浏览器内存和重传成本都高。20MB 是我们压测后选的经验值,你们的数字可以自己压。
配置:后端签发,客户端直发
后端只做一件事:检查登录、签发带 scoped 权限的凭证。客户端拿到凭证后自己组装请求:
// 后端:签发直传凭证,scope 锁定到对象键
func (s *CosStore) SignedUpload(ctx context.Context, key string, size int64) (*Upload, error) {
opt := &cos.Permission{
Scope: []cos.ScopeItem{{
Action: "name/cos:PutObject",
Bucket: s.bucket, Region: s.region,
Prefix: key,
}},
}
cred, err := s.sts.GetCredential(ctx, opt, 900) // 15 分钟
if err != nil {
return nil, err
}
return &Upload{URL: s.objectURL(key), Token: cred.SessionToken}, nil
}
客户端这边直接 PUT 字节流,证书头带上后端给的 session token,不需要任何 SDK 也行得通:
// 客户端:把分片或整文件 PUT 到 COS
req, _ := http.NewRequest(http.MethodPut, up.URL, file)
req.Header.Set("x-cos-security-token", up.Token)
req.Header.Set("Content-Type", "application/octet-stream")
resp, err := http.DefaultClient.Do(req)
// 2xx 即成功,失败按状态码决定重试策略
这套走通之后,后端的 CPU 曲线再也没随上传量波动过,流量计费也从后端公网带宽挪到了 COS 的按量计费上,成本明细在 5.4 成本复盘里。
踩坑:直传的四个暗坑
第一个坑是跨域。浏览器直传 COS 必须在桶上配 CORS,方法要包含 PUT、POST、GET、DELETE,还要放行 x-cos-security-token 这类自定义头。漏一个头,错误信息藏得很深,只在浏览器控制台里。
第二个坑是端口与协议。COS 的域名默认走 https,有同事在本地调试时用了 http 的回源域名,证书校验不过,报错却指向 CORS,全家白配了三小时。跨域和证书报错长得像,先看 Network 面板的真实状态码再下结论。
第三个坑是整文件 PUT 的内存。大文件一旦超过阈值还走 PUT,浏览器要把整个文件读进内存构造请求体,2GB 文件直接让标签页崩溃。前端必须按大小分流:超阈值自动切换到分片上传,用户无感知。
第四个坑是完成回调。客户端 PUT 成功不等于系统知道这件事成功,必须显式调一次后端的完成接口记账。有用户传完就关页面,回调没发,文件成了孤儿。兜底用 SCF 监听 COS 事件对账,这套机制在 5.1 云函数回调里展开。
再展开讲讲直传带来的一个副产物:上传成功率的提升。改直传之前,我们的上传成功率在 92% 上下浮动,失败几乎全在后端中转那一程:超时、磁盘满、网关限流。直传之后成功率稳定在 99% 以上,因为整个链路里只剩客户端到 COS 一段,而 COS 的可用性比我那台小服务器高得多。这个数字后来被我们写进对外材料里,说服了另一个团队也改直传。
还有一类特殊场景我们保留了后端中转,就是需要服务端预处理的文件。比如早期没有接入 CI 时,上传的图片要服务端压缩,当时只能走中转。后来全部改成 SCF 事件驱动,中转接口就彻底下线了。留个心眼:任何架构决策都注明它成立的前提,前提消失时决策也要跟着动,代码里的注释至少要说明为什么这么干。
最后给一个排错小工具的思路:直传失败时,把客户端的完整请求头(脱敏后)和服务端签名串一起上报,绝大多数鉴权问题对着两份数据一对比就能定位。我们做了一周统计,直传相关工单下降了七成,运维同志印象深刻。 说回协议层面的选择细节。PUT 直传要求客户端能完整构造 HTTP 请求,早期我们担心浏览器兼容性,实测下来 fetch 加 PUT 在所有主流浏览器都没问题,唯一麻烦的是进度条——原生 fetch 拿不到上传进度,最后用 XMLHttpRequest 包了一层。就为这一个进度条,客户端多写了五十行代码,但用户对网盘的耐心完全取决于进度条是否存在,没有进度条的上传就等于没在做。
再补一个观察:小文件直传的优化天花板在别处。把 3MB 的图从 10 秒优化到 2 秒之后,用户依然觉得慢,因为剩下的是签名握手与页面渲染的时间。后来我们做了两件事,一是登录后预签发一批通用凭证存客户端备用,二是上传按钮按下时立刻显示进度条假装在动。前者真省了一次往返,后者纯属心理战术,两者一起上,体感又快了一截。 补充一个直传迁移过程中的人际经验。推动直传最难的从来不是技术,而是说服团队相信后端不再碰文件是安全的。我把一次压测数据贴在群里:同一条链路,中转模式后端 CPU 长期 60%,直传模式降到 8%,而上传速度快三倍。数字面前争论当晚就结束了。技术选型要多用压测报告说话,少用直觉和资历说话,这一条经验我后来推广到了所有方案评审里。
另外一个常被忽略的收尾动作:直传链路里残留的孤儿对象。用户传了一半注销了,分片残留在 COS 里,每月账单上它们沉默地占着位置。我们写了个 SCF 定时任务,扫描七十二小时未完成的分片上传就清理,每月能省下可观的一笔小钱。存储便宜是按 GB 算的,垃圾多了照样肉疼。 写到这里,关于直传还有最后一个决策点没聊:上传失败后的重试策略。我们的策略分三层:单个分片失败指数退避重试三次;整体上传失败保留进度由用户手动点重试;网络完全离线则进入暂停队列等恢复。关键是不要在失败后立刻自动重新全量上传,那种沉默的流量消耗会让移动用户骂街。把控制权交给用户,同时保管好进度,剩下的不用操心。
顺带给个调试技巧。客户端直传报错信息往往很简陋,403 SignatureDoesNotMatch 是常客。遇到它不要瞎猜,第一步对比客户端发 header 与 STS 下发参数的大小写,第二步检查域名与 region 是否匹配,第三步看时间漂移。90% 的签名错误在这三步里,剩下 10% 是 scope 写错。
直传方案上线后我们抓了一周包做验证,发现少量请求仍然经过后端中转——是某旧版客户端没升级。客户端版本灰度与后端兼容判断同时推进,两周后全量请求都走直传,后端公网带宽峰值降了九成。
CORS 配置是直传落地最容易卡住的一步。浏览器跨域 PUT 到 COS,需要的响应头一个不能少,预检请求还要返回正确的允许方法。我们整理了一份校验清单,从此再没在跨域上耽误过排期。
小结
直传的本质是把最重的字节流从后端身上卸下来。一程替三程,快了、省了、也稳了。代价是权限必须交给临时密钥来保障——这正是下一节的主题。
