一个 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% |
| 服务端 CPU | 100% | 15% | -85% |
| 上传完成率 | 61% | 94% | +54% |
| 用户投诉 | 每周3-5条 | 0条 | -100% |
写在最后
图片上传看起来是个小功能,但它是最容易被忽视的性能瓶颈之一。用户不会说"你的上传速度很慢",他只会说"这个平台卡",然后划走。
对于独立开发而言,你不能指望用户都有千兆宽带和旗舰手机。性能优化的本质,是在替用户的设备和网络带宽兜底。
现在每次我自己上传一张图片,看着压缩进度从 20MB 秒变 200KB,心里都在感慨:
好产品不是让高端用户爽,是让普通用户不难受。