导出一次,OOM 一次
统一支付平台每月初要给商户出对账单,大商户一个月的流水能到百万行量级。最早的版本用 excelize.NewFile + SetSheetRow 一行行写,跑一次导出,内存直接吃掉几个 G,OOM 被杀是常事,运营还老过来催:“怎么还没生成好?”
我当时要做的,就是把这个导出改造成稳定跑百万行、内存可控、耗时可接受。选型上我们本来就在用 xuri/excelize,它的 StreamWriter 就是为这种场景设计的。真正的问题不在库,在于整条链路都得改成流式。
读、写、传,全改成流式
核心思路三条:
- 数据库游标分页读取,用 ID 翻页而不是
OFFSET,避开深分页; - Excelize StreamWriter 按行写入,写完立即刷盘,不在内存里攒所有行;
- 文件边写边传 S3/MinIO,用
io.Pipe 把 Excelize 的输出直接对接 SDK 的上传流,不落本地磁盘。
再用 goroutine 把"读 DB"和"写 Excel"做成生产者-消费者,通道容量控制在几千行,背压自然形成。

落到代码上
StreamWriter 的基础用法是这样,关键是记得 Flush 结束:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
| f := excelize.NewFile()
defer f.Close()
sw, err := f.NewStreamWriter("Sheet1")
if err != nil {
return err
}
styleID, _ := f.NewStyle(&excelize.Style{Font: &excelize.Font{Bold: true}})
_ = sw.SetRow("A1", []interface{}{
excelize.Cell{Value: "订单号", StyleID: styleID},
excelize.Cell{Value: "交易时间", StyleID: styleID},
excelize.Cell{Value: "金额", StyleID: styleID},
excelize.Cell{Value: "手续费", StyleID: styleID},
excelize.Cell{Value: "状态", StyleID: styleID},
})
|
生产者按 ID 游标翻页:
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
| func streamOrders(ctx context.Context, db *gorm.DB, merchantID string, month string,
ch chan<- []Order) error {
defer close(ch)
var lastID int64
const pageSize = 2000
for {
var rows []Order
err := db.WithContext(ctx).
Where("merchant_id = ? AND month = ? AND id > ?", merchantID, month, lastID).
Order("id ASC").Limit(pageSize).Find(&rows).Error
if err != nil {
return err
}
if len(rows) == 0 {
return nil
}
select {
case ch <- rows:
case <-ctx.Done():
return ctx.Err()
}
lastID = rows[len(rows)-1].ID
if len(rows) < pageSize {
return nil
}
}
}
|
消费者拿到一批就调 SetRow,注意行号要自己维护:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
| rowIdx := 2
for batch := range ch {
for _, o := range batch {
cell := []interface{}{
o.OutTradeNo,
o.CreatedAt.Format("2006-01-02 15:04:05"),
o.Amount.StringFixed(2),
o.Fee.StringFixed(2),
o.Status,
}
cellRef, _ := excelize.CoordinatesToCellName(1, rowIdx)
if err := sw.SetRow(cellRef, cell); err != nil {
return err
}
rowIdx++
}
}
if err := sw.Flush(); err != nil {
return err
}
|
最关键的一步是边写边传 S3。io.Pipe 把 Write 变成 Reader:
1
2
3
4
5
6
7
8
9
10
11
| pr, pw := io.Pipe()
go func() {
err := f.Write(pw)
pw.CloseWithError(err)
}()
_, err = s3Client.PutObject(ctx, &s3.PutObjectInput{
Bucket: aws.String(bucket),
Key: aws.String(key),
Body: pr,
})
|
f.Write(pw) 会在 StreamWriter 刷盘时往 Pipe 里写,S3 SDK 那头并发读,全程磁盘上不产生临时文件。
五个坑
第一,单个 sheet 行数上限。Excel 一个 sheet 最多 1048576 行,写到 100 万行时主动新建 Sheet2、Sheet3,表头重复写一次。StreamWriter 在 sheet 之间切换要先 Flush 旧的再 NewStreamWriter 新的。
第二,时间格式和数字格式。直接写字符串虽然省事,但商户拿到后没法在 Excel 里求和。金额我加了数字格式:
1
2
3
| moneyStyle, _ := f.NewStyle(&excelize.Style{NumFmt: 2}) // 0.00
_ = sw.SetColStyle("C", moneyStyle)
_ = sw.SetColStyle("D", moneyStyle)
|
第三,GORM 的游标内存。即使分页 2000,GORM 默认会把结果映射到结构体切片,只要及时释放引用,GC 能正常回收。但要注意别在循环外持有 rows 的引用,否则整批都不释放。
第四,Pipe 的错误传播。如果 S3 上传失败,pr 会先被关闭,但 f.Write(pw) 那一侧还在写,必须通过 pw.CloseWithError(err) 让它感知到,否则 goroutine 泄漏。生产里我还加了一个 context.AfterFunc 做兜底。
第五,耗时与内存的权衡。4C8G 的 Pod 里测下来,百万行导出稳定在 100MB 内存以内,耗时约 40 秒。想再快,可以按商户分 shard 并行导出多个文件再合并,但运维复杂度上来了,当前规模没必要。
改造之后
改造后对账导出再没 OOM 过,运营也不再追着要文件。回头看,百万行 Excel 导出的关键是整条链路都流式:数据库流式读、Excel 流式写、对象存储流式传,任何一环攒在内存里都会爆。Excelize 的 StreamWriter 已经把最难的 XML 分片写做掉了,应用层只要把生产和消费解耦,加上背压,就能稳定跑下来。
封面图:NYC Wanderer / Flickr · CC BY-SA 2.0