纯 Go 实现:百万级数据高效导出 Excel(以及 CSV)实战

1,076 阅读6分钟

纯 Go 实现:百万级数据高效导出 Excel(以及 CSV)实战

只用 Go,一篇文章吃透“大数据导出”的性能坑流式思路、与可直接跑的完整示例
重点:极低内存可中断可并发可在 HTTP 中直接下载


为什么大数据导出总“炸内存”?

  • 一次性加载:先查全量数据再写文件 → O(N) 内存直接拉满。
  • 非流式写入:许多库默认把整表缓存在内存里,再一次性落盘。
  • 文件格式差异:XLSX 是压缩的 XML 打包;CSV 则是纯文本。CSV 写入更快内存更省,但不支持样式/合并等。
  • 先攒内存再输出:比如写到 bytes.Buffer / WriteToBuffer(),文件越大越容易爆内存;大文件应尽量直接写 io.Writer(文件或 HTTP 响应)。

核心原则分页查询 + 流式写入 + 限流并发 + 可中断(Context)


技术选型(纯 Go)

格式库/包优点注意点
XLSX[github.com/xuri/excelize/v2]支持样式/多表/列宽/冻结窗格;有StreamWriter写样式需要谨慎,流式写下不建议频繁变更样式
CSV标准库 encoding/csv依赖零、最快、最省内存无样式、用 Excel 打开时默认编码/分隔符需注意

目录

  1. 最小可跑:流式写出 100 万行 XLSX
  2. 极致轻量:CSV 流式导出
  3. HTTP 直接下载(无中间文件)
  4. 数据库分页:生产-消费流式管道
  5. 取消/限流/超时与资源回收
  6. 样式/列宽/冻结窗格
  7. 常见坑与对策清单
  8. 总结

最小可跑:流式写出 100 万行 XLSX

  • 关键:StreamWriter + SetRow + Flush,最后 f.Write(io.Writer)/f.SaveAs(path)
  • 内存监控:runtime.ReadMemStats
// go mod init example && go get github.com/xuri/excelize/v2
package main

import (
	"fmt"
	"math/rand"
	"runtime"
	"strconv"
	"time"

	"github.com/xuri/excelize/v2"
)

func main() {
	start := time.Now()
	f := excelize.NewFile()
	defer f.Close()

	const sheet = "Sheet1"
	sw, err := f.NewStreamWriter(sheet)
	must(err)

	// 写表头
	header := []interface{}{"ID", "Name", "Amount", "CreatedAt"}
	cell, _ := excelize.CoordinatesToCellName(1, 1)
	must(sw.SetRow(cell, header))

	// 模拟写 1,000,000 行
	n := 1_000_000
	for i := 1; i <= n; i++ {
		row := []interface{}{
			i,
			"User_" + strconv.Itoa(i),
			rand.Intn(10_000),
			time.Now().Add(time.Duration(i) * time.Second).Format(time.RFC3339),
		}
		cell, _ := excelize.CoordinatesToCellName(1, i+1)
		must(sw.SetRow(cell, row))

		// 每 50k 行打印一次内存占用
		if i%50_000 == 0 {
			var m runtime.MemStats
			runtime.ReadMemStats(&m)
			fmt.Printf("[progress] rows=%d heap=%.2fMB\n", i, float64(m.HeapAlloc)/1024.0/1024.0)
		}
	}

	must(sw.Flush())

	// 可选:冻结首行 + 自动列宽(注意:自动列宽对流式无感,需在 Flush 后做固定宽度)
	// 冻结首行
	must(f.SetPanes(sheet, &excelize.Panes{
		Freeze:      true,
		YSplit:      1,
		ActivePane:  "bottomLeft",
		TopLeftCell: "A2",
	}))

	// 保存到磁盘(也可改为写到 HTTP ResponseWriter,见后文)
	must(f.SaveAs("bigdata.xlsx"))

	fmt.Printf("done in %v\n", time.Since(start))
}

func must(err error) {
	if err != nil {
		panic(err)
	}
}

效果:在普通开发机上可稳定导出百万行,堆内存通常几十到百余 MB(视系统/版本而定),远低于一次性构建整表的做法。

极致轻量:CSV 流式导出

  • 零依赖、写入速度快、最省内存。
  • Excel 可以直接打开(注意编码/分隔符)。
package main

import (
	"encoding/csv"
	"fmt"
	"os"
	"strconv"
)

func main() {
	file, err := os.Create("bigdata.csv")
	if err != nil {
		panic(err)
	}
	defer file.Close()

	w := csv.NewWriter(file)
	defer w.Flush()

	// 表头
	_ = w.Write([]string{"ID", "Name", "Age"})

	const n = 1_000_000
	for i := 1; i <= n; i++ {
		_ = w.Write([]string{
			strconv.Itoa(i),
			"User_" + strconv.Itoa(i),
			strconv.Itoa(18 + i%30),
		})
		if i%100_000 == 0 {
			fmt.Println("rows:", i)
			w.Flush() // 显式冲刷,降低缓冲占用
		}
	}
}

HTTP 直接下载(无中间文件)

  • 无需中间文件:最终直接写入 http.ResponseWriter(不落盘、不用 bytes.Buffer)。
  • 说明:CSV 可以真正逐行写入并 Flush();XLSX(ZIP)也可以直接写到响应体,但通常是在最终 f.Write(w) 写出阶段客户端开始持续接收数据,并不等价于“每写一行就立刻发到客户端”(还会受浏览器/反代缓冲影响)。

XLSX:流式写到 ResponseWriter(示例代码)

package main

import (
	"log"
	"net/http"
	"strconv"
	"time"

	"github.com/xuri/excelize/v2"
)

func main() {
	http.HandleFunc("/export/xlsx", exportXLSX)
	http.HandleFunc("/export/csv", exportCSV)
	log.Println("listen :8080")
	log.Fatal(http.ListenAndServe(":8080", nil))
}

func exportXLSX(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet")
	w.Header().Set("Content-Disposition", "attachment; filename=\"report.xlsx\"")

	f := excelize.NewFile()
	defer f.Close()

	sw, err := f.NewStreamWriter("Sheet1")
	if err != nil {
		http.Error(w, err.Error(), 500)
		return
	}
	_ = sw.SetRow("A1", []interface{}{"ID", "Name", "Amount", "CreatedAt"})

	n := 200_000 // 从 query 或业务层拿
	for i := 1; i <= n; i++ {
		cell, _ := excelize.CoordinatesToCellName(1, i+1)
		_ = sw.SetRow(cell, []interface{}{
			i,
			"User_" + strconv.Itoa(i),
			i % 1000,
			time.Now().Format(time.RFC3339),
		})
	}
	_ = sw.Flush()

	// 直接写到 ResponseWriter
	if err := f.Write(w); err != nil {
		// 客户端中断下载常见:write: broken pipe
		log.Println("write err:", err)
	}
}

func exportCSV(w http.ResponseWriter, r *http.Request) {
	w.Header().Set("Content-Type", "text/csv; charset=utf-8")
	w.Header().Set("Content-Disposition", "attachment; filename=\"report.csv\"")

	cw := NewCSVStream(w)
	defer cw.Flush()

	_ = cw.Write([]string{"ID", "Name", "Age"})
	for i := 1; i <= 500_000; i++ {
		_ = cw.Write([]string{
			strconv.Itoa(i),
			"User_" + strconv.Itoa(i),
			strconv.Itoa(18 + i%30),
		})
	}
}

// 轻薄包装:将 http.ResponseWriter 适配成 csv.Writer
type CSVStream struct{ w *csv.Writer }

func NewCSVStream(w http.ResponseWriter) *CSVStream { return &CSVStream{w: csv.NewWriter(w)} }
func (c *CSVStream) Write(rec []string) error       { return c.w.Write(rec) }
func (c *CSVStream) Flush()                         { c.w.Flush() }

数据库分页:生产-消费流式管道

  • 目标:不在内存里堆积全量数据。
  • 方案:分页拉取(或游标查询)→ 放入 channel → 单一 writer 顺序写出。
package main

import (
	"context"
	"database/sql"
	"fmt"
	"time"

	_ "modernc.org/sqlite" // 为了示例,用 SQLite;生产用 MySQL/PostgreSQL 驱动
)

type Row struct {
	ID        int64
	Name      string
	Amount    int64
	CreatedAt time.Time
}

func StreamRows(ctx context.Context, db *sql.DB, pageSize int, out chan<- Row) error {
	defer close(out)

	offset := 0
	for {
		// 替换为你的 SQL(MySQL 推荐 keyset/游标翻页)
		rows, err := db.QueryContext(ctx,
			`SELECT id, name, amount, created_at FROM t ORDER BY id LIMIT ? OFFSET ?`,
			pageSize, offset,
		)
		if err != nil {
			return err
		}
		n := 0
		for rows.Next() {
			var r Row
			if err := rows.Scan(&r.ID, &r.Name, &r.Amount, &r.CreatedAt); err != nil {
				_ = rows.Close()
				return err
			}
			select {
			case out <- r: // 下游慢时自然限速
			case <-ctx.Done():
				_ = rows.Close()
				return ctx.Err()
			}
			n++
		}
		_ = rows.Close()
		if n == 0 {
			return nil // 完成
		}
		offset += n
	}
}

func ExamplePipeline(ctx context.Context, db *sql.DB) error {
	out := make(chan Row, 1024) // 小缓冲,避免放大内存
	go func() {
		_ = StreamRows(ctx, db, 5000, out)
	}()

	// 在这里对接 XLSX/CSV Writer(单写者,保持顺序)
	for r := range out {
		// sw.SetRow(...)/csv.Write(...)
		_ = r
	}
	return ctx.Err()
}

func main() {
	fmt.Println("见 ExamplePipeline;将其与前文 XLSX/CSV 写入代码拼装即可。")
}

Keyset 翻页(基于上次最大 ID)在 MySQL 大表场景下更稳更快,避免 OFFSET 深翻导致的回表代价。

取消/限流/超时与资源回收

  • Context:贯穿 DB 查询、业务、Writer,允许用户在前端取消下载时快速停水关闸
  • 限流:time.Ticker 控制每秒写入/查询页数,避免压垮 DB。
  • 回收:defer rows.Close()/file.Close()/sw.Flush(),对网络中断容错。
ctx, cancel := context.WithTimeout(r.Context(), 5*time.Minute) // HTTP 请求 5 分钟超时
defer cancel()

// 在所有 QueryContext/SetRow 处传递 ctx,遇到取消/超时立即停止

样式/列宽/冻结窗格

  • 冻结首行:见前文 SetPanes 示例。
  • 列宽:SetColWidth(sheet, "A", "D", 18),注意:StreamWriter 模式下不要频繁改动,否则性能受损。
  • 样式:可用 NewStyle + SetCellStyle,但大数据导出建议样式极简(仅表头设一下)。
styleID, _ := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
_ = f.SetCellStyle("Sheet1", "A1", "D1", styleID)
_ = f.SetColWidth("Sheet1", "A", "D", 18)

常见坑与对策清单

1. 导出极慢

  • 关掉逐格设置样式/公式;使用 SetRow 成批写

2. 内存持续上涨

  • 确认使用 StreamWriter;减少中间切片累积;分页尺寸控制在 1k~10k。

3.浏览器中断下载

  • 日志常见 broken pipe,属正常情况;上游应放弃继续写入并回收资源。

4.Excel 打开 CSV 乱码

  • 使用 ; charset=utf-8;Windows 场景可考虑 BOM(\xEF\xBB\xBF)或导出 XLSX。

5.OFFSET 翻页抖动

  • 大表使用 keyset(WHERE id > last_id ORDER BY id LIMIT ?)。

6.导出耗时过长

  • 优化 SQL(只查必要列、加索引)、缩小时间/条件范围、服务端先异步生成后提供下载(任务轮询)。

7.并发顺序问题

  • 多协程查询 + 单 Writer 顺序写;或多 Sheet 并行,每个 Sheet 一个 Writer。

总结

  • 首选 CSV:海量数据、追求极致性能与稳定,CSV 基本无敌。
  • 需要样式就上 XLSX:务必使用 StreamWriter,样式从简,分页拉取。
  • 端到端流式:DB → channel → 单写者 → 文件/HTTP,配合 Context 实现可中断与资源即时回收。