数据导出是企业应用中最常见、却最容易被低估的功能。根据 Segment 2025 年的企业数据报告,超过 78% 的企业用户每周至少使用一次数据导出功能,而导出失败是 B 端系统投诉的 Top 3 原因之一。一个写得不好的导出接口,可以在用户点击「导出」按钮的瞬间把服务器内存打爆——我就亲眼见过一个 SELECT * FROM orders 加 JSON.stringify 的导出实现,直接让 Node.js 进程 OOM 崩溃,连带拖垮了同机部署的其他服务。
本文不讲 Hello World,只讲生产环境真正会遇到的问题:百万行 Excel 怎么流式生成?CSV 编码乱码怎么彻底解决?PDF 中文排版怎么不踩坑?异步导出任务怎么设计? 每个方案都有完整可运行的代码,每个坑都是真实线上踩过的。
📌 记住: 数据导出不是「查出来然后写文件」这么简单。它是一个涉及数据库查询优化、内存管理、文件流式写入、异步任务调度、前端交互反馈的完整系统工程。
📊 一、三种导出格式的技术选型与适用场景
1.1 格式对比全景表
在动手写代码之前,先搞清楚该用什么格式。很多开发者习惯性地所有导出都用 Excel,这是一个常见的误区。
| 对比维度 | CSV | Excel (.xlsx) | |
|---|---|---|---|
| 文件体积 | ⭐⭐⭐ 极小(纯文本) | ⭐⭐ 中等(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 次指数退避) | 🟡 建议 |
📌 记住: 数据导出是一个容易被忽视但影响面极广的功能。一个设计良好的导出系统应该做到:用户点击后立即得到反馈,任务在后台安静执行,完成后通知下载,文件自动过期清理。整个过程对用户透明,对服务器无感。
🎯 总结
构建企业级数据导出系统的核心原则可以归纳为三点:
- 流式处理是底线:从数据库到文件写入,全链路流式,内存占用恒定
- 异步是必须:导出任务必须走队列,不允许阻塞 API 响应
- 用户体验要完整:提交 → 进度 → 通知 → 下载 → 清理,闭环设计
技术选型建议:
- CSV 导出:用原生 Stream API,性能最好,适合数据交换场景
- Excel 导出:用 ExcelJS(Node.js)或 openpyxl(Python)的流式 API,避免全量加载
- PDF 导出:用 Puppeteer 浏览器渲染方案,中文支持最好,排版最灵活
- 任务队列:BullMQ + Redis 是 Node.js 生态的最佳选择,Python 可用 Celery + Redis
相关工具推荐:
- 🔧 ExcelJS — Node.js 流式 Excel 读写库
- 🔧 BullMQ — 基于 Redis 的高性能任务队列
- 🔧 Puppeteer — Chrome 无头浏览器,PDF 生成利器
- 🔧 jsjson.com JSON 格式化工具 — 在线 JSON 格式化与校验
- 🔧 jsjson.com Base64 编码工具 — 文件 Base64 编解码