Skip to content

c.json() 的性能陷阱:大对象序列化如何阻塞事件循环?

流式 JSON 响应的替代方案

在 Hono 中,c.json(data) 是一个简洁的工具,用于将 JavaScript 对象序列化为 JSON 并返回响应。然而,当处理大型数据对象时,c.json() 可能引发严重的性能问题,甚至阻塞事件循环,导致服务不可用。


一、问题本质:JSON.stringify() 是同步且 CPU 密集的操作

当你调用:

ts
c.json(largeData) // largeData 可能是 10MB+ 的数组或对象

Hono 内部会执行:

ts
const body = JSON.stringify(data) // ← 同步、阻塞主线程
return new Response(body, { headers: { 'Content-Type': 'application/json' } })
问题分析:
问题说明
同步执行JSON.stringify() 在 V8 引擎中是同步操作,期间事件循环被完全阻塞
CPU 密集序列化大对象消耗大量 CPU 时间,可能导致高延迟
内存峰值整个 JSON 字符串需在内存中构建,可能触发 GC 或 OOM
不可扩展在 Serverless 环境(如 Cloudflare Workers)中,CPU 时间受限,可能超时
实验:阻塞事件循环
ts
app.get('/big-json', (c) => {
  const largeArray = Array(100_000).fill({ id: 1, name: 'Test', data: 'x'.repeat(100) })
  return c.json(largeArray) // 可能阻塞数百毫秒
})
  • 同时发起多个请求 → 服务器响应变慢或超时;
  • 其他请求被“饿死”,即使它们是轻量的。

二、解决方案:避免一次性序列化,改用流式响应

目标:将大数据分块生成和传输,避免长时间阻塞。

✅ 方案 1:使用 ReadableStream + 分块生成
ts
app.get('/stream-json', (c) => {
  const stream = new ReadableStream({
    async start(controller) {
      // 模拟大数据源(如数据库游标、文件流)
      const data = Array(50_000).fill(null).map((_, i) => ({ id: i, name: `User-${i}` }))

      // 分块写入
      const chunkSize = 1000
      for (let i = 0; i < data.length; i += chunkSize) {
        const chunk = data.slice(i, i + chunkSize)
        const jsonChunk = JSON.stringify(chunk)
        
        // 发送分块
        controller.enqueue(jsonChunk)
        
        // 可选:微任务让步,避免长时间占用事件循环
        if (i % 5000 === 0) {
          await new Promise(resolve => setTimeout(resolve, 0))
        }
      }

      controller.close()
    }
  })

  const response = new Response(stream, {
    headers: {
      'Content-Type': 'application/json',
      'X-Content-Type': 'streamed-json'
    }
  })

  return response
})

注意:此例返回的是多个 JSON 数组拼接,客户端需处理。更规范的做法见方案 3。


✅ 方案 2:NDJSON(Newline Delimited JSON)

更高效、标准的流式 JSON 格式:每行一个 JSON 对象。

ts
app.get('/ndjson', (c) => {
  const stream = new ReadableStream({
    async start(controller) {
      const total = 50_000
      
      // NDJSON 通常以 [ 开头
      controller.enqueue('[\n')

      for (let i = 0; i < total; i++) {
        const obj = { id: i, name: `User-${i}` }
        const line = JSON.stringify(obj) + (i < total - 1 ? ',\n' : '\n')
        controller.enqueue(line)

        // 每 1000 条让步一次
        if (i % 1000 === 0) {
          await new Promise(resolve => setTimeout(resolve, 0))
        }
      }

      controller.enqueue(']')
      controller.close()
    }
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'application/x-ndjson' }
  })
})

客户端可逐行解析:

js
const response = await fetch('/ndjson')
const reader = response.body.getReader()
const decoder = new TextDecoder()

let buffer = ''
while (true) {
  const { done, value } = await reader.read()
  buffer += decoder.decode(value, { stream: true })

  const lines = buffer.split('\n')
  buffer = lines.pop() // 保留未完整行

  for (const line of lines) {
    if (line.trim()) {
      const obj = JSON.parse(line.replace(/,$/, ''))
      console.log(obj)
    }
  }

  if (done) break
}

✅ 方案 3:使用数据库游标 + 真正的流

适用于从数据库流式读取:

ts
app.get('/db-stream', async (c) => {
  const db = c.env.DB // D1 or similar

  const stream = new ReadableStream({
    async start(controller) {
      controller.enqueue('[\n')

      // 假设支持游标或分页查询
      let offset = 0
      const limit = 1000
      let hasMore = true

      while (hasMore) {
        const rows = await db.prepare(`
          SELECT id, name FROM users LIMIT ? OFFSET ?
        `).bind(limit, offset).all()

        if (rows.results.length === 0) {
          hasMore = false
        } else {
          // 分批序列化并发送
          const chunk = rows.results.map(r => JSON.stringify(r)).join(',\n') + ',\n'
          controller.enqueue(chunk)
          offset += limit

          // 让步
          await new Promise(resolve => setTimeout(resolve, 0))
        }
      }

      // 发送结尾
      const finalChunk = buffer ? buffer + '\n]' : '\n]'
      controller.enqueue(finalChunk.trimEnd().replace(/,\n\]$/, '\n]'))
      controller.close()
    }
  })

  return new Response(stream, {
    headers: { 'Content-Type': 'application/x-ndjson' }
  })
})

三、性能对比

方案阻塞事件循环内存使用客户端体验适用场景
c.json(bigData)❌ 高❌ 高❌ 延迟高小数据(< 100KB)
ReadableStream + 分块✅ 低✅ 低✅ 渐进加载大列表、日志
NDJSON 流✅ 最佳✅ 最佳✅ 实时处理数据导出、事件流

四、最佳实践建议

  1. 避免 c.json() 处理 > 1MB 的数据
  2. 优先使用分页/api/users?page=1&limit=100 比返回全部数据更合理;
  3. 对大数据使用流式响应ReadableStream + NDJSON
  4. 控制流速:在 controller.enqueue() 后适当让步(setTimeout(0));
  5. 客户端适配:使用 fetch + ReadableStream 逐块处理;
  6. 监控序列化时间:记录 JSON.stringify() 耗时,设置告警。

五、结论

c.json() 的便利性背后隐藏着同步序列化的性能陷阱。在处理大型数据集时,应主动规避这一风险,采用流式 JSON 响应(如 NDJSON)或分页查询。这不仅能提升服务性能,还能改善用户体验,实现真正的“响应式” API。