go基于协程对100w数据量的excel写与读

2,820 阅读7分钟

execl数据的写入读取在业务中常有的事情,当然一般数据量实际并不会很大,但也还是存在该特殊情况写入数据有100w或者更多,具体业务场景不做分析,本文以实现功能为目标会基于go通过协程与流实现生成或读取100w数据量的execl文件。

用例代码地址:gitee.com/dn-jinmin/s…

前言

在项目中execl导入导出的场景较多,因此抽一些时间对该功能进行总结,通过该案例你可以了解到如下内容:

  1. 协程的运用
  2. 基于协程的execl读写
  3. 缓存于流的区别

1. 准备

首先你需要先下载excelize包,建议用v2系列的版本

go get github.com/360EntSecGroup-Skylar/excelize/v2@v2.3.0

数据对象:本文采取的数据对象为user(name,phone,email,age,other)

type User struct {
    name  string
    phone string
    email string
    age   int
    other string
}

2. 基础:读写

先简要的提供一下关于go对execl的读写示例

写: 使用 excelize 库来写入 Excel 文件。以下是一个简单的示例代码:

package main

import (
    "fmt"
    "github.com/360EntSecGroup-Skylar/excelize/v2"
)

type Person struct {
    Name  string
    Phone string
    Email string
    Age   int
    Other string
}

func main() {
    execlPath := "test.xlsx"

    persons := []Person{
        {Name: "Tom", Phone: "13888888888", Email: "tom@example.com", Age: 20, Other: "other"},
        {Name: "Jerry", Phone: "13999999999", Email: "jerry@example.com", Age: 22, Other: "other"},
    }

    // 新建一个工作簿(excel 文件)
    file := excelize.NewFile()

    // 新建一个 Sheet,命名为 Sheet1
    sheetName := "Sheet1"
    sheet := file.NewSheet(sheetName)

    // 写入字段属性行,从 A1 开始
    file.SetCellValue(sheetName, "A1", "name")
    file.SetCellValue(sheetName, "B1", "phone")
    file.SetCellValue(sheetName, "C1", "email")
    file.SetCellValue(sheetName, "D1", "age")
    file.SetCellValue(sheetName, "E1", "other")

    // 写入数据行
    rowIndex := 2 // 第二行开始
    for _, person := range persons {
        file.SetCellValue(sheetName, "A"+fmt.Sprint(rowIndex), person.Name)
        file.SetCellValue(sheetName, "B"+fmt.Sprint(rowIndex), person.Phone)
        file.SetCellValue(sheetName, "C"+fmt.Sprint(rowIndex), person.Email)
        file.SetCellValue(sheetName, "D"+fmt.Sprint(rowIndex), person.Age)
        file.SetCellValue(sheetName, "E"+fmt.Sprint(rowIndex), person.Other)
        rowIndex++
    }

    // 设置生成的 Excel 文件的默认 Sheet
    file.SetActiveSheet(sheet)

    // 保存 Excel 文件
    if err := file.SaveAs(execlPath); err != nil {
        panic(err)
    }
}

示例代码中,通过定义一个 Person 结构体,然后定义一个 person slice 来存储数据,最后通过 excelize 库来读取结构体数据并写入到 Excel 文件。

读:使用 excelize 第三方库来读取 excel 文件。以下为一个简单的示例:

package main

import (
    "fmt"
    "github.com/360EntSecGroup-Skylar/excelize/v2"
)

func main() {
    execlPath := "test.xlsx"
    // 打开 excel 文件
    file, err := excelize.OpenFile(execlPath)
    if err != nil {
        fmt.Println(err)
        return
    }

    // 读取 Sheet1 中的 A1 单元格
    cellA1, err := file.GetCellValue("Sheet1", "A1")
    fmt.Println(cellA1, err)

    // 读取 Sheet1 中所有单元格
    rows, err := file.GetRows("Sheet1")
    if err != nil {
        fmt.Println(err)
        return
    }
    for _, row := range rows {
        for _, colCell := range row {
            fmt.Print(colCell, "\t")
        }
        fmt.Println()
    }
}

在示例中,我们首先打开一个名为 test.xlsx 的 Excel 文件,然后分别读的 A1 单元格和 Sheet1 中的所有单元格,最后输出到控制台

采用流式写入器:

package main

import (
    "fmt"
    "strconv"
    "github.com/360EntSecGroup-Skylar/excelize/v2"
)

type Person struct {
    Name  string
    Phone string
    Email string
    Age   int
    Other string
}

func main() {

    execlPath := "test.xlsx"

    persons := []Person{
        {Name: "Tom", Phone: "138888", Email: "tom@example.com", Age: 20, Other: "other"},
        {Name: "Jerry", Phone: "13999999999", Email: "jerry@example.com", Age: 22, Other: "other"},
    }

    // 创建一个新的空工
    file := excelize.NewFile()

    // 新建一个 Sheet,命名为 Sheet1
    sheetName := "Sheet1"
    streamWriter, err := file.NewStreamWriter(sheetName)
    if err != nil {
        fmt.Println(err)
        return
    }

    // 写入字段属性行,从 A1 开始
    if err := streamWriter.SetRow("A1", []interface{}{"name", "phone", "email", "age", "other"}); err != nil {
        panic(err)
    }
    rows := 2
    // 写入数据行
    for _, person := range persons {
        if err := streamWriter.SetRow("A"+strconv.Itoa(rows), []interface{}{person.Name, person.Phone, person.Email, person.Age, person.Other}); err != nil {
            panic(err)
        }
        rows++
    }

    // 设置生成的 Excel 文件的默认 Sheet
    if err := streamWriter.Flush(); err != nil {
        panic(err)
    }

    // 保存 Excel 文件
    if err := file.SaveAs(execlPath); err != nil {
        panic(err)
    }
}

示例代码中,通过采用流式写入器 streamWriter,一次性写入整行数据,可以避免多次调用 SetCellValue 方法造成的性能瓶颈。可以有效提升写入效率,特别是在大量数据写入的场景下.但是要注意flush方法需要写在最后,如果多次调用会丢失之前的数据。

3. 流式 与 缓存区别

在excelize提供了两种方式对execl数据的写入,SetCallValue与streamWriter两种方式。

在上方的代码中,使用f.SetCellValue是将修改后的数据写入到Excel文件的缓存中,并没有直接写入磁盘。 实际上,在使用f.SaveAs方法将Excel文件保存到磁盘之前,所有的修改操作都是在Excel文件的缓存中进行的。当调用f.SaveAs方法时,才会将缓的数据写入到磁盘中。 另外,需要注意的是,当写入大量的数据时,缓存的大小可能会超过系统内存限制,导致程序运行变慢或崩溃。

而流式写入器,则采用批量写入整行数据的方式,可以在性能上进行优化,特别是在需要写入大量数据时,使用流式写入器效率会更高。需要注意的式采取流式写入器flush需至于最后执行,否则会存在数据丢失的问题。

它们之间的区别点:

  • 内存占用

    • 使用SetCellValue方法时,我们需要将所有要写入的数据先保存到缓存中,然后一次性地将缓存的数据写入excel文件。因此,当写入的数据量较大的时候会占用较多的内存。
    • 而使用streamWriter方法时,是将每一行数据依次写入Excel文件,不需要将所有的数据先保存到缓存中,因此占用的内存较少。
  • 执行效率

    • 由于SetCellValue方法需要将所有的数据保存到缓存中,再将数据一次性写入Excel文件,因此执行效率较低。
    • streamWriter方法是将每一行数据一次写入excel文件,因此效率更高,特别是写入大量数据的时候。
  • 对excel文件的影响访问

    • SetCellValue方法,是直到我们调用SaveAs方法才会将缓存中的数据保存到excel文件中
    • streamWriter方法是每次写入数据都会立即反映在excel文件中。

4. 向excel写入100w数据

基于SetCellValue实现

如下是基于SetCellValue实现的例子,在例子中会存在几个问题需根据自己的实际场景修改

需注意:

  • 在利用协程的时候注意任务的分配即每个协程处理的数据量
  • 需注意利用协程的数量需要可控
  • 需注意所有协程全部执行完成

在代码中每个goroutine需要创建大量的数据对象,导致内存占用过多,而go语音的GC不能及时回收这些数据,因此在代码中还做了如下操作以减少内存占用:

  1. 实现goroutine池控制并发数量,避免同事过多的创建大量数据对象导致内存过多。
  2. 将大量数据写入excel文件时,避免一次性将所有数据写入内存中,采用分批次写入的方式。
package main

import (
    "fmt"
    "strconv"
    "sync"

    "github.com/360EntSecGroup-Skylar/excelize/v2"
    "go.uber.org/atomic"
    
    "gitee.com/dn-jinmin/gen-id/generator"
    "gitee.com/dn-jinmin/gen-id/utils"
)

type User struct {
    name  string
    phone string
    email string
    age   int
    other string
}
type ExcelData struct {
    rowIndex *atomic.Int32
    users    []*User
}

var genid = new(generator.GeneratorData)

const (
    batchSize = 10000
    goroutine = 100 // 并发goroutine数
    total     = 1000000
)

func createUser(id int) *User {
    return &User{
        name:  genid.GeneratorName(),
        phone: genid.GeneratorPhone(),
        email: genid.GeneratorEmail(),
        age:   utils.RUint(100),
        other: "other info for user " + strconv.Itoa(id),
    }
}

func writeExcelFile(filePath string) {
    f := excelize.NewFile()
    index := f.NewSheet("Sheet1")
    f.SetCellValue("Sheet1", "A1", "Name")
    f.SetCellValue("Sheet1", "B1", "Phone")
    f.SetCellValue("Sheet1", "C1", "Email")
    f.SetCellValue("Sheet1", "D1", "Age")
    f.SetCellValue("Sheet1", "E1", "Other")

    var wg sync.WaitGroup
    ch := make(chan int, goroutine) // 使用制并发goroutine数
        
    // 创建并发的协程数量
    go func() {
	for i := 0; i < goroutine; i++ {
            ch <- i
	}
    }()

    for i := 0; i < batchSize*goroutine && i < total; i += batchSize {
	data := &ExcelData{
            rowIndex: atomic.NewInt32(int32(i + 2)), // Excel行号从2开始
            users:    make([]*User, batchSize),
	}

	// 创建用户数据
	for j := 0; j < batchSize; j++ {
            data.users[j] = createUser(i + j)
	}

	chunkSize := batchSize 
	chunkSize = minInt(chunkSize, batchSize)
	chunks := makeChunks(data.users, chunkSize)

	for id, chunk := range chunks {
            idx := <-ch // 获取一个goroutine

            wg.Add(1)
            go func(users []*User) {
		defer func() {
                    wg.Done()
                    ch <- idx // 归还goroutine
		}()

		for _, user := range users {
                    if user == nil {
			continue
                    }
                    rowIndex := int(data.rowIndex.Load())
                    // 通过f.SetCellValue方法将数据写入到缓存中
                    f.SetCellValue("Sheet1", "A"+strconv.Itoa(rowIndex), user.name)
                    f.SetCellValue("Sheet1", "B"+strconv.Itoa(rowIndex), user.phone)
                    f.SetCellValue("Sheet1", "C"+strconv.Itoa(rowIndex), user.email)
                    f.SetCellValue("Sheet1", "D"+strconv.Itoa(rowIndex), user.age)
                    f.SetCellValue("Sheet1", "E"+strconv.Itoa(rowIndex), user.other)
                    data.rowIndex.Add(1)
		}
            }(chunk)
	}

	wg.Wait()
        
	for _, user := range data.users {
            if user != nil {
                user = nil
            }
	}
        
	fmt.Printf("processed [%d - %d] users\n", i+1, i+batchSize)
    }
    
    f.SetActiveSheet(index)
    if err := f.SaveAs(filePath); err != nil {
        fmt.Println(err)
    }
}

func makeChunks(users []*User, chunkSize int) [][]*User {
    var chunks [][]*User
    for i := 0; i < len(users); i += chunkSize {
	end := minInt(i+chunkSize, len(users))
	chunks = append(chunks, users[i:end])
    }
    return chunks
}

func minInt(x, y int) int {
    if x < y {
	return x
    }
    return y
}

func main() {
    writeExcelFile("user.xlsx")
}

在程序中关于中batchSize定义为是每次任务的数据量,goroutine则是处理的任务协程数量。

// 创建并发的协程数量
go func() {
    for i := 0; i < goroutine; i++ {
	ch <- i
    }
}()

创建并发的协程数量,在创建的协程池后则就是具体的任务处理核心代码,根据数据的批次处理量创建对应的用户数量,同事,更好地利用CPU资源,我们可以将数据拆分成为多个小区间进行并发写入。在批次任务执行完成之后对生成的用户数据进行释放,释放的方式是重置为nil这样以便于go的GC能够及时释放内存。

基于streamWriter实现

在技术上的实现于SetCellValue是有区别

package main

import (
    "fmt"
    "strconv"

    "github.com/360EntSecGroup-Skylar/excelize/v2"
    "go.uber.org/atomic"

    "gitee.com/dn-jinmin/gen-id/generator"
    "gitee.com/dn-jinmin/gen-id/utils"
)

type User struct {
    name  string
    phone string
    email string
    age   int
    other string
}

type ExcelData struct {
    rowIndex *atomic.Int32
}

var genid = new(generator.GeneratorData)

const (
    batchSize = 10000
    total     = 1000000
)

func createUser(id int) *User {
    return &User{
        name:  genid.GeneratorName(),
        phone: genid.GeneratorPhone(),
        email: genid.GeneratorEmail(),
        age:   utils.RUint(100),
        other: "other info for user " + strconv.Itoa(id),
    }
}

func writeExcelFile(filePath string) {
    sheet := "Sheet1"

    f := excelize.NewFile()
    // 创建流式写入器
    sw, err := f.NewStreamWriter(sheet)
    if err != nil {
        fmt.Println(err)
        return
    }

    index := f.NewSheet(sheet)
    sw.SetRow("A1", []interface{}{
        "Name", "Phone", "Email", "Age", "Other",
    })

    // 写入数据
    for i := 0; i < total; i += batchSize {
        data := &ExcelData{
            rowIndex: atomic.NewInt32(int32(i + 2)), // Excel行号从2开始
        }

        // 并发创建用户数据
        for j := 0; j < batchSize; j++ {
            user := createUser(i + j)
            rowIndex := data.rowIndex.Load()
            sw.SetRow("A"+strconv.Itoa(int(rowIndex)), 
                []interface{}{user.name, user.phone, user.email, user.age, user.other})
            data.rowIndex.Add(1)
            user = nil
        }

        fmt.Printf("processed [%d - %d] users\n", i+1, i+batchSize)
    }

    sw.Flush()

    f.SetActiveSheet(index)
    if err := f.SaveAs(filePath); err != nil {
        fmt.Println(err)
    }
}

func main() {
    writeExcelFile("user.xlsx")
}

可以看到我并没有利用协程往excel中写数据,而是采取同步的方案去进行写。因为excelize提供的streamWriter是并发不安全的,底层会利用到切片存储数据,在并发的情况下会导致数据异常。

当然如果你的业务中是需要读取mysql的数据导入到excel中,则可以考虑利用一个协程读取mysql的数据再基于通道将数据传递到写excel的协程中。

5. 读取100w数据量的excel文件

读取的方法我们可以利用excelize中提供的Rows()与GetRows()方法进行实现,如下是两者的区别:

  • Rows()

使用f.Rows方法可以迭代遍历xlsx表格中的每一行数据。它返回的是一个迭代器对象,需要通过for循环进行遍历。

示例代码:

// 迭代遍历所有行数据
rows, err := f.Rows("Sheet1")
if err != nil {
    fmt.Println(err)
    return
}
for rows.Next() {
    row := rows.Columns()
    fmt.Println(row)
}
  • GetRows()

f.GetRows 使用f.GetRows方法可以一次性读取xlsx文件中的所有行数据,返回的是一个二维数组,其中每个子数组代表一行数据。

示例代码:

// 一次性读取所有行数据
rows, err := f.GetRows("Sheet1")
if err != nil {
    fmt.Println(err)
    return
}
for _, row := range rows {
    fmt.Println(row)
}

因此,f.Rows和f.GetRows方法的使用场景不同,在读取大量数据时,使用f.GetRows会更高效。而在需要逐一遍历处理行数据时,使用f.Rows方法则更加方便。

本案例中对于全部读取可以直接看 “2” 节点的内容,此处直接与协程结合,当读取数据后会基于协程并发处理。

GetRows()方式

package main

import (
    "fmt"
    "sync"
    
    "github.com/360EntSecGroup-Skylar/excelize/v2"
)

func processRows(rows [][]string, resultChan chan<- string) {
    // 处理每一行数据
    for _, row := range rows {
        // TODO: 执行数据处理操作
        // 这里只是简单地将每行数据拼接成一个字符串
        resultChan <- fmt.Sprintf("%v\n", row)
    }
}

func main() {
    // 打开Excel文件
    f, err := excelize.OpenFile("test.xlsx")
    if err != nil {
        fmt.Println(err)
        return
    }

    // 读取所有行数据
    rows, err := f.GetRows("Sheet1")
    if err != nil {
        fmt.Println(err)
        return
    }

    // 创建一个结果通道
    resultChan := make(chan string)

    // 启动多个goroutine并发处理数据
    go func() {
        for {
            select {
            case result := <-resultChan:
                // 输出处理结果
                fmt.Print(result)
            }
        }
    }()
    
    var wg sync.WaitGroup
    for i := 0; i < 10; i++ {
        // 每个goroutine处理10万行数据
        wg.Add(1)
        go func(i int) {
            defer wg.Done()
            processRows(rows[i*100000:(i+1)*100000], resultChan)
        }(i)
    }

    // 等待所有goroutine完成处理
    wg.Wait()
}

说明:

  1. 使用excelize包打开Excel文件,并获取工作表名、行数和列数。
  2. 创建一个有缓冲的通道,用于存储需要处理的行数据。
  3. 启动多个协程进行并发处理,每个协程从通道中读取一行数据进行处理。
  4. 将需要处理的行数据发送到通道中。
  5. 关闭通道,等待所有协程处理完成。

这样可以在保证数据处理效率的同时,充分利用多核CPU的性能。

Rows方式

package main

import (
    "fmt"
    "sync"
    "github.com/360EntSecGroup-Skylar/excelize/v2"
)

func processRows(rows [][]string, resultChan chan<- string) {
    // 处理每一行数据
    for _, row := range rows {
        // TODO: 执行数据处理操作
        // 这里只是简单地将每行数据拼接成一个字符串
        resultChan <- fmt.Sprintf("%v\n", row)
    }
}

func main() {
    // 打开Excel文件
    f, err := excelize.OpenFile("test.xlsx")
    if err != nil {
        fmt.Println(err)
        return
    }

    // 读取所有行数据
    rows, err := f.Rows("Sheet1")
    if err != nil {
        fmt.Println(err)
        return
    }

    // 创建一个结果通道
    resultChan := make(chan string)

    // 启动多个goroutine并发处理数据
    go func() {
        for {
            select {
            case result := <-resultChan:
                // 输出处理结果
                fmt.Print(result)
            }
        }
    }()
    // 去掉头表头
    rows.Next()

    var wg sync.WaitGroup
    datas := make([][]string, 0,100000)
    for rows.Next() {
        data, err := rows.Columns()
        if err != nil {
                fmt.Println(err)
                return
        }
        
        datas = append(datas, data)
        if len(datas) >= 100000 {
            wg.Add(1)
            go func(datas [][]string) {
                defer wg.Done()
                processRows(datas, resultChan)
            }(datas)
            datas = nil
        }
    }
    
    // 剩下的处理完成
    wg.Add(1)
    go func(datas [][]string) {
        defer wg.Done()
        processRows(datas, resultChan)
    }(datas)
    
    // 等待所有goroutine完成处理
    wg.Wait()
}

说明:在实现上与GetRows类似只是在写法上需注意,rows是并发不安全的因此不要使rows并发操作。