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 MB57 KB(−98.4%)
上传流量经过应用服务器不经过应用服务器
显示尺寸原图缩放显示512px 长边,按需取

顺带一个收益:因为端侧已经把图压到 512px,服务端不再需要跑一遍图片处理(不需要 sharp 之类的重依赖),部署镜像也小了一圈。

回头看,这件事的关键判断是:"上传"和"处理"应该分开。让最接近数据的一方(客户端)做压缩,让最擅长存的一方(OSS)存文件,应用服务器只保留它真正擅长的那部分——鉴权与策略。