Multer 生产环境最佳实践

本篇基于 Multer 官方仓库,把前面介绍的 API、校验、存储和错误处理组合成一套可落地的生产方案。

Multer 负责的是:解析 multipart/form-data,把文本字段放入 req.body,把文件交给 storage engine,并执行数量、大小和 header 层面的初步筛选。

Multer 不等于完整的文件安全系统。认证、配额、真实类型检测、病毒扫描、持久化、访问控制、生命周期管理和监控都需要工程自行补齐。

1. 先根据规模选择上传架构

1.1 小型学习项目:服务器本地磁盘

浏览器
  → Express + Multer
  → 临时目录
  → 内容检查
  → 正式目录
  → MySQL 保存元数据

适合:

需要注意:

1.2 常规生产项目:对象存储

浏览器
  → Express + Multer
  → 临时隔离区
  → 检查/扫描
  → S3、OSS、COS 等对象存储
  → MySQL 保存 object key 和业务元数据

适合需要多实例部署、备份、CDN 或大量文件的应用。数据库通常只保存:

file_id
owner_id
original_name
storage_key
detected_mime_type
size
sha256
status
created_at

不要把对象存储的永久公开 URL 当作唯一数据。域名、bucket 或访问策略可能变化,通常保存稳定的 storage_key,在返回 API 数据时再生成 URL。

1.3 大文件或高流量:预签名 URL 直传

1. 浏览器向 Express 申请上传凭证
2. Express 检查登录、权限、配额和文件声明
3. Express 返回短期预签名 URL
4. 浏览器直接上传到对象存储
5. 浏览器通知 Express 上传完成
6. 后台检查文件并更新状态

这种架构中,大文件不经过 Node.js 进程,因此对应接口不使用 Multer。它可以减少 Node.js 的带宽、内存和连接占用,但需要处理:

2. 推荐的完整处理顺序

Nginx 请求体上限
  → 登录认证
  → 业务权限和用户配额
  → 上传接口限流
  → Multer 字段、数量和大小限制
  → fileFilter 根据 part headers 快速拒绝
  → 临时隔离区
  → Magic Number / 文件解析 / 病毒扫描
  → 生成正式 storage key
  → 正式存储
  → MySQL 写入元数据和业务关系
  → 返回 file_id

认证和权限应放在 Multer 前面。否则服务器可能接收完一个无权用户的大文件,才开始判断权限。

router.post(
  "/documents",
  requireLogin,
  requireDocumentUploadPermission,
  uploadRateLimiter,
  upload.single("document"),
  validateDocumentFields,
  createDocument,
);

但 multipart 中的普通文本字段也由 Multer 解析,所以依赖 req.body 的 express-validator 校验通常放在 Multer 后面。若某个业务参数必须在接收大文件前验证,可将流程拆成“创建上传会话”和“上传文件”两个接口。

3. 多层大小限制如何搭配

3.1 Nginx:限制整个 HTTP 请求体

location /api/uploads/ {
    client_max_body_size 12m;
    proxy_pass http://node_backend;
}

client_max_body_size 限制整个请求体,不只是文件内容。multipart 边界、part headers 和普通字段也占空间,因此它应略大于业务允许的文件总大小。

3.2 Multer:限制文件和字段

const upload = multer({
  storage,
  limits: {
    fileSize: 5 * 1024 * 1024,
    files: 2,
    fields: 10,
    parts: 12,
    fieldSize: 64 * 1024,
    fieldNestingDepth: 5,
  },
});

这些限制会传递给底层 Busboy。fileSize 是单个文件的上限,files 是文件数量上限;两者不能代替“整个请求体上限”。

3.3 业务层:限制用户配额

还应检查:

限流控制频率,配额控制资源总量,它们解决的不是同一个问题。

4. memoryStorage() 的使用边界

memoryStorage() 会把完整文件放入 file.buffer。它适合:

const avatarUpload = multer({
  storage: multer.memoryStorage(),
  limits: {
    fileSize: 2 * 1024 * 1024,
    files: 1,
    fields: 4,
  },
});

估算内存时不能只看单文件限制。例如 100 个并发请求,每个请求接收 2 MiB 文件,仅文件 buffer 理论上就可能占用约 200 MiB,还没有计算 Node.js、Express、解析器和业务代码的内存。

大文件、文件数量多或并发不可控时,应使用磁盘临时存储、流式自定义 storage engine 或预签名直传。

5. 文件类型风险不能只看 mimetype

file.mimetype 来自 multipart part headers,可以由客户端伪造。生产系统可按风险组合以下检查:

  1. 扩展名:只适合用户体验和初步规则;
  2. 声明的 mimetype:适合在 fileFilter 中快速拒绝明显不符合者;
  3. Magic Number:识别常见文件签名;
  4. 实际解析:使用成熟库完整解析目标格式;
  5. 重编码:图片解码后重新编码,丢弃部分隐藏内容;
  6. 病毒扫描:对办公文档、压缩包或外部共享文件尤其重要;
  7. 人工审核:用于高风险公开内容。

5.1 SVG

SVG 是 XML 文本,不等同于普通位图。直接以内联方式展示不可信 SVG 可能引入脚本、外部资源或其他主动内容。可选择:

5.2 ZIP 和压缩炸弹

一个很小的压缩包解压后可能极大。不能只限制压缩文件本身大小,还应限制:

解压时必须防止成员名称中的 ../ 或绝对路径逃出目标目录。

5.3 图片解压炸弹

压缩后的图片可能很小,但像素尺寸很大。解码前检查宽、高和总像素数,并给图像处理任务设置资源限制。

5.4 PDF 和 Office 文档

这类格式结构复杂,可能包含脚本、宏、嵌入文件和外部链接。若业务只是让用户下载,应使用附件方式并在独立下载域名提供;若要在线预览,建议先经过扫描,再转换为受控的预览格式。

6. 临时隔离区和文件状态

文件刚上传完成时不要直接标记为可用。可以设计以下状态:

incoming → scanning → ready
                    ↘ rejected

下载接口只允许访问 ready 文件。隔离区不应被 Nginx 静态目录或 CDN 直接公开。

状态机还能处理 MySQL 事务无法回滚文件系统或对象存储的问题:数据库操作失败时执行补偿删除;补偿失败则由后台任务根据状态和时间再次清理。

7. 文件名和下载策略

始终区分:

original_name:用户上传时的名称,仅作为不可信元数据
storage_key:服务器生成的 UUID/随机值/对象 key
download_name:经过规范化、用于下载展示的安全名称

不要把 file.originalname 直接用于磁盘路径、Shell 命令、对象 key 或日志结构。物理存储名称可使用 UUID:

import { randomUUID } from "node:crypto";
import path from "node:path";

const storageKey = randomUUID();
const safeExtension = ".webp"; // 根据服务端确认的类型决定
const storedName = `${storageKey}${safeExtension}`;
const fullPath = path.join(uploadRoot, storedName);

下载时从数据库读取经过规范化的名称:

res.download(filePath, record.downloadName);

Express 会设置 Content-Disposition: attachment。仍应在入库时移除控制字符,限制长度,并为无法安全展示的名称提供回退值。不要手工拼接 Content-Disposition 字符串。

8. 私有文件、公开文件和 CDN

私有文件

下载前检查登录、文件所有权和业务权限,然后:

预签名 URL 是一种临时持有者凭证。不要写入公开日志,也不要给过长有效期。

公开文件

可通过 CDN 分发,但最好使用不可猜测或基于内容版本的 key。用户删除或替换文件时,需要同步处理:

9. 日志、隐私和审计

上传日志建议记录结构化字段:

{
  "event": "file_upload_rejected",
  "requestId": "req-123",
  "userId": 42,
  "fileId": "file-456",
  "declaredMimeType": "image/png",
  "detectedMimeType": "application/x-dosexec",
  "size": 18342,
  "reason": "FILE_SIGNATURE_MISMATCH"
}

通常不要记录:

原始文件名也可能包含换行符等日志注入字符。结构化日志库会降低手工拼接字符串的风险,但仍应做字段长度限制和敏感信息脱敏。

审计日志关注“谁在何时对哪个文件做了什么”;运行日志关注错误诊断。两者用途和保留周期通常不同。

10. 建议监控的指标

若使用 memoryStorage(),应特别观察进程 RSS、external memory 和并发上传数,而不只是 V8 heap。

11. 文件生命周期

上传只是文件生命周期的开始。还要明确:

定时清理任务必须幂等:重复执行不会误删有效文件,失败后可以安全重试。

12. 一份可执行的安全检查清单

上线前逐项确认:

13. 常见错误方案复盘

错误一:只检查扩展名

用户可以把可执行文件改名为 .jpg。扩展名不是内容证明。

错误二:相信 file.mimetype

mimetype 是客户端声明。它适合尽早过滤,不适合做最终安全结论。

错误三:使用原始名称保存文件

会引入重名覆盖、路径穿越、跨平台字符和日志/Header 注入问题。

错误四:把所有文件放进 MemoryStorage

小文件上限乘以高并发后仍可能耗尽内存。

错误五:文件保存后立即公开

真实类型识别、扫描或业务落库尚未完成时,文件不应被访问。

错误六:只依赖 MySQL 事务

数据库回滚不会自动删除磁盘文件或对象存储对象,需要补偿和后台清理。

14. 练习题

  1. 为头像上传设计 Nginx、Multer 和业务配额三层限制,并说明每层解决什么问题。
  2. 使用 memoryStorage() 编写一个最大 2 MiB 的单头像上传接口,再估算 200 个并发上传可能占用的文件 buffer 内存。
  3. 设计一个 incoming → scanning → ready/rejected 文件表,列出关键字段和允许的状态转换。
  4. 为私有合同文件设计上传和下载流程,要求原始名称可恢复,但物理路径中不能出现原始名称。
  5. 设计 ZIP 文件的安全检查规则,覆盖解压总大小、成员数量、路径穿越和嵌套深度。
  6. 为预签名 URL 直传方案设计“申请上传、确认完成、后台扫描、孤儿回收”四个阶段。
  7. 编写一份故障测试清单,覆盖客户端中断、MySQL 写入失败、对象存储失败、扫描超时和补偿删除失败。
  8. 设计上传监控面板,选择至少六个指标,并说明每个指标异常时可能意味着什么。

官方参考