独立开发中·📚18 篇日记·📝最近更新:2026年07月12日
📸

一张20MB的烤全羊照片,差点搞垮我的服务器

用户上传单反照片导致2核4G服务器CPU飙到100%。全链路优化后流量降99%。

水哥
2026-07-048 分钟阅读

一个 20MB 的照片引发的血案

觅食猫平台上线的第二周,有个用户上传了一张他拍的烤全羊照片。20MB,6016×4016 像素,直接从单反导出的 RAW 转 JPEG。

他在页面点确认,然后……页面卡住了。浏览器转圈,手机发烫,等了 20 秒后他关掉了页面。

我的服务器是 2 核 4GB 的轻量云服务器,它正在拼命压缩一张 20MB 的图片。

问题链条

用户上传 20MB 图片
  → 浏览器 base64 编码(内存暴涨)
  → 发送 HTTP 请求(带宽吃满)
  → 服务端接收(内存占用飙升)
  → sharp 压缩(CPU 100%)
  → 写磁盘(IO 等待)
  → 返回 URL(太慢了,用户已经关掉了)

每一步都是瓶颈。更糟的是,如果同时有 5 个用户上传大图,服务器直接 OOM。我看了服务器监控,CPU 曲线像心电图一样——而且是大起大落那种。

考察各种方案

方案优点缺点成本
换大配置服务器简单粗暴月费翻倍¥200→¥450/月
云存储 + 图片处理专业,无限扩展API 复杂,按量付费¥0.2/千次
前端压缩 + 分片上传分担服务器压力自己写逻辑开发时间
全栈优化综合最优工作量大3天开发

我选了最后一个——全栈优化。但我做了一件事:从头到尾重新设计上传链路。

前端:把压力留给自己

1. 客户端的压缩——在离开浏览器之前

// src/utils/imageCompress.ts
export async function compressImage(file: File, maxWidth = 1920): Promise<Blob> {
  return new Promise((resolve, reject) => {
    const img = new Image()
    const url = URL.createObjectURL(file)

    img.onload = () => {
      URL.revokeObjectURL(url)

      // 计算缩放比例
      let { width, height } = img
      if (width > maxWidth) {
        height = Math.round(height * maxWidth / width)
        width = maxWidth
      }

      // Canvas 输出 WebP
      const canvas = document.createElement('canvas')
      canvas.width = width
      canvas.height = height
      const ctx = canvas.getContext('2d')!

      ctx.drawImage(img, 0, 0, width, height)
      canvas.toBlob(
        (blob) => resolve(blob!),
        'image/webp',
        0.75 // 75% 质量,足够清晰
      )
    }

    img.onerror = reject
    img.src = url
  })
}

效果: 20MB JPEG → 200KB WebP,压缩比 99%。而且 WebP 的视觉质量在 75% 质量下几乎无损。

2. 渐进式上传体验

// 上传时显示预览 + 进度
function ImageUploader() {
  const [progress, setProgress] = useState(0)
  const [preview, setPreview] = useState('')

  const handleUpload = async (file: File) => {
    // 立即可见预览(本地 URL)
    setPreview(URL.createObjectURL(file))

    // 后台压缩
    const compressed = await compressImage(file)

    // 分块上传 1MB 每块
    const chunks = splitIntoChunks(compressed, 1024 * 1024)
    for (let i = 0; i < chunks.length; i++) {
      await uploadChunk(chunks[i], i, chunks.length)
      setProgress(Math.round((i + 1) / chunks.length * 100))
    }
  }

  return (
    <div>
      {preview && <img src={preview} className="blur-sm" />}
      {progress > 0 && <ProgressBar value={progress} />}
    </div>
  )
}

// 用户体验时间线:
// 0ms:  选择文件 → 立即可见模糊预览
// 500ms: 压缩完成 → 预览变清晰
// 1s:   开始上传 → 进度条出现
// 3s:   上传完成 → 图片可用

用户全程不需要等待,进度条是心理缓冲,模糊预览是视觉安抚。

后端:守住防线

1. 二次压缩兜底

即使前端已经压缩了,后端也要做二次压缩——永远不要信任客户端。

// src/services/image.service.ts
import sharp from 'sharp'

export async function processImage(buffer: Buffer): Promise<{
  webp: Buffer
  thumbnail: Buffer
  metadata: { width: number, height: number, size: number }
}> {
  const image = sharp(buffer)

  const metadata = await image.metadata()

  // 在主图生成的同时生成缩略图
  const [webp, thumbnail] = await Promise.all([
    image
      .resize(1920, undefined, { withoutEnlargement: true }) // 不超过1920宽
      .webp({ quality: 75 })
      .toBuffer(),
    image
      .resize(400, 300, { fit: 'cover' }) // 列表用的缩略图
      .webp({ quality: 60 })
      .toBuffer(),
  ])

  return {
    webp,
    thumbnail,
    metadata: {
      width: metadata.width!,
      height: metadata.height!,
      size: webp.length,
    }
  }
}

2. 安全防护

export const uploadConfig = {
  limits: {
    fileSize: 10 * 1024 * 1024, // 前端会压到 1MB 以内,但这里还是设了 10MB 上限
    files: 9,                    // 一次最多 9 张
  },
  fileFilter: (req: any, file: Express.Multer.File, cb: Function) => {
    // 只允许图片 MIME 类型
    const allowed = ['image/jpeg', 'image/png', 'image/webp', 'image/avif']
    if (!allowed.includes(file.mimetype)) {
      cb(new Error('不支持的文件类型'), false) // 直接拒绝
    }
    cb(null, true)
  }
}
// 更重要的:用魔数验证文件真实类型,不信任 MIME
async function validateImageBuffer(buffer: Buffer): Promise<boolean> {
  const metadata = await sharp(buffer).metadata()
  return !!metadata.format // sharp 能解析出来的才是真图片
}

最终效果

优化前:
  用户选择 20MB 照片 → 浏览器卡死 → 请求超时 → 用户流失
  
优化后:
  用户选择 20MB 照片
    → 0ms: 模糊预览立即可见
    → 400ms: 前端压缩完成(20MB→200KB WebP)
      → 清晰预览显示
    → 1s: 上传完成(200KB vs 20MB,带宽节省 99%)
    → 2s: 服务端生成缩略图
    → 图片可用,总耗时约 2 秒
指标优化前优化后改善
上传流量20MB/张200KB/张-99%
上传耗时25秒+2秒-92%
服务端 CPU100%15%-85%
上传完成率61%94%+54%
用户投诉每周3-5条0条-100%

写在最后

图片上传看起来是个小功能,但它是最容易被忽视的性能瓶颈之一。用户不会说"你的上传速度很慢",他只会说"这个平台卡",然后划走。

对于独立开发而言,你不能指望用户都有千兆宽带和旗舰手机。性能优化的本质,是在替用户的设备和网络带宽兜底。

现在每次我自己上传一张图片,看着压缩进度从 20MB 秒变 200KB,心里都在感慨:

好产品不是让高端用户爽,是让普通用户不难受。

相关文章