文件大小限制

要点

  • 文件上传接口如果不对体积做限制,一个恶意请求就能把 Worker 内存撑爆,或者把 R2 存储塞满
  • Cloudflare Workers 本身有请求体大小限制:免费计划 100MB,付费计划 500MB
  • Hono 提供了 bodyLimit 中间件,可以按路由设置不同的大小限制
  • 超过 Worker 内存承载能力的文件,需要走流式上传,边读边写到 R2
  • 更大的文件(几百 MB 以上)考虑分片上传,前端配合上报进度

1. 为什么要限制文件大小

前面两篇讲了文件上传接口和类型校验。类型校验挡住了「不该进来的文件」,这一篇要挡住「不该进来的体积」。

不做大小限制会出什么问题?

  1. 资源耗尽。Workers 的内存有上限(免费计划 128MB),把整个请求体读到内存里,一个 200MB 的文件就能让 Worker 直接 OOM。即使不崩,R2 的存储空间也被无意义地消耗
  2. 恶意攻击。有人给没有大小限制的上传接口发超大文件,目的是打垮服务或刷爆存储账单

限制文件大小是上传接口的第一道防线,应该在类型校验之前执行——先卡体积,再卡类型,避免白白花时间检查一个注定被拒绝的文件的 MIME type。

2. Cloudflare Workers 的硬限制

在写代码之前,先看 Cloudflare Workers 平台本身的请求体限制。这是平台层面的硬上限:

计划请求体最大Worker 内存
免费100MB128MB
付费(Paid)500MB128MB

「请求体最大」和「Worker 内存」是两个概念。即使付费计划允许 500MB 的请求体,Worker 也只有 128MB 内存——不能用 await c.req.arrayBuffer() 把 500MB 的文件完整读到内存里。处理接近 500MB 的文件,必须用流式处理(后面会讲),边读边写到 R2。

对于大多数应用场景,免费计划的 100MB 限制已经够用。头像、文档、图片,单个文件超过 100MB 的情况很少。

3. Hono 的 bodyLimit 中间件

Hono 内置了 bodyLimit 中间件,挂到路由上就能生效:

import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
 
const app = new Hono()
 
// 全局限制:所有上传接口最多 10MB
app.use('/api/upload/*', bodyLimit({
  maxSize: 10 * 1024 * 1024,
  onError: (c) => {
    return c.json({ error: '文件过大,最大允许 10MB' }, 413)
  },
}))
 
app.post('/api/upload', async (c) => {
  const formData = await c.req.formData()
  // ...
})

maxSize 的单位是字节。onError 是超过限制时的回调,不写的话 Hono 返回默认的 400 响应。

超过限制时建议返回 413 Payload Too Large,前端和 API 网关看到 413 就知道是体积问题,不用去解析错误消息。

4. 按路由设置不同的大小限制

实际项目里,不同类型的文件有不同的合理大小。头像 2MB 够了,文档附件可能需要 50MB,AI 生成的视频可能要 100MB。统一限制要么太松要么太紧,按路由单独设置更合理:

import { Hono } from 'hono'
import { bodyLimit } from 'hono/body-limit'
 
const app = new Hono()
 
// 头像上传:2MB
app.post(
  '/api/avatar',
  bodyLimit({ maxSize: 2 * 1024 * 1024 }),
  async (c) => {
    const formData = await c.req.formData()
    const file = formData.get('avatar') as File
    // ...
  }
)
 
// 文档上传:50MB
app.post(
  '/api/documents',
  bodyLimit({ maxSize: 50 * 1024 * 1024 }),
  async (c) => {
    const formData = await c.req.formData()
    const file = formData.get('file') as File
    // ...
  }
)

把大小限制整理成一个配置对象,后续调整时只改一处:

// lib/upload-limits.ts
export const UPLOAD_LIMITS = {
  avatar: 2 * 1024 * 1024,       // 2MB
  image: 5 * 1024 * 1024,        // 5MB
  document: 50 * 1024 * 1024,    // 50MB
  audio: 25 * 1024 * 1024,       // 25MB
  video: 100 * 1024 * 1024,      // 100MB
} as const

5. AI 场景的典型大小限制

AI 相关的文件上传,各类文件有一个经验值范围可以参考:

文件类型建议限制原因
图片(输入)5MB大多数 AI 模型接受 2048×2048 以内的图片,PNG 压缩后通常 1-3MB
PDF 文档10MBRAG 场景的文档解析,超过 10MB 的 PDF 解析时间会明显变长
音频25MBWhisper 等语音模型的输入,25MB 约覆盖 30 分钟音频
视频100MB视频理解模型的输入,通常还需要抽帧处理

这些数字不是固定值,根据接入的模型能力和产品需求调整。更重要的是在接口层面用中间件把限制落地,只写在文档里的「建议不超过 XX MB」没有强制力。

import { UPLOAD_LIMITS } from '../lib/upload-limits'
 
app.post(
  '/api/ai/parse-document',
  bodyLimit({ maxSize: UPLOAD_LIMITS.document }),
  async (c) => {
    const formData = await c.req.formData()
    const file = formData.get('file') as File
 
    if (file.type !== 'application/pdf') {
      return c.json({ error: '仅支持 PDF 格式' }, 400)
    }
    // bodyLimit 已经保证了大小,这里只需要检查类型
    // 调用 AI 解析逻辑 ...
  }
)

6. 流式上传:大文件不过内存

前面的例子都用了 await c.req.formData()await c.req.arrayBuffer(),会把整个请求体读到内存里。50MB 的 PDF 或 100MB 的视频就会造成内存压力。Workers 内存上限 128MB,同时几个用户上传大文件就容易不够用。

解决办法是流式上传——拿到请求体的 ReadableStream,直接传给 R2 的 put 方法,数据从请求流直接写入存储:

app.post('/api/documents', async (c) => {
  const stream = c.req.body
  if (!stream) {
    return c.json({ error: '请求体为空' }, 400)
  }
 
  const key = `documents/${Date.now()}.pdf`
 
  // 流直接传给 R2,Worker 内存里只有当前 chunk
  await c.env.BUCKET.put(key, stream, {
    httpMetadata: { contentType: 'application/pdf' },
  })
 
  return c.json({ key })
})

对于 multipart/form-data 格式,File 对象的 .stream() 方法返回 ReadableStream<Uint8Array>,同样不会把整个文件加载到内存:

app.post(
  '/api/upload-large',
  bodyLimit({ maxSize: 100 * 1024 * 1024 }),
  async (c) => {
    const formData = await c.req.formData()
    const file = formData.get('file') as File
    if (!file) {
      return c.json({ error: '未提供文件' }, 400)
    }
 
    const key = `uploads/${Date.now()}-${file.name}`
    await c.env.BUCKET.put(key, file.stream(), {
      httpMetadata: { contentType: file.type },
    })
    return c.json({ key, size: file.size })
  }
)

c.req.body 是整个请求体的原始流,file.stream() 是 multipart 解析后的文件字段流。两种方式传给 R2 都不会把整个文件加载到内存。

7. 分片上传:应对超大文件

流式上传解决了内存问题,但覆盖不到一个场景:上传中途断了怎么办? 200MB 的视频传了 4 分钟断网了,流式上传意味着前面的数据全部丢失,必须从头再来。

分片上传(Chunked Upload)把大文件切成多个小块,逐块上传。每块成功后服务端记录进度,中途断了只需要从断点继续:

import { Hono } from 'hono'
 
type Bindings = { BUCKET: R2Bucket; KV: KVNamespace }
const app = new Hono<{ Bindings: Bindings }>()
 
// 1. 初始化分片上传
app.post('/api/upload/init', async (c) => {
  const { filename, totalChunks, fileSize } = await c.req.json()
  const uploadId = crypto.randomUUID()
 
  await c.env.KV.put(
    `upload:${uploadId}`,
    JSON.stringify({ filename, totalChunks, fileSize, uploadedChunks: [], createdAt: Date.now() }),
    { expirationTtl: 86400 }
  )
  return c.json({ uploadId })
})
 
// 2. 上传单个分片
app.post('/api/upload/chunk', async (c) => {
  const formData = await c.req.formData()
  const chunk = formData.get('chunk') as File
  const uploadId = c.req.header('X-Upload-Id')
  const chunkIndex = Number(c.req.header('X-Chunk-Index'))
 
  if (!uploadId || isNaN(chunkIndex)) {
    return c.json({ error: '缺少上传标识或分片序号' }, 400)
  }
 
  const stateJson = await c.env.KV.get(`upload:${uploadId}`)
  if (!stateJson) {
    return c.json({ error: '上传不存在或已过期' }, 404)
  }
  const state = JSON.parse(stateJson)
 
  // 保存分片到 R2
  await c.env.BUCKET.put(`chunks/${uploadId}/${chunkIndex}`, chunk.stream())
 
  state.uploadedChunks.push(chunkIndex)
  await c.env.KV.put(`upload:${uploadId}`, JSON.stringify(state))
  return c.json({ uploaded: state.uploadedChunks.length, total: state.totalChunks })
})
 
// 3. 完成合并
app.post('/api/upload/complete', async (c) => {
  const { uploadId } = await c.req.json()
  const stateJson = await c.env.KV.get(`upload:${uploadId}`)
  if (!stateJson) return c.json({ error: '上传不存在' }, 404)
 
  const state = JSON.parse(stateJson)
  if (state.uploadedChunks.length !== state.totalChunks) {
    return c.json({ error: '分片不完整' }, 400)
  }
 
  // 逐个读取分片并拼接
  const chunks: Uint8Array[] = []
  for (let i = 0; i < state.totalChunks; i++) {
    const obj = await c.env.BUCKET.get(`chunks/${uploadId}/${i}`)
    if (!obj) return c.json({ error: `分片 ${i} 丢失` }, 500)
    chunks.push(new Uint8Array(await obj.arrayBuffer()))
  }
  const totalLength = chunks.reduce((sum, c) => sum + c.length, 0)
  const merged = new Uint8Array(totalLength)
  let offset = 0
  for (const chunk of chunks) { merged.set(chunk, offset); offset += chunk.length }
 
  const finalKey = `uploads/${Date.now()}-${state.filename}`
  await c.env.BUCKET.put(finalKey, merged)
 
  // 清理分片和上传记录
  for (let i = 0; i < state.totalChunks; i++) {
    await c.env.BUCKET.delete(`chunks/${uploadId}/${i}`)
  }
  await c.env.KV.delete(`upload:${uploadId}`)
  return c.json({ key: finalKey })
})

实际项目里还需要考虑:

  1. 分片大小:通常 5-10MB,太小请求数太多,太大断点续传浪费多
  2. 并发上传:前端同时上传多个分片(3 个并发),加快速度
  3. 超时清理:KV 记录设 expirationTtl: 86400,24 小时未完成自动清理
  4. 幂等性:同一个 chunkIndex 重复上传应该覆盖

R2 也支持 S3 兼容的 multipart upload API,适合 GB 级别的更大文件。对于几十 MB 的文件,KV + 分片的方案够用。

8. 前端进度条配合

后端做了分片上传,前端需要配合展示进度。fetch API 不支持上传进度,需要借助 XMLHttpRequestupload.onprogress 事件:

async function uploadFile(file: File, uploadId: string) {
  const CHUNK_SIZE = 5 * 1024 * 1024
  const totalChunks = Math.ceil(file.size / CHUNK_SIZE)
 
  for (let i = 0; i < totalChunks; i++) {
    const start = i * CHUNK_SIZE
    const chunk = file.slice(start, Math.min(start + CHUNK_SIZE, file.size))
    const formData = new FormData()
    formData.append('chunk', chunk)
 
    await new Promise<void>((resolve, reject) => {
      const xhr = new XMLHttpRequest()
      xhr.upload.onprogress = (e) => {
        if (e.lengthComputable) {
          const totalProgress = (i + e.loaded / e.total) / totalChunks
          updateProgressBar(totalProgress)
        }
      }
      xhr.onload = () => xhr.status === 200 ? resolve() : reject(new Error(`分片 ${i} 失败`))
      xhr.onerror = () => reject(new Error('网络错误'))
      xhr.open('POST', '/api/upload/chunk')
      xhr.setRequestHeader('X-Upload-Id', uploadId)
      xhr.setRequestHeader('X-Chunk-Index', String(i))
      xhr.send(formData)
    })
  }
}
 
function updateProgressBar(progress: number) {
  const percent = Math.round(progress * 100)
  const bar = document.getElementById('progress-bar')
  if (bar) {
    bar.style.width = `${percent}%`
    bar.textContent = `${percent}%`
  }
}

完整的上传流程是:初始化 → 逐片上传(带进度)→ 完成合并。对于小文件(头像、图片),不需要分片,直接 fetch 一次传完:

async function uploadAvatar(file: File) {
  const formData = new FormData()
  formData.append('avatar', file)
  const res = await fetch('/api/avatar', { method: 'POST', body: formData })
  return res.json()
}

9. 前端限制和后端限制的关系

前端已经检查了 file.size,后端还需要 bodyLimit 吗?

需要。前端限制是用户体验优化——让用户选文件时就知道太大了。但前端检查可以被绕过(直接调 API、改 JS、用 curl),后端必须有自己的防线。

正确的做法是两层都加,且限制值保持一致:

// 前端:选文件时立即提示
fileInput.addEventListener('change', (e) => {
  const file = (e.target as HTMLInputElement).files[0]
  if (file && file.size > 2 * 1024 * 1024) {
    alert('头像不能超过 2MB')
    fileInput.value = ''
  }
})
// 后端:bodyLimit 兜底
app.post(
  '/api/avatar',
  bodyLimit({
    maxSize: 2 * 1024 * 1024,
    onError: (c) => c.json({ error: '头像不能超过 2MB' }, 413),
  }),
  async (c) => { /* ... */ }
)

前端给用户快速反馈,后端保证安全。两层限制值要对齐,避免出现前端允许 5MB 但后端只收 2MB 的矛盾。

总结

文件上传的大小控制分几个层次:

  1. 平台限制:Cloudflare Workers 免费 100MB、付费 500MB 的请求体上限,硬约束
  2. 中间件限制bodyLimit 按路由设置业务级别的限制,超过返回 413
  3. 内存保护:超过几 MB 的文件用流式上传(file.stream()c.req.body),不全部读到内存
  4. 断点续传:更大的文件走分片上传,前端配合上报进度,中断后从断点继续
  5. 双层防线:前端做体验优化(选择时立即提示),后端做安全兜底(中间件拦截)

AI 场景的常见限制值:图片 5MB、PDF 10MB、音频 25MB、视频 100MB,根据接入模型的实际能力调整。把数字集中在一个配置对象里,方便后续统一修改。