构建企业级数据导出系统:Excel/CSV/PDF 大数据量导出与流式生成实战

深入解析企业级数据导出系统架构,涵盖 Excel、CSV、PDF 三种格式的流式生成方案,含百万行数据导出优化、内存控制、异步任务队列、前端下载进度追踪等完整实现,附 Node.js/Java/Go 多语言代码示例。

开发者效率 2026-06-03 22 分钟

数据导出是企业应用中最常见、却最容易被低估的功能。根据 Segment 2025 年的企业数据报告,超过 78% 的企业用户每周至少使用一次数据导出功能,而导出失败是 B 端系统投诉的 Top 3 原因之一。一个写得不好的导出接口,可以在用户点击「导出」按钮的瞬间把服务器内存打爆——我就亲眼见过一个 SELECT * FROM ordersJSON.stringify 的导出实现,直接让 Node.js 进程 OOM 崩溃,连带拖垮了同机部署的其他服务。

本文不讲 Hello World,只讲生产环境真正会遇到的问题:百万行 Excel 怎么流式生成?CSV 编码乱码怎么彻底解决?PDF 中文排版怎么不踩坑?异步导出任务怎么设计? 每个方案都有完整可运行的代码,每个坑都是真实线上踩过的。

📌 记住: 数据导出不是「查出来然后写文件」这么简单。它是一个涉及数据库查询优化、内存管理、文件流式写入、异步任务调度、前端交互反馈的完整系统工程。

📊 一、三种导出格式的技术选型与适用场景

1.1 格式对比全景表

在动手写代码之前,先搞清楚该用什么格式。很多开发者习惯性地所有导出都用 Excel,这是一个常见的误区。

对比维度 CSV Excel (.xlsx) PDF
文件体积 ⭐⭐⭐ 极小(纯文本) ⭐⭐ 中等(XML 压缩) ⭐ 较大(含字体嵌入)
生成速度 ⭐⭐⭐ 极快(流式写入) ⭐⭐ 较快(流式写入) ⭐ 较慢(排版计算)
内存占用 ⭐⭐⭐ 极低 ⭐⭐ 中等 ⭐ 较高
数据量上限 无限制 理论 104 万行 无明确限制
中文支持 需指定编码 原生支持 需嵌入字体
格式/样式 ❌ 无 ✅ 丰富 ✅ 丰富
可编辑性 ✅ 可用文本编辑器 ✅ 可用 Excel 编辑 ❌ 不可编辑
适用场景 数据交换、批量导入 业务报表、数据分析 正式文件、合同、发票

💡 提示: 如果用户只是要「看一下数据」或「导入到其他系统」,CSV 是最佳选择——生成快、体积小、兼容性好。只有需要样式、公式或多 Sheet 时才用 Excel。PDF 适合需要打印或作为正式文档的场景。

1.2 常见的错误选型

错误做法: 所有导出场景都用 Excel,10 万行数据用 json2csv 转一下再用 xlsx 包生成

正确做法: 根据场景选格式,大数据量优先 CSV,需要样式时用 Excel 流式生成

// ❌ 错误:一次性加载所有数据到内存,再生成 Excel
const data = await db.query('SELECT * FROM orders') // 100万行全部加载
const workbook = XLSX.utils.book_new()
const worksheet = XLSX.utils.json_to_sheet(data) // 内存翻倍
XLSX.writeFile(workbook, 'orders.xlsx') // 同步写入,阻塞事件循环
// ✅ 正确:流式查询 + 流式写入
const cursor = db.collection('orders').find().batchSize(1000)
const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({ filename: 'orders.xlsx' })
const sheet = workbook.addWorksheet('Orders')
for await (const doc of cursor) {
  sheet.addRow(doc).commit() // 每行立即写入磁盘
}
await workbook.commit() // 最终提交

🚀 二、大数据量导出的流式架构

2.1 核心问题:内存控制

大数据量导出的核心矛盾是:数据量可能无限大,但服务器内存是有限的。解决方案只有一种——流式处理(Streaming)。从数据库到文件生成,全链路都必须是流式的。

全链路流式架构如下:

数据库游标 (Cursor)
    ↓ 流式读取(每次 1000 行)
数据转换层 (Transform Stream)
    ↓ 流式转换(字段映射、格式化)
文件写入层 (Writable Stream)
    ↓ 流式写入(直接写磁盘)
临时文件 → 完成后移动到存储

⚠️ 警告: 永远不要用 res.json(data)JSON.stringify(data) 来处理大数据量导出。JSON.stringify 会在序列化时将整个对象树保留在内存中,100 万行数据可能需要 2-4GB 内存。

2.2 Node.js 完整实现:百万行 Excel 流式导出

下面是使用 Node.js + ExcelJS 流式 API + MongoDB Cursor 的完整实现:

// 流式 Excel 导出服务 - Node.js + ExcelJS
import ExcelJS from 'exceljs'
import { MongoClient } from 'mongodb'
import { createWriteStream, renameSync } from 'fs'
import { join } from 'path'
import { randomUUID } from 'crypto'

const MONGO_URI = process.env.MONGO_URI || 'mongodb://localhost:27017'
const EXPORT_DIR = process.env.EXPORT_DIR || '/data/exports'

/**
 * 流式导出订单数据为 Excel
 * 内存占用恒定在 ~50MB 以下,无论数据量多大
 */
export async function exportOrdersToExcel(filter = {}, options = {}) {
  const {
    batchSize = 1000,      // MongoDB 每批拉取行数
    sheetName = '订单数据',  // Sheet 名称
    columns = null,         // 自定义列配置,null 则自动推导
  } = options

  const fileId = randomUUID()
  const tempPath = join(EXPORT_DIR, `${fileId}.tmp`)
  const finalPath = join(EXPORT_DIR, `${fileId}.xlsx`)

  // 1. 创建流式 WorkbookWriter(直接写磁盘,不缓存在内存)
  const workbook = new ExcelJS.stream.xlsx.WorkbookWriter({
    filename: tempPath,
    useStyles: true,        // 启用样式支持
    useSharedStrings: false, // 不共享字符串,节省内存
  })

  const sheet = workbook.addWorksheet(sheetName)

  // 2. 定义列(或从第一条数据自动推导)
  const client = new MongoClient(MONGO_URI)
  await client.connect()
  const db = client.db()
  const cursor = db.collection('orders').find(filter).batchSize(batchSize)

  // 从第一条数据推导列定义
  const firstDoc = await cursor.next()
  if (!firstDoc) {
    await workbook.commit()
    return { path: finalPath, rows: 0 }
  }

  const columnDefs = columns || Object.keys(firstDoc)
    .filter(k => !k.startsWith('_'))
    .map(key => ({
      header: key,
      key,
      width: Math.max(key.length * 2, 12),
    }))

  sheet.columns = columnDefs

  // 写入表头样式
  sheet.getRow(1).font = { bold: true, size: 11 }
  sheet.getRow(1).commit()

  // 3. 流式写入数据行
  let rowCount = 1
  const writeRow = async (doc) => {
    rowCount++
    const row = sheet.addRow(doc)
    row.commit() // 立即提交到磁盘,释放内存
  }

  // 先写第一条
  await writeRow(firstDoc)

  // 流式写入剩余数据
  for await (const doc of cursor) {
    await writeRow(doc)

    // 每 10000 行打印一次进度
    if (rowCount % 10000 === 0) {
      console.log(`[Export] 已写入 ${rowCount} 行...`)
    }
  }

  // 4. 提交并关闭
  await workbook.commit()
  await client.close()

  // 重命名临时文件为最终文件
  renameSync(tempPath, finalPath)

  console.log(`[Export] 完成,共 ${rowCount} 行,文件:${finalPath}`)
  return { path: finalPath, rows: rowCount, fileId }
}

⚠️ 警告: useSharedStrings: false 是一个关键优化。ExcelJS 默认会把所有单元格文本收集到一个共享字符串表中,在大数据量场景下这会消耗大量内存。设为 false 后每个单元格独立存储字符串,内存占用显著降低。

2.3 Python 完整实现:CSV 流式导出

CSV 导出天然适合流式处理,因为它是纯文本格式:

# 流式 CSV 导出 - Python + FastAPI
import csv
import io
import asyncio
from fastapi import FastAPI
from fastapi.responses import StreamingResponse
from motor.motor_asyncio import AsyncIOMotorClient

app = FastAPI()

class CSVStreamAdapter:
    """将异步数据源适配为 CSV 流式输出"""
    
    def __init__(self, cursor, fieldnames, encoding='utf-8-sig'):
        self.cursor = cursor
        self.fieldnames = fieldnames
        self.encoding = encoding
        self.buffer = io.BytesIO()
        # utf-8-sig 带 BOM 头,确保 Excel 打开中文不乱码
        self.writer = csv.DictWriter(
            io.TextIOWrapper(self.buffer, encoding=encoding, newline=''),
            fieldnames=fieldnames,
            extrasaction='ignore'
        )
        self._header_written = False
        self._exhausted = False

    async def __aiter__(self):
        """异步迭代器,每次 yield 一个 chunk"""
        if not self._header_written:
            self.writer.writeheader()
            self._header_written = True
            self.buffer.seek(0)
            chunk = self.buffer.read()
            self.buffer.seek(0)
            self.buffer.truncate()
            if chunk:
                yield chunk

        async for doc in self.cursor:
            # 字段值转换(处理 None、日期等特殊类型)
            row = {}
            for field in self.fieldnames:
                val = doc.get(field, '')
                if val is None:
                    row[field] = ''
                elif hasattr(val, 'isoformat'):
                    row[field] = val.isoformat()
                else:
                    row[field] = str(val)
            
            self.writer.writerow(row)
            self.buffer.seek(0)
            chunk = self.buffer.read()
            self.buffer.seek(0)
            self.buffer.truncate()
            if chunk:
                yield chunk

        self._exhausted = True


@app.get("/api/export/orders")
async def export_orders_csv():
    """流式导出订单为 CSV,内存占用恒定 < 10MB"""
    client = AsyncIOMotorClient('mongodb://localhost:27017')
    db = client.mydb
    cursor = db.orders.find({}).batch_size(1000)
    
    fieldnames = ['order_id', 'customer', 'amount', 'status', 'created_at']
    
    stream = CSVStreamAdapter(cursor, fieldnames)
    
    return StreamingResponse(
        stream,
        media_type='text/csv',
        headers={
            'Content-Disposition': 'attachment; filename="orders.csv"',
            'Content-Type': 'text/csv; charset=utf-8',
        }
    )

💡 提示: CSV 文件在 Windows Excel 中打开时,如果不是 UTF-8 with BOM 编码,中文会变成乱码。解决方案是使用 utf-8-sig 编码(自动添加 BOM 头),或者改用 \t 分隔的 TSV 格式。

🔧 三、异步导出任务队列架构

3.1 为什么不能用同步导出

同步导出的问题不仅仅是超时——更大的问题是资源竞争。当 10 个用户同时点击导出时,10 个数据库查询 + 10 个文件生成任务同时执行,直接把服务器打挂。

生产环境必须用异步导出架构:

用户点击导出
    ↓
API 返回任务 ID(立即响应)
    ↓
任务入队(Redis/BullMQ)
    ↓
Worker 进程异步执行导出
    ↓
完成后通知用户(WebSocket/轮询/邮件)
    ↓
用户下载文件

3.2 Node.js + BullMQ 完整实现

// 异步导出任务系统 - Node.js + BullMQ + Redis
import { Queue, Worker, QueueEvents } from 'bullmq'
import Redis from 'ioredis'
import { exportOrdersToExcel } from './export-service.js'

const connection = new Redis({ host: 'localhost', port: 6379, maxRetriesPerRequest: null })

// 1. 创建导出任务队列
const exportQueue = new Queue('data-export', {
  connection,
  defaultJobOptions: {
    attempts: 3,           // 失败重试 3 次
    backoff: { type: 'exponential', delay: 5000 },
    removeOnComplete: { age: 86400 },  // 完成 24 小时后自动清理
    removeOnFail: { age: 604800 },     // 失败 7 天后清理
  },
})

// 2. Worker 处理导出任务
const exportWorker = new Worker('data-export', async (job) => {
  const { type, filter, userId, options } = job.data
  
  console.log(`[Worker] 开始处理导出任务: ${job.id}, 类型: ${type}, 用户: ${userId}`)
  
  // 更新进度
  await job.updateProgress({ stage: 'querying', percent: 0 })
  
  let result
  switch (type) {
    case 'orders-excel':
      result = await exportOrdersToExcel(filter, {
        ...options,
        onProgress: async (rows) => {
          // 每 5000 行更新一次进度
          if (rows % 5000 === 0) {
            await job.updateProgress({ stage: 'exporting', rows })
          }
        },
      })
      break
    case 'users-csv':
      result = await exportUsersToCSV(filter, options)
      break
    default:
      throw new Error(`未知的导出类型: ${type}`)
  }
  
  await job.updateProgress({ stage: 'completed', percent: 100, ...result })
  return result
}, {
  connection,
  concurrency: 3,          // 同时最多处理 3 个导出任务
  limiter: { max: 10, duration: 60000 },  // 每分钟最多处理 10 个任务
})

// 3. Worker 事件监听(用于通知用户)
const events = new QueueEvents('data-export', { connection })
events.on('completed', ({ jobId, returnvalue }) => {
  console.log(`[Events] 任务 ${jobId} 完成:`, returnvalue)
  // 这里可以触发 WebSocket 通知或发送邮件
})
events.on('failed', ({ jobId, failedReason }) => {
  console.error(`[Events] 任务 ${jobId} 失败: ${failedReason}`)
})

// 4. API 接口:提交导出任务
async function submitExportJob(req, res) {
  const { type, filter, options } = req.body
  const userId = req.user.id
  
  // 检查是否有重复任务(防止用户重复点击)
  const existingJobs = await exportQueue.getJobs(['waiting', 'active'])
  const duplicate = existingJobs.find(
    j => j.data.userId === userId && j.data.type === type && 
         JSON.stringify(j.data.filter) === JSON.stringify(filter)
  )
  
  if (duplicate) {
    return res.json({ 
      jobId: duplicate.id, 
      message: '该导出任务已在处理中',
      status: await duplicate.getState(),
    })
  }
  
  const job = await exportQueue.add('export', {
    type, filter, userId, options,
  })
  
  res.json({ 
    jobId: job.id, 
    message: '导出任务已提交,请稍后下载',
  })
}

⚠️ 警告: concurrency: 3 是一个关键参数。导出是 I/O 密集型任务,但文件生成会消耗 CPU 和内存。并发数过高会导致 Worker 进程内存溢出。建议根据服务器配置调整,一般设为 CPU 核心数的 1/2。

3.3 任务状态查询与进度追踪

// 导出任务状态查询 API
async function getExportStatus(req, res) {
  const { jobId } = req.params
  
  const job = await exportQueue.getJob(jobId)
  if (!job) {
    return res.status(404).json({ error: '任务不存在' })
  }
  
  const state = await job.getState()
  const progress = job.progress
  
  const response = {
    jobId: job.id,
    status: state,
    progress,
    createdAt: new Date(job.timestamp).toISOString(),
    data: {
      type: job.data.type,
      filter: job.data.filter,
    },
  }
  
  // 如果任务完成,返回下载链接
  if (state === 'completed') {
    response.downloadUrl = `/api/exports/download/${progress.fileId}`
    response.rows = progress.rows
  }
  
  // 如果任务失败,返回错误信息
  if (state === 'failed') {
    response.error = job.failedReason
  }
  
  res.json(response)
}

💡 四、PDF 生成与中文排版实战

4.1 PDF 生成的技术选型

PDF 生成是三种格式中最复杂的,主要挑战在于中文排版。常见的方案对比:

方案 原理 中文支持 性能 适用场景
Puppeteer (Chrome) 浏览器渲染 → PDF ✅ 原生 ⭐⭐ 较慢 复杂排版、图表丰富
PDFKit 底层 PDF 绘制 ⚠️ 需嵌入字体 ⭐⭐⭐ 快 简单报表、发票
jsPDF Canvas 绘制 ⚠️ 需嵌入字体 ⭐⭐ 中等 前端生成 PDF
WeasyPrint (Python) CSS 渲染引擎 ✅ 原生 ⭐⭐ 较慢 HTML 转 PDF
ReportLab (Python) 底层 PDF 绘制 ⚠️ 需嵌入字体 ⭐⭐⭐ 快 精确排版

💡 提示: 如果你的 PDF 需要复杂的表格排版和中文支持,Puppeteer 是最省心的方案——直接用 HTML + CSS 写模板,浏览器原生渲染,中文、Emoji、RTL 文字全部原生支持,不需要嵌入任何字体文件。

4.2 Puppeteer PDF 生成完整实现

// PDF 生成服务 - Node.js + Puppeteer
import puppeteer from 'puppeteer'
import Handlebars from 'handlebars'

// PDF 模板(实际项目中应该放在独立的 .hbs 文件中)
const invoiceTemplate = `
<!DOCTYPE html>
<html>
<head>
  <meta charset="UTF-8">
  <style>
    * { margin: 0; padding: 0; box-sizing: border-box; }
    body { font-family: "Microsoft YaHei", "PingFang SC", sans-serif; padding: 40px; }
    .header { display: flex; justify-content: space-between; margin-bottom: 30px; }
    .logo { font-size: 24px; font-weight: bold; color: #1a1a1a; }
    .invoice-info { text-align: right; color: #666; font-size: 14px; }
    .invoice-info h2 { color: #2563eb; margin-bottom: 8px; }
    table { width: 100%; border-collapse: collapse; margin: 20px 0; }
    th { background: #f8fafc; padding: 12px; text-align: left; border-bottom: 2px solid #e2e8f0; font-size: 13px; }
    td { padding: 10px 12px; border-bottom: 1px solid #e2e8f0; font-size: 13px; }
    .amount { text-align: right; font-family: "Courier New", monospace; }
    .total-row td { font-weight: bold; border-top: 2px solid #1a1a1a; font-size: 15px; }
    .footer { margin-top: 40px; color: #999; font-size: 12px; text-align: center; }
    @media print { body { padding: 0; } }
  </style>
</head>
<body>
  <div class="header">
    <div class="logo">{{companyName}}</div>
    <div class="invoice-info">
      <h2>销售发票</h2>
      <p>发票编号:{{invoiceNo}}</p>
      <p>开票日期:{{date}}</p>
      <p>客户:{{customerName}}</p>
    </div>
  </div>
  <table>
    <thead>
      <tr>
        <th>序号</th>
        <th>商品名称</th>
        <th>规格</th>
        <th>数量</th>
        <th>单价</th>
        <th class="amount">金额</th>
      </tr>
    </thead>
    <tbody>
      {{#each items}}
      <tr>
        <td>{{@index}}</td>
        <td>{{name}}</td>
        <td>{{spec}}</td>
        <td>{{quantity}}</td>
        <td class="amount">¥{{price}}</td>
        <td class="amount">¥{{subtotal}}</td>
      </tr>
      {{/each}}
      <tr class="total-row">
        <td colspan="5">合计</td>
        <td class="amount">¥{{total}}</td>
      </tr>
    </tbody>
  </table>
  <div class="footer">
    <p>本发票由系统自动生成,如有疑问请联系财务部门</p>
  </div>
</body>
</html>
`

/**
 * 生成 PDF 发票
 * @param {Object} data - 发票数据
 * @returns {Buffer} PDF 文件 Buffer
 */
export async function generateInvoicePDF(data) {
  // 预编译模板(生产环境应缓存编译结果)
  const template = Handlebars.compile(invoiceTemplate)
  const html = template({
    ...data,
    items: data.items.map((item, i) => ({
      ...item,
      index: i + 1,
      subtotal: (item.quantity * item.price).toFixed(2),
    })),
    total: data.items
      .reduce((sum, item) => sum + item.quantity * item.price, 0)
      .toFixed(2),
  })

  // 启动浏览器实例(生产环境应使用连接池)
  const browser = await puppeteer.launch({
    headless: 'new',
    args: ['--no-sandbox', '--disable-setuid-sandbox'],
  })

  try {
    const page = await browser.newPage()
    await page.setContent(html, { waitUntil: 'networkidle0' })

    const pdfBuffer = await page.pdf({
      format: 'A4',
      printBackground: true,
      margin: { top: '20mm', bottom: '20mm', left: '15mm', right: '15mm' },
      displayHeaderFooter: true,
      headerTemplate: '<span></span>',
      footerTemplate: `
        <div style="width:100%;text-align:center;font-size:10px;color:#999;">
          第 <span class="pageNumber"></span> 页 / 共 <span class="totalPages"></span> 页
        </div>
      `,
    })

    return pdfBuffer
  } finally {
    await browser.close()
  }
}

⚠️ 警告: Puppeteer 的 launch() 会启动一个完整的 Chrome 进程,每次调用需要 200-500ms。生产环境必须使用浏览器池(browser pool),复用浏览器实例。可以使用 puppeteer-cluster 库或自建连接池,将并发浏览器实例数限制在 5 个以内。

4.3 PDF 浏览器连接池优化

// Puppeteer 连接池 - 避免反复启动浏览器
import puppeteer from 'puppeteer'

class BrowserPool {
  constructor(maxSize = 3) {
    this.pool = []
    this.maxSize = maxSize
    this.waiting = []
  }

  async acquire() {
    // 复用空闲浏览器
    if (this.pool.length > 0) {
      const browser = this.pool.pop()
      // 检查浏览器是否还活着
      if (browser.isConnected()) return browser
      await browser.close().catch(() => {})
    }
    // 池未满则创建新实例
    if (this.pool.length < this.maxSize) {
      return puppeteer.launch({
        headless: 'new',
        args: ['--no-sandbox', '--disable-setuid-sandbox', '--disable-dev-shm-usage'],
      })
    }
    // 池已满,等待释放
    return new Promise((resolve) => this.waiting.push(resolve))
  }

  release(browser) {
    if (this.waiting.length > 0) {
      const resolve = this.waiting.shift()
      resolve(browser)
    } else {
      this.pool.push(browser)
    }
  }

  async closeAll() {
    await Promise.all(this.pool.map(b => b.close().catch(() => {})))
    this.pool = []
  }
}

// 全局单例
export const browserPool = new BrowserPool(3)

// 使用示例
export async function generatePDFWithPool(html, options) {
  const browser = await browserPool.acquire()
  try {
    const page = await browser.newPage()
    await page.setContent(html, { waitUntil: 'networkidle0' })
    const pdf = await page.pdf(options)
    await page.close() // 关闭页面但保留浏览器
    return pdf
  } finally {
    browserPool.release(browser)
  }
}

📋 五、前端交互:下载进度与错误处理

5.1 前端下载进度追踪

异步导出场景下,前端需要展示完整的任务生命周期:

// 前端导出管理器 - 支持进度追踪和自动下载
class ExportManager {
  constructor(baseUrl) {
    this.baseUrl = baseUrl
    this.polling = new Map() // jobId -> timer
  }

  /**
   * 提交导出任务并追踪进度
   * @param {string} type - 导出类型
   * @param {Object} filter - 筛选条件
   * @param {Function} onProgress - 进度回调
   * @returns {Promise<{downloadUrl: string}>}
   */
  async submit(type, filter, onProgress) {
    // 1. 提交任务
    const res = await fetch(`${this.baseUrl}/api/export/submit`, {
      method: 'POST',
      headers: { 'Content-Type': 'application/json' },
      body: JSON.stringify({ type, filter }),
    })
    const { jobId, message } = await res.json()

    // 2. 轮询任务状态
    return new Promise((resolve, reject) => {
      const timer = setInterval(async () => {
        try {
          const statusRes = await fetch(`${this.baseUrl}/api/export/status/${jobId}`)
          const status = await statusRes.json()

          // 通知进度
          onProgress?.(status)

          if (status.status === 'completed') {
            clearInterval(timer)
            this.polling.delete(jobId)
            
            // 3. 自动触发下载
            await this.download(status.downloadUrl, `${type}-${jobId}.xlsx`)
            resolve(status)
          }

          if (status.status === 'failed') {
            clearInterval(timer)
            this.polling.delete(jobId)
            reject(new Error(status.error || '导出失败'))
          }
        } catch (err) {
          clearInterval(timer)
          this.polling.delete(jobId)
          reject(err)
        }
      }, 2000) // 每 2 秒轮询一次

      this.polling.set(jobId, timer)
    })
  }

  async download(url, filename) {
    const a = document.createElement('a')
    a.href = url
    a.download = filename
    document.body.appendChild(a)
    a.click()
    document.body.removeChild(a)
  }

  // 取消所有轮询
  cancelAll() {
    for (const timer of this.polling.values()) {
      clearInterval(timer)
    }
    this.polling.clear()
  }
}

// 使用示例
const exporter = new ExportManager('https://api.example.com')

document.getElementById('btn-export').addEventListener('click', async () => {
  const btn = document.getElementById('btn-export')
  btn.disabled = true
  btn.textContent = '导出中...'

  try {
    await exporter.submit('orders-excel', { status: 'completed' }, (status) => {
      if (status.progress?.rows) {
        btn.textContent = `导出中... 已处理 ${status.progress.rows} 行`
      }
    })
    btn.textContent = '导出完成 ✓'
  } catch (err) {
    btn.textContent = `导出失败: ${err.message}`
  } finally {
    setTimeout(() => {
      btn.disabled = false
      btn.textContent = '导出数据'
    }, 3000)
  }
})

5.2 文件下载与过期清理

// 文件下载 API + 过期清理
import { createReadStream, existsSync, statSync, unlinkSync, readdirSync } from 'fs'
import { join } from 'path'

const EXPORT_DIR = '/data/exports'
const FILE_EXPIRY_MS = 24 * 60 * 60 * 1000 // 24 小时过期

// 下载接口
function downloadExport(req, res) {
  const { fileId } = req.params
  const filePath = join(EXPORT_DIR, `${fileId}.xlsx`)
  
  if (!existsSync(filePath)) {
    return res.status(404).json({ error: '文件不存在或已过期' })
  }
  
  const stat = statSync(filePath)
  
  res.setHeader('Content-Type', 'application/vnd.openxmlformats-officedocument.spreadsheetml.sheet')
  res.setHeader('Content-Disposition', `attachment; filename="export-${fileId}.xlsx"`)
  res.setHeader('Content-Length', stat.size)
  
  const stream = createReadStream(filePath)
  stream.pipe(res)
}

// 定时清理过期文件(每小时执行一次)
function cleanupExpiredFiles() {
  const now = Date.now()
  const files = readdirSync(EXPORT_DIR)
  
  let cleaned = 0
  for (const file of files) {
    if (file === '.gitkeep') continue
    const filePath = join(EXPORT_DIR, file)
    const stat = statSync(filePath)
    
    if (now - stat.mtimeMs > FILE_EXPIRY_MS) {
      unlinkSync(filePath)
      cleaned++
    }
  }
  
  if (cleaned > 0) {
    console.log(`[Cleanup] 清理了 ${cleaned} 个过期导出文件`)
  }
}

// 每小时执行一次清理
setInterval(cleanupExpiredFiles, 60 * 60 * 1000)

✅ 六、最佳实践与避坑指南

6.1 数据库查询优化

导出性能的瓶颈往往不在文件生成,而在数据库查询。以下是关键优化点:

  • 使用覆盖索引(Covering Index):为导出常用的查询条件和排序字段建立复合索引
  • 使用游标(Cursor)代替 skip/limit 分页skip(100000).limit(1000) 在大数据量下性能极差
  • 限制导出字段:不要 SELECT *,只查需要的字段
  • 避免在导出查询中使用 $lookup(JOIN):大量 JOIN 会严重拖慢查询速度,建议用宽表或预聚合
  • ⚠️ 设置合理的查询超时:防止慢查询长时间占用数据库连接
// ❌ 错误:使用 skip/limit 分页,第 100 页时性能急剧下降
for (let page = 0; page < totalPages; page++) {
  const data = await db.orders.find({}).skip(page * 1000).limit(1000).toArray()
  // skip(100000) 需要扫描前 10 万行,越来越慢
}

// ✅ 正确:使用游标流式读取,性能恒定
const cursor = db.orders.find({}).batchSize(1000)
for await (const doc of cursor) {
  // 每次只从网络读取一个 batch,性能恒定
  await writeRow(doc)
}

6.2 生产环境 Checklist

检查项 说明 优先级
异步任务队列 大数据量导出必须走队列,不允许同步导出 🔴 必须
并发限制 限制同时执行的导出任务数(建议 3-5) 🔴 必须
文件过期清理 自动删除超过 24 小时的导出文件 🔴 必须
重复任务去重 防止用户重复点击产生多个相同任务 🟡 建议
导出权限控制 检查用户是否有权限导出指定数据 🔴 必须
数据脱敏 导出数据中的手机号、身份证号需要脱敏 🔴 必须
导出审计日志 记录谁在什么时间导出了什么数据 🟡 建议
文件大小限制 单个导出文件限制(建议 100MB) 🟡 建议
进度反馈 前端展示导出进度,避免用户反复点击 🟡 建议
错误重试 任务失败后自动重试(建议 3 次指数退避) 🟡 建议

📌 记住: 数据导出是一个容易被忽视但影响面极广的功能。一个设计良好的导出系统应该做到:用户点击后立即得到反馈,任务在后台安静执行,完成后通知下载,文件自动过期清理。整个过程对用户透明,对服务器无感。

🎯 总结

构建企业级数据导出系统的核心原则可以归纳为三点:

  1. 流式处理是底线:从数据库到文件写入,全链路流式,内存占用恒定
  2. 异步是必须:导出任务必须走队列,不允许阻塞 API 响应
  3. 用户体验要完整:提交 → 进度 → 通知 → 下载 → 清理,闭环设计

技术选型建议:

  • CSV 导出:用原生 Stream API,性能最好,适合数据交换场景
  • Excel 导出:用 ExcelJS(Node.js)或 openpyxl(Python)的流式 API,避免全量加载
  • PDF 导出:用 Puppeteer 浏览器渲染方案,中文支持最好,排版最灵活
  • 任务队列:BullMQ + Redis 是 Node.js 生态的最佳选择,Python 可用 Celery + Redis

相关工具推荐:

📚 相关文章