2026.08.14
头像上传从 3.58MB 到 57KB:预签名直传与端侧压缩
一张手机原图 3.58MB,而它要显示的地方只有 96px。这篇记录怎么让图片流量完全不经过应用服务器,并且在上传前就把它压到 57KB。
头像这个需求看起来小,但它同时踩了三个坑:文件太大、流量过服务器、上传失败率。
问题一:一张原图 3.58MB#
手机相机的原图动辄三四兆,而头像的显示尺寸是 96px(2x 屏 192px)。把 3.58MB 传上去再缩小显示,是纯粹的资源浪费——用户的流量、服务器的带宽、CDN 的存储,全都在为不会显示出来的像素付费。
端侧先压:
async function compress(file: File, maxEdge = 512, quality = 0.82) {
const bitmap = await createImageBitmap(file);
// 按最长边等比缩放,不裁切(让服务端决定裁切策略)
const scale = Math.min(1, maxEdge / Math.max(bitmap.width, bitmap.height));
const w = Math.round(bitmap.width * scale);
const h = Math.round(bitmap.height * scale);
const canvas = new OffscreenCanvas(w, h);
const ctx = canvas.getContext("2d")!;
ctx.drawImage(bitmap, 0, 0, w, h);
bitmap.close();
// WebP 优先,浏览器不支持时回落 JPEG
const blob = await canvas.convertToBlob({ type: "image/webp", quality });
return blob;
}用 createImageBitmap + OffscreenCanvas 而不是 new Image() + <canvas>:前者可以在 Worker 里跑,不阻塞主线程;后者必须在主线程,一张 3.58MB 的图解码会造成肉眼可见的卡顿。
实测:3.58MB → 57KB(−98.4%),同参数等价验证过(同样 512px 最长边、同样质量系数,压缩前后视觉无差异)。
问题二:流量不该过应用服务器#
传统做法是 客户端 → 应用服务器 → OSS。这条链路的问题在于应用服务器成了纯粹的转发器——它不处理图片内容,却要承担全部上传带宽。并发一上来,Node 进程先被 I/O 拖死。
预签名直传把中间那一跳去掉:
客户端 ──① 要一个签名──→ 应用服务器
客户端 ←─② 返回带签名的 URL ──┘
客户端 ──③ 直接 PUT 到 OSS ──→ OSS
应用服务器只做一件事:签发一个有时效、有大小和类型限制的 URL。它不碰文件内容,也就不会成为带宽瓶颈。
// 应用服务器侧
export async function getUploadTicket(userId: string, mime: string, size: number) {
if (!/^image\/(webp|jpeg|png)$/.test(mime)) {
throw new Error("不支持的图片类型");
}
if (size > 2 * 1024 * 1024) {
throw new Error("图片过大");
}
return oss.signPutObject({
// 路径里带 userId —— 这是行级隔离的第一道,不能只靠前端
key: `avatar/${userId}/${nanoid()}.webp`,
expiresIn: 300, // 5 分钟
contentLengthRange: [1, 2 * 1024 * 1024],
contentType: mime,
});
}三个约束都不能省:时效(5 分钟)、类型白名单、大小上限。少了任何一个,签出来的就是一扇没有门的入口。
问题三:上传失败率的来源#
直传之后失败率反而更需要关注,因为客户端要自己处理重试。两种情况要分开:
- 签名过期(用户磨蹭了 5 分钟以上)→ 重新签,然后重试。这是可恢复的。
- 网络中断(大文件传到一半)→ 需要断点续传或整体重试。
我在客户端做了"换新签名再重试一次",失败就明确报错给用户。不做无限重试——无限重试会让用户面对一个永远转圈的界面,还不如直接告诉他失败了可以再点一次。
结果#
| 指标 | 改造前 | 改造后 |
|---|---|---|
| 单图体积 | 3.58 MB | 57 KB(−98.4%) |
| 上传流量 | 经过应用服务器 | 不经过应用服务器 |
| 显示尺寸 | 原图缩放显示 | 512px 长边,按需取 |
顺带一个收益:因为端侧已经把图压到 512px,服务端不再需要跑一遍图片处理(不需要 sharp 之类的重依赖),部署镜像也小了一圈。
回头看,这件事的关键判断是:"上传"和"处理"应该分开。让最接近数据的一方(客户端)做压缩,让最擅长存的一方(OSS)存文件,应用服务器只保留它真正擅长的那部分——鉴权与策略。