小Cの已经记不起来的博客

文件上传 的十种打开方式:工程实践笔记

先说坑:上传真不是 “form 里加个 file 就完了”

最近又碰到一次线上上传 500,用户报“传不上去”。结果一看日志,浏览器明明选了 80MB 视频,后端 Multer 直接抛 LIMIT_FILE_SIZE,Nginx 又先回了个 413,前端只拿到 Internal Server Error,排查得我一晚上没睡踏实。后来才意识到,文件上传这个功能特别容易低估:小文件能用,大文件就不行;本地能传,容器里不行;开发环境正常,线上 CDN 和反代一叠,全乱了。

下面把我这些年工程里实际用过的十种上传方式整理一下,基本按从小到大、从后端到前端、从能传到稳传的顺序来。有些命令直接抄,有些配置需要根据你的环境改,不保证每个域名和版本号永远能用,但思路是通用的。

第一种:最基础的 multipart 表单直传后端

这玩意儿就是最古老但最实用的方式,适合小文件、后台表单、临时文件。前端一个 form:

<form action="/upload" method="post" enctype="multipart/form-data">
  <input type="file" name="file">
  <button type="submit">上传</button>
</form>

Node/Express 用 Multer 接:

const express = require('express');
const multer = require('multer');
const path = require('path');

const app = express();

const upload = multer({
  dest: path.join(__dirname, 'tmp'),
  limits: { fileSize: 50 * 1024 * 1024 },
  fileFilter(req, file, cb) {
    const ext = path.extname(file.originalname).toLowerCase();
    if (!['.jpg', '.png', '.pdf', '.mp4'].includes(ext)) {
      return cb(new Error('file type not allowed'));
    }
    cb(null, true);
  }
});

app.post('/upload', upload.single('file'), (req, res) => {
  res.json({ saved: req.file.filename, size: req.file.size });
});

Multer 的 dest 会用系统临时目录,文件名随机,不会保留原文件名。这里其实已经有个安全点:不要把用户给的文件名直接落盘,不然 ../../etc/passwd 这种路径穿越能玩出花。

第二种:后端收到文件后先落盘,再异步处理

如果上传的是图片、视频、模型文件,别在请求线程里直接做压缩、转码、病毒扫描、数据库入库。最稳的是先写临时文件,返回一个任务 ID,再让 worker 慢慢处理。

const crypto = require('crypto');

app.post('/upload', upload.single('file'), async (req, res) => {
  const taskId = crypto.randomUUID();
  await store.save(req.file.path);
  await queue.add('process-file', { taskId, userId: req.user.id });
  res.status(202).json({ taskId, status: 'processing' });
});

这个方式的坑是临时文件残留。你 worker 失败了、机器重启了、容器被杀了,/tmp 里可能留一堆没人要的文件。生产环境记得挂一个清理任务,别指望系统帮你定期清。

第三种:前端直传对象存储,走预签名 URL

文件一大,就别让后端当二道贩子了。上传 100MB、1GB 视频,还从浏览器进后端再转存 S3/OSS/MinIO,带宽和内存都能被打爆。更常见的是后端签发一个短时效的预签名 PUT URL,浏览器直接 PUT 到对象存储。

后端签发:

const url = await storage.signedUrl({
  Bucket: 'my-bucket',
  Key: `users/${userId}/${uuid}.mp4`,
  Expires: 600,
  Method: 'PUT',
  ContentType: 'video/mp4'
});

前端上传:

await fetch(signedUrl, {
  method: 'PUT',
  body: file,
  headers: {
    'Content-Type': 'video/mp4'
  }
});

这里容易踩的坑是签名里的 Content-Type 和请求头必须一致,差一个都不行。AWS SDK v3 的 @aws-sdk/s3-request-presigner 会把这个坑放大给你看,报一个非常友好的 SignatureDoesNotMatch

第四种:大文件分片上传

单文件 PUT 预签名 URL 简单,但浏览器网络一抖,几十 MB、几百 MB 就全白传。真正稳一点的分片上传流程一般是三步:初始化上传、上传分片、合并分片。

# 后端返回 uploadId、partSize、每个分片的 signedUrl
curl -X POST https://api.example.com/uploads/init \
  -H 'Content-Type: application/json' \
  -d '{"file":"big.zip","size":1073741824}'

前端按固定大小切片:

const partSize = 10 * 1024 * 1024;
for (let offset = 0; offset < file.size; offset += partSize) {
  const blob = file.slice(offset, Math.min(offset + partSize, file.size));
  const partNumber = Math.floor(offset / partSize) + 1;
  await uploadPart(blob, partNumber);
}

S3 multipart upload 要求除最后一个 part 外,每个 part 至少 5MB,这个规则不知道坑了多少人。你要是用 1KB 分片玩,合并直接报错,还不容易一眼看出来。

第五种:秒传和哈希去重

秒传听起来很高级,其实就是先算文件哈希,上传前先问后端:“这个文件你是不是已经有了?”

后端如果要算本地文件哈希,别一次性读进内存,直接流式算:

const crypto = require('crypto');
const fs = require('fs');

const hash = crypto.createHash('sha256');
fs.createReadStream('/path/to/big.zip')
  .pipe(hash)
  .on('finish', () => {
    console.log(hash.digest('hex'));
  });

命令行里临时验证也很方便:

sha256sum big.zip

后端收到哈希后查表,命中就直接返回成功,没命中再给上传链接。注意哈希碰撞不要想太多,工程里用 SHA-256 基本够了;但去重要带大小、哈希、bucket 前缀一起判断,别拿文件名当唯一标识。

浏览器里如果大文件也想要秒传,别直接 file.arrayBuffer(),整个文件读进内存会卡。要么小文件才走 WebCrypto,要么就别在浏览器里硬算。

第六种:浏览器 input file 用 fetch 传,别再整页刷新

很多人还在用原生 form 提交上传,能传是能传,就是没有进度、没有取消、没有失败重试。工程里一般用 fetchXMLHttpRequest 包一下。

const form = new FormData();
form.append('file', file);
form.append('taskId', taskId);

const xhr = new XMLHttpRequest();
xhr.open('POST', '/upload');
xhr.upload.onprogress = e => {
  if (e.lengthComputable) {
    setProgress((e.loaded / e.total) * 100);
  }
};
xhr.send(form);

fetch 做上传进度确实麻烦,尤其浏览器 fetch 流式进度兼容性没那么舒服。你要是写后台管理系统,用 XMLHttpRequest 反而更直接,少折腾。

第七种:Docker 里的上传目录别踩权限

本地跑得好好的,进 Docker 就 EACCES: permission denied。原因通常很简单,挂载目录宿主权限和容器用户权限不匹配。

mkdir -p ./data/uploads
chmod 777 ./data/uploads

docker run --rm \
  -v $(pwd)/data/uploads:/app/data/uploads \
  -e UPLOAD_DIR=/app/data/uploads \
  my-upload-service:latest

chmod 777 是排查用的偷懒办法,生产别这么干。正式一点就是 Dockerfile 里创建用户、目录,然后 chown:

RUN mkdir -p /app/data/uploads \
 && chown -R node:node /app/data/uploads
USER node

容器化之后文件持久化也得想清楚,/tmp 在容器里可能一重启就没了。你需要挂载卷,或者干脆把上传后的最终文件放到对象存储,本地只留临时态。

第八种:Nginx 413 和超时怎么排查

上传 50MB 文件,前端直接 413 Request Entity Too Large,多半是 Nginx 默认限制。改 client_max_body_size 就行:

http {
    client_max_body_size 200M;
    proxy_request_buffering off;
    proxy_read_timeout 600s;
    proxy_send_timeout 600s;
}

如果前面还有 Kong、Traefik、ALB、CDN,别只改一个 Nginx,一层一层查。很多时候最后发现是 API 网关也设了 body limit,或者负载均衡器超时太短,大文件传到一半就被掐了。

另一个坑是 proxy_request_buffering off 开不开。开了可以让 Nginx 不把整个请求体缓存到临时文件,边收边转发;但是上游服务挂掉或响应慢的时候,行为会不一样,测试环境别直接线上抄。

第九种:私有文件上传后怎么安全下载

上传到 bucket 不难,难的是上传完怎么给正确的人看。如果把文件放成 public-read,等于谁拿到 URL 谁能访问,过几天爬虫、扫描器、离职员工转发的链接全来了。

更稳的是私有读,后端根据用户权限签发 GET URL:

const downloadUrl = await storage.getSignedUrl({
  Bucket: 'my-bucket',
  Key: record.objectKey,
  Expires: 300,
  ResponseContentDisposition: `attachment; filename="${encodeURIComponent(record.filename)}"`
});

前端拿到 URL 后直接 window.open 或者 <a download>。如果文件类型是图片,浏览器会优先 inline 展示;要强制下载,就设 Content-Disposition。这个细节经常被人忽略,最后问“为什么 PDF 上传后点链接变成浏览器打开了”。

第十种:上传失败重试和幂等

上传最容易让人骂娘的地方不是成功不了,而是成功不了之后重复点击,数据库里多出一堆垃圾记录,或者文件传了两次。工程里需要给上传操作一个 uploadId,所有失败重试都基于同一个 ID。

app.post('/uploads/init', async (req, res) => {
  const { fileName, size, sha256, idempotencyKey } = req.body;
  const upload = await getOrCreateUpload({
    idempotencyKey,
    fileName,
    size,
    sha256,
    userId: req.user.id
  });
  res.json(upload);
});

合并完成后,如果对象存储返回成功但数据库没写进去,重试时不能再创建一份对象。可以先用 metadata 或数据库状态记录 pending / uploading / completed / failed,最后再原子标记完成。

最后怎么选

小文件、后台表单,直接 multipart + Multer,够用。大文件、公网用户多、带宽贵,前端直传对象存储。要稳定续传,分片上传。要省流量,秒传哈希。要安全,权限判断放后端,下载 URL 短时效。别把上传当成“前端一个 input,后端一个 req.file”,那只是 demo。真到线上,文件类型、大小、权限、存储、网络、重试、清理、审计,一个都跑不掉。

我现在更倾向的做法是:小文件走后端中转,大文件走直传;对象存储只当冷存储和最终落地;临时文件尽量短命;上传链路一定带 idempotencyKey。这样至少用户反馈“上传失败”时,我能看懂日志,不用半夜猜是不是 Nginx 又背锅了。

评论

还没有评论。

发表评论

提交后评论将经过自动审核,审核通过后公开展示。

未在播放