Excel 百万级数据导出性能优化
工单系统的报表导出功能,一开始用的 xlsx 库直接在前端生成 Excel 文件。数据量小的时候没问题,但当运营需要导出半年的工单数据(100 万+ 行)时,浏览器直接崩了。
问题分析
100 万行数据 × 每行 15 列 = 1500 万个单元格。xlsx 库会把整个工作簿构建为一个内存中的 JS 对象,峰值内存占用可达 2-3GB,直接触发浏览器标签页崩溃。
核心问题:一次性把所有数据加载到内存。
方案演进
方案一:后端流式导出(推荐)
最彻底的方案是把导出逻辑放到后端。Java 用 Apache POI 的 SXSSFWorkbook(流式模式),Python 用 openpyxl 的 write_only 模式,内存占用恒定。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19
| SXSSFWorkbook workbook = new SXSSFWorkbook(100); Sheet sheet = workbook.createSheet("工单数据");
int rowNum = 0; for (WorkOrder order : workOrderService.findAll()) { Row row = sheet.createRow(rowNum++); row.createCell(0).setCellValue(order.getId()); row.createCell(1).setCellValue(order.getTitle()); row.createCell(2).setCellValue(order.getStatus()); if (rowNum % 100 == 0) { workbook.write(tempOutputStream); } }
workbook.write(outputStream); workbook.dispose();
|
SXSSFWorkbook 的核心原理是只在内存中保留最近 100 行,之前的行已经写到磁盘临时文件了。内存占用从 O(n) 降到 O(100)。
方案二:前端分片导出
如果必须在前端导出(比如数据来自前端已有的表格),可以用分片策略:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43
| import { ref } from 'vue' import * as XLSX from 'xlsx'
export function useExcelExport() { const progress = ref(0) const isExporting = ref(false)
async function exportLargeData(data: any[], filename: string) { isExporting.value = true progress.value = 0
const CHUNK_SIZE = 10000 const chunks = Math.ceil(data.length / CHUNK_SIZE) const wb = XLSX.utils.book_new()
const ws = XLSX.utils.aoa_to_sheet([])
for (let i = 0; i < chunks; i++) { const start = i * CHUNK_SIZE const end = Math.min(start + CHUNK_SIZE, data.length) const chunk = data.slice(start, end)
XLSX.utils.sheet_add_json(ws, chunk, { origin: -1, skipHeader: i > 0, })
progress.value = Math.round(((i + 1) / chunks) * 100)
await new Promise(resolve => setTimeout(resolve, 0)) }
XLSX.utils.book_append_sheet(wb, ws, '数据') XLSX.writeFile(wb, filename) isExporting.value = false }
return { progress, isExporting, exportLargeData } }
|
方案三:Web Worker + 流式写入
把文件生成放到 Web Worker 里,主线程保持响应:
1 2 3 4 5 6 7 8 9 10
| self.onmessage = function(e) { const { data, columns } = e.data const wb = XLSX.utils.book_new() const ws = XLSX.utils.json_to_sheet(data, { header: columns })
const buffer = XLSX.write(wb, { type: 'array', bookType: 'xlsx' }) self.postMessage({ buffer }, [buffer]) }
|
数据库层面优化
导出慢的另一个原因是查询慢。100 万条数据的 SELECT * 本身就很耗时。
1 2 3 4 5 6 7 8 9 10 11
| SELECT * FROM work_orders WHERE created_at >= '2026-01-01'
DECLARE cur CURSOR FOR SELECT id, title, status, created_at FROM work_orders WHERE created_at >= '2026-01-01' ORDER BY created_at
|
关键优化点:
- 只查需要的字段 — 不要
SELECT *
- 用游标而非 LIMIT/OFFSET — 百万级数据 OFFSET 会越来越慢
- 加合适的索引 — 导出条件字段必须有索引
前端进度条
导出时间长,进度条是必须的:
1 2 3 4 5 6
| <template> <div v-if="isExporting" class="export-progress"> <div class="progress-bar" :style="{ width: progress + '%' }" /> <span>{{ progress }}% — 正在生成文件...</span> </div> </template>
|
方案对比
| 方案 |
内存占用 |
速度 |
复杂度 |
适用场景 |
| xlsx 一次性生成 |
O(n) 极高 |
慢 |
低 |
< 1万行 |
| 前端分片导出 |
O(1万) |
中 |
中 |
1-10万行 |
| Web Worker |
O(1万) 主线程空闲 |
中 |
中 |
需要保持 UI 响应 |
| 后端流式导出 |
O(100) |
快 |
高 |
> 10万行 |
最终方案
我们的选择是后端流式导出 + 前端进度条。超过 5 万行就走后端,前端只负责发起请求和显示进度。用户体验好,服务器内存也扛得住。
如果后端实在改不了,前端分片 + Web Worker 是保底方案,但要注意分片大小和主线程让出的节奏。