图片上传全链路实战:浏览器压缩、COS 直传与服务端处理

悦目图库

这个项目做上传链路的第一天,摆在我面前的是一道算术题:服务器出口带宽只有 5Mbps,用户传一张 24MB 的照片,走“浏览器 → 你的服务器 → 对象存储”这条最朴素的路线,一张图就要把出口带宽占满整整三十八秒。十个用户同时传,服务器直接卡成幻灯片,所有接口跟着遭殃,连你自己刷新后台都转半天。

所以这个项目的上传链路,从第一天起就没走那条朴素路线,而是:

浏览器压缩 → 后端签发凭证 → 客户端直传 COS → 后端确认处理

后端在整个过程里几乎不碰图片文件本身,它只做两件事:发一张 30 秒的“通行证”,然后在图片落进对象存储之后去做收尾处理。这篇文章就把这条链路从选文件到落库,每一环的代码都翻出来讲透。代码都是真实跑在生产上的,我尽量贴着原文讲,不给你看删改过的“教学版”。

一、先画一张图:一条链路,四步棋

先记住四个动作,后面每一节都是其中一个动作的展开:

  1. 前端压缩:选完文件,先在浏览器里压一遍,目标 ≤5MB;
  2. 签发凭证:前端把文件名、大小报给后端,后端返回一个 30 秒有效的 COS 预签名 PUT URL;
  3. 直传 COS:前端拿着这个 URL,用 XMLHttpRequest 直接把文件 PUT 到腾讯云对象存储,全程不经过后端;
  4. 确认处理:传完,前端再调一次后端,后端对 COS 里刚落地的对象发起图片处理:转 WebP、嵌盲水印、出缩略图,最后落数据库。

Upload Pipeline

整条链路落到接口上,其实只有两个,在接口文档里长这样:

POST /picture/upload/token
  请求: { fileName, fileSize, spaceId, pictureId, isPost }
  响应: { cosKey, presignedUrl, expireTime }   // 30秒有效

POST /picture/upload/confirm
  请求: { cosKey, pictureId, picName, tagName, categoryName, spaceId,
          introduction, picSize, picWidth, picHeight, picScale,
          picFormat, picColor, thumbnailKey, isPost }
  响应: PictureVO   // 落库后的完整图片信息

前端只做两件事:要凭证,传完喊 confirm。中间那段最重的传输,由浏览器和 COS 之间直接完成。

前端图片上传页面 ▲ 图:前端图片上传页面(AddPicturePage / PictureUpload,含压缩与全局上传进度)

为什么执着于“直传”而不是“后端转发”?核心就两个字:带宽。对象存储的出口带宽是云厂商替你扛的,而你的服务器带宽是花真金白银买的,而且根本扛不住。图片这类大文件,每一次“经手服务器”都是在烧钱和烧性能。直传之后,服务器只处理几百字节的 JSON,这个账怎么算都划算。

当然,直传不是银弹。小文件、必须要服务器先落一层再处理的场景、或者你根本用不起对象存储,后端转发依然是合理方案。直传换来的带宽和时延,代价是牺牲掉“文件先经过我们”这件事——意味着你没法在落库前对文件内容做任何服务端检查。所以架构选型本质是权衡:把带宽省下来,把检查往后推。这页后面会看到,敏感词过滤、图片处理这些检查,全都放在 confirm 之后去做。

二、前端压缩:我们自己写,不引压缩库

package.json 里其实躺着 compressorjs、browser-image-compression 这些现成库,但最终主上传链路用的是 src/utils/uploadUtil.ts 里自己写的 compressImage。为什么?因为我们需要的是“压到某个确定体积以下”,而不是“压到什么质量”,这俩目标完全不同——库通常给你一个 quality 参数让你猜,我们想要的是一句“给我 ≤5MB”。

先看这个函数的骨架。小文件直接放行,大文件才动手:

const MAX_COMPRESSED_SIZE = 5 * 1024 * 1024
const MAX_WIDTH = 2560
const MAX_HEIGHT = 1440

export async function compressImage(file, maxBytes = MAX_COMPRESSED_SIZE) {
  // 已经足够小,直接返回,别折腾
  if (file.size <= maxBytes) return file

  const supportsWebP = await checkWebPSupport()
  const targetFormat = supportsWebP ? 'image/webp' : 'image/jpeg'
  const targetQuality = targetFormat === 'image/webp' ? 0.85 : 0.92
  // ... FileReader → Image → canvas
}

有个细节值得一提:WebP 支持是运行时探测的,不是写死的。checkWebPSupport() 用一张 base64 的 1x1 WebP 图去喂给 new Image(),能解码就说明浏览器支持 WebP,然后用 WebP + 0.85 质量;不支持就退回 JPEG + 0.92。为什么这么较真?因为 WebP 在同样观感下体积能小 30% 左右,而老 Safari 不支持它,宁可牺牲一点体积也不能传一张破图上去。

真正的重头戏是压缩算法的第二步——先按体积比缩放,再用二分搜索逼近目标大小

// 先按"体积比开根号"整体缩放,粗调一把
const scale = Math.min(1, Math.sqrt(maxBytes / file.size))
let w = Math.round(img.width * scale)
let h = Math.round(img.height * scale)

// 再限制最大尺寸
if (w > MAX_WIDTH || h > MAX_HEIGHT) {
  if (aspect > 1) { w = Math.min(w, MAX_WIDTH); h = Math.round(w / aspect) }
  else { h = Math.min(h, MAX_HEIGHT); w = Math.round(h * aspect) }
}

// 二分搜索质量:在 [0.5, targetQuality] 之间反复 toBlob
const compress = (min, max, attempt) => {
  if (attempt > 8 || max - min < 0.01) {
    canvas.toBlob(b => b ? resolve(b) : reject(), targetFormat, min)
    return
  }
  const mid = (min + max) / 2
  canvas.toBlob(b => {
    if (!b) return reject()
    if (Math.abs(b.size - maxBytes) < maxBytes * 0.05) resolve(b)   // 够了
    else if (b.size > maxBytes) compress(min, mid, attempt + 1)      // 太大,往低质量走
    else compress(mid, max, attempt + 1)                             // 太小,往高质量回
  }, targetFormat, mid)
}

Binary Search Compression

这个二分很有意思。canvas.toBlob 不是同步的,所以这里其实是“异步二分”:每次取质量区间的中点,toBlob 出当前质量的体积,看它离目标 5MB 差多少,大了就砍质量区间,小了就往回抬,最多递归八层。之所以能收敛,是因为在同一个 canvas、同一种格式下,质量参数和输出体积基本是单调关系——质量高体积大,质量低体积小。这个单调性就是二分的前提。

我见过很多人用“固定 quality=0.8”一刀切,结果 24MB 的图压完还有 12MB,或者 500KB 的小图被压到糊。二分搜索的好处是不看原始大小,只看目标体积,无论来的是 3MB 还是 24MB,出来的都贴着 5MB 这条线。

还有一处很鸡贼:转 JPEG 时先 ctx.fillStyle = '#FFFFFF'; ctx.fillRect(...) 把背景填白。因为 JPEG 不支持透明,如果不填白,PNG 的透明区域会变成黑色块。这行代码看着不起眼,但漏了它,一堆带透明通道的图会瞬间变丑。

前端压完这一刀,后端在 COS 上还会再压一刀(第五节讲),两道关口,各管各的:前端管“别让大文件上路”,COS 管“存储里的最终形态”。

压缩还有两个上限值得单独说:MAX_WIDTH=2560、MAX_HEIGHT=1440。为什么是这两个数?2560 是很多 2K 屏幕的宽度,超过它再往上,观感几乎无差,体积却涨得飞快;1440 的高是给长图留的余地。这个取舍背后是“看图场景”——列表页要的是能看清楚的缩略图,详情页要的是不糊的大图,两头都够用,就是不上 4K。

项目里其实还有第二个压缩器,供聊天、扫图、批量等场景使用。它走的是另一条路:固定质量从 0.85 开始逐档往下减,直到体积达标或质量低于 0.5。两条路对比很有意思:逐档降质量实现简单,但可能多压几次才达标,对本来就不大的图有点浪费;二分搜索收敛更快,但它依赖“质量-体积单调”这个前提,而这个单调性在 Canvas 的 toBlob 里基本成立。所以主上传用二分,辅助场景用逐档,各取所需。

三、签发凭证:后端只给 30 秒的信任

文件压缩好了,前端开始要凭证。请求 POST /picture/upload/token,带着文件名和大小。后端先做一轮校验,再生成 COS 的 key 和预签名 URL。

校验部分有一堆细节值得说。文件大小上限是 20MB——注意,这个上限是原始文件的上限,前端压缩到 5MB 是“上路目标”,后端这道 20MB 是“防绕过”的底线。你要是绕过前端直接调接口传个 200MB,后端直接拒。格式白名单是 jpg/jpeg/png/webp,加上一堆音频格式——是的,这条上传通道不止管图片,音频也走同一个凭证体系。

然后是生成 cosKey。key 的路径规则直接决定了文件在 COS 里的组织方式,也隐隐是权限的雏形:

String prefix;
if (spaceId != null && spaceId > 0) {
    prefix = String.format("space/%s", spaceId);          // 用户空间
} else if ((spaceId != null && spaceId == -1L) || (isPost != null && isPost)) {
    prefix = String.format("post/%s", loginUser.getId()); // 帖子/聊天图
} else {
    prefix = String.format("public/%s", loginUser.getId()); // 公共图
}
String uuid = RandomUtil.randomString(16);
String date = cn.hutool.core.date.DateUtil.formatDate(new java.util.Date());
String cosKey = String.format("%s/%s_%s.%s", prefix, date, uuid, ext);

space/{id}post/{userId}public/{userId} 三套前缀,把“谁传的、传到哪类内容”都编码进了路径里。date + 随机16位 保证 key 几乎不可能碰撞,也天然按天分目录,方便日后生命周期管理和清理。

然后是这张 30 秒的“通行证”——预签名 PUT URL:

public String generatePresignedPutUrl(String key, int expireSeconds) {
    java.util.Date expiration = new java.util.Date(System.currentTimeMillis() + expireSeconds * 1000L);
    GeneratePresignedUrlRequest req = new GeneratePresignedUrlRequest(bucket, key, HttpMethodName.PUT);
    req.setExpiration(expiration);
    return cosClient.generatePresignedUrl(req).toString();
}

预签名 URL 是什么?说穿了就是“把签名放进 URL 里”。COS 的鉴权本来靠 Authorization 请求头,后端拿着 SecretKey 把“方法 + 路径 + 过期时间”签成一段字符串拼进 URL,前端拿到这个 URL 就能直接 PUT,完全不需要知道 SecretKey,也不知道 SecretId。有效期 30 秒,过期作废。

这步的设计逻辑是:SecretKey 永远只存在于后端,前端拿到的永远是一张“限时、限对象、限动作”的凭证。你就算把预签名 URL 截走,它 30 秒后自己就废了,而且只能 PUT 那一个 key。这就是对象存储直传的标准姿势,比把 SecretId/SecretKey 塞进前端配置里安全一个量级。

为什么有效期是 30 秒而不是十分钟?因为这张凭证是“一次直传的额度”:它的全部价值,就是在这 30 秒内、对一个特定 key、执行一次 PUT。凭证本身是免费签发的,真正烧钱的是直传那一步,所以把凭证生命周期压短,纯粹是为了减少“被截获后可利用的时间窗”。配合第六节的限流,就凑成两条保险:凭证尽量短命,接口尽量限量。

腾讯云 COS 存储桶控制台 ▲ 图:腾讯云 COS 存储桶控制台——文件按 public/、space/、post/ 前缀分目录存放

四、前端直传 COS:手写 XHR PUT

拿到凭证,前端开始直传。我做了一个反直觉的选择:没有引腾讯云的 cos-js-sdk-v5,而是用 XMLHttpRequest 手写了一个 PUT。原因很朴素——预签名 URL 本质就是一个带签名的 PUT 地址,而浏览器发 PUT 只需要几行代码,何必为了一个 PUT 引入一个大几千行的 SDK?

Direct Upload vs Proxy

const xhr = new XMLHttpRequest()
xhr.open('PUT', presignedUrl)
xhr.setRequestHeader('Content-Type', uploadFile.type || 'image/jpeg')
xhr.upload.onprogress = (e) => {
  if (e.lengthComputable) {
    const percent = Math.round((e.loaded * 100) / e.total)
    updateProgress(percent)
  }
}
xhr.onload = () =>
  (xhr.status >= 200 && xhr.status < 300)
    ? resolve()
    : reject(new Error('COS ' + xhr.status))
xhr.onerror = () => reject(new Error('COS upload failed'))
xhr.send(uploadFile)

这个 xhr.upload.onprogress 是整条链路上唯一能拿到真实上传进度的地方。进度不是模拟的,是浏览器底层上报的字节数。

进度拿到之后去哪了?进了一个全局单例 useUploadProgress。它是个模块级变量,全站共享一份,谁触发上传都能驱动 App.vue 最顶层那个“上传胶囊”——一个悬浮在页面角落的小组件,状态机是 idle → uploading → confirming → done / error。你上传 5 张图也好,AI 生成图片也罢,任何上传动作都会唤醒同一个胶囊。这个设计有个隐性好处:上传进行到一半,用户切去别的页面,进度条还在——因为状态挂在模块里,不在某个组件的本地 data 里。

直传这一步还有一类特别难排查的错:预签名过期返回 403。前端 PUT 收到 403,正确的处理是重新走一遍“要凭证 → PUT”,而不是直接报错让用户重选文件——文件已经压好了,重传只差一张凭证。我们后来把“凭证失败自动重取一次”加进去,这类偶发 403 的用户可见率就降到了接近零。教训是:网络层的偶发错误,能自动重试的就别甩给用户。

全局上传进度胶囊 ▲ 图:全站共用的上传进度胶囊——上传中 / 确认中 / 完成 / 失败

直传完成,前端立刻调 POST /picture/upload/confirm,把 cosKey 交回去。注意顺序:必须等 PUT 成功再 confirm,confirm 是“宣告我传完了,请后端接盘处理”的信号。

五、确认上传:COS 一条命令,处理三件事

到这里,后端才开始真正“接盘”。confirmUpload 里有个分支:如果请求带了 AI 生成的元数据(宽、高都传了,hasAiMeta),说明图片是 AI 服务直接生成的、已经在别处处理好了,后端直接跳过图片处理,省一笔 COS 处理费;否则走正常处理。

顺带说下那个分支——AI 生成的图片走的是另一条更省的路:AI 服务在生成时就已经把图片处理好、元数据带上了,confirm 时直接把宽高、比例、缩略图 key 一起传过来,后端连 COS 处理都不调用,直接落库。为什么不统一走 COS 处理?因为 AI 图是程序生成的,尺寸可控、质量可控,再让它过一遍 WebP 转码和缩略图,纯属浪费 COS 的处理费。这个分支看着只有几行,其实是“能省则省”的运维哲学在代码里的落点。

confirm 开头还有两件容易被忽略的事:一是校验目标空间是否还有容量,满了直接拒;二是处理“重新上传替换”——pictureId 传过来时,要拿旧图的空间做一致性校验,防止用户往别人的空间里替换。这些校验看着琐碎,但正是它们把“直传”这个看起来没人管的流程,重新拉回到权限的轨道上。

正常处理是整条链最精彩的一段,就一行调用:

String watermarkText = "@" + loginUser.getUserName() + " | yuemutuku.com";
String xml = cosManager.processUploadedImage(req.getCosKey(), watermarkText,
        cosManager.getQualityForFileSize(1024 * 1024));
cosResult = CosManager.parseImageProcessResult(xml);

这一行背后,COS 干了三件事:转 WebP、嵌盲水印、出缩略图。靠的是 COS 的“持久化图片处理”能力——你 POST 一个对象,附上一份 Pic-Operations 规则 JSON,COS 处理完把产物写回同一个桶。规则长这样:

{
  "is_pic_info": 1,
  "rules": [
    { "fileid": "xxx.webp",
      "rule": "imageMogr2/format/webp/quality/90|watermark/3/type/3/text/{base64}/version/3.0" },
    { "fileid": "xxx_thumbnail.webp",
      "rule": "imageMogr2/thumbnail/1024x1024>/format/webp/quality/90" }
  ]
}

COS Cloud Processing

第一条规则把原图转成 WebP,质量按传入值,后面用管道符 | 串上一个盲水印规则;第二条生成一个 1024x1024 以内、无水印的缩略图。一次请求,两个产物,这就是“别把图下载回服务器再处理”的威力——处理跑在云上,你的服务器连图都没看过一眼。

盲水印是这一段的隐藏主角。所谓盲水印,就是人眼看不出、但程序能提取出来的水印,COS 支持文字盲水印 v3.0。规则里的 text 是 base64 编码的水印文字:

String base64 = Base64.getUrlEncoder().withoutPadding()
        .encodeToString(watermarkText.getBytes(StandardCharsets.UTF_8));
return String.format("|watermark/3/type/3/text/%s/version/3.0", base64);

水印文字是“@用户名 | yuemutuku.com”,每个上传者都带自己的身份标识。将来发现盗图,提取盲水印就能溯源到是哪个用户传的。这个项目的版权理念(本站所有图都有 CC 协议的讲究)在这一行代码里落地了。

还有个细节:压缩质量是动态的getQualityForFileSize 按文件大小分档:

public int getQualityForFileSize(long fileSize) {
    if (fileSize < 500 * 1024) return 95;   // 小于500KB,高质量
    else if (fileSize < 1024 * 1024) return 92;
    else if (fileSize < 2 * 1024 * 1024) return 90;
    else if (fileSize < 5 * 1024 * 1024) return 85;
    else return 80;                          // 5MB以上,适中的质量
}

为什么按大小而不是固定质量?因为质量只是手段,体积才是目的。一张本来就小的图,用高质量保留细节;一张大图,稍微压狠一点换体积,观感几乎无差。这个函数体现的思路和前端二分搜索是一脉相承的:盯着体积说话,别盯着质量说话

处理完,COS 返回一段 XML,parseImageProcessResult 从中抠出宽、高、格式、大小和平均色。平均色这一项很有意思,它被算成“主色调”存进数据库,列表页可以根据主色调给图片打底色,让网格看起来色彩和谐——前端没多做一次请求,数据是上传时就顺带算好的。

COS 图片处理产物 ▲ 图:COS 对象里处理后的两个产物——xxx.webp 与 xxx_thumbnail.webp

六、每一环都挂着一道闸:Bucket4j 限流

这条链路上每一个后端接口,都挂着一个限流注解。拿获取凭证举例:

@BucketRateLimit(key = "picture_upload_token", dimension = RateLimitDimension.USER,
        permitsPerMinute = 30, permitsPerHour = 300, message = "获取上传凭证过于频繁,请稍后再试")
public BaseResponse<UploadTokenVO> getUploadToken(...)

每用户每分钟 30 次、每小时 300 次。确认上传同样是这个额度。为什么上传要限流?因为COS 的每一笔存储都是钱——直传虽然不花服务器带宽,但花的是对象存储的容量和请求次数。不限流的话,一个脚本刷子就能在十分钟里给你造出一仓库垃圾图,账单先把你吓死。

实现用的是 Bucket4j,一个 Java 的令牌桶限流库。切面里先放行管理员,再按“注解的 key + 维度”拼出限流键,从 Redis 里取桶,tryConsume(1) 成功就放行,失败就抛“请求过于频繁”。这里维度是 USER,意味着按登录用户隔离——一个用户被限,不影响别人。换成 IP 维度(搜索接口就是按 IP 限的),就是按来源 IP 隔离。

限流桶存的是 Redis,不是本地内存——因为后端是集群部署的,本地桶的话,每个实例各限各的,总配额就悄悄翻了 N 倍;放 Redis 才能让“每用户每分钟 30 次”在全集群真正生效。代价是多一次 Redis 往返,但对上传这种低频接口完全可以接受。这也是分布式限流和单机限流最本质的区别:单机看内存,集群看共享存储。

更有意思的是,这个切面还接了 abuse 风控:rateLimitService.getThrottleMultiplier(key) 会返回一个“限流倍率”,被系统判定为高风险的用户,限流阈值会成倍收紧。也就是说,限流不是死的,是跟着用户信誉动态调的。上传 30 次/分钟是正常用户的天花板,但在风控眼里,一个异常用户可能只有 5 次。

限流提示的文案,就来自注解里的 message 字段——一旦触线,前端会弹出一句“获取上传凭证过于频繁,请稍后再试”。

七、踩坑记录,比经验值钱

这条链路踩的坑,我回头把它们分成三组,比一条条列着清楚——一组是时间与路径的债,一组是跟外部依赖打架,一组是边界校验的教训。

时间与路径的债。 预签名 URL 只有 30 秒,最早我把"要凭证"放在压缩之前——大图压缩两秒多,本地没事,慢速手机上一转就奔五秒,PUT 时 URL 早过期,403。修法是现在这个顺序:先压缩、再要凭证,把 30 秒留给网络而不是本地计算。还有路径前缀——public/space/post/ 三套前缀一旦有存量数据,就是不可更改的约定,改一个路径等于迁全库。这两跤的教训是同一个:有些"上线时最年轻"的东西,一落地就成了铁律,所以一开始就要想清楚。

跟外部依赖打架。 COS 的 Java SDK 对盲水印 v3.0 支持不完整,注释里写得明白:"直接用 REST API"。于是盲水印和提取都走了原生 HttpURLConnection 直调 + 手写签名——最折腾的是签名,要自己用 COSSigner 对"Host + Pic-Operations 头 + 路径"算 Authorization,一个头不对就 401,修完还得处理 SSL,最后弃了 Hutool 用原生连接。图片处理返回的 XML 也坑:parseImageProcessResult 要从里面抠宽高和平均色,字段名跟 SDK 文档不完全一致,踩过才知道哪个是真的。这类"云厂商返回格式"的坑,没有捷径,只能真机验证。

边界,永远靠后端兜。 Content-Type 必须对——对象存储把它写进对象 metadata,CDN 和浏览器都靠它,传错图片会被当下载。别信前端传来的文件名——生成 cosKey 用的是后端从文件名取的扩展名,白名单也是后端自己正则校验的,前端伪造后缀过不了这关;反过来,如果你只用前端传的类型字段拼存储路径,就等于把钥匙交给了用户。凡是影响存储路径和权限的字段,一律以后端校验为准——这条经验适用于所有上传接口。

这就是这条链路撞过的墙,每一面墙后面,都有一晚上的排查。

八、复盘:一条链路的三道防线

走完整条链路,回头看它的设计,其实就三句话:

能直传,就别让文件经手服务器。 这是整条链路的地基,带宽是钱,时延是体验,两头都是命。

能在云上处理,就别下载回本地。 WebP 转码、盲水印、缩略图,全部发生在 COS 里,服务器的 CPU 连个像素都没摸过。

每一环都有一道闸。 前端压缩管“别让大文件上路”,后端校验管“别绕过前端乱来”,限流管“别被脚本刷爆”,三道闸各管一段,缺一环都会出事。

前端的二分质量搜索、后端的预签名 PUT、COS 的一次处理三件套、Bucket4j 的动态限流——单拎出来任何一个都不是什么高深技术,但把它们拧成一条链,就是一套不烧带宽、不烧 CPU、不怕刷的生产级上传体系。

如果你只从这篇文章带走三样东西:第一,大文件上传永远优先“直传对象存储”,后端只负责发凭证;第二,压缩盯着体积说话,质量只是手段,二分搜索比瞎猜质量靠谱;第三,凡是能写进“云上一条规则”的处理——转码、缩略图、水印——就别下载回服务器。这三条能同时省掉带宽、CPU 和运维精力,性价比比任何花哨的优化技巧都高。

下次你再写上传功能,不妨先问自己一句:这个文件,真的需要经过我的服务器吗?