golang 高性能的 MySQL 数据导出
- 背景:
- 任务
- 行动
- 单元测试
- 分页查询的问题
- 深度分页的耗时情况
- 优化分页查询的查询性能
- 方案1: 偏移分页(查询耗时:4s)
- 方案2: 快照导出(查询耗时:1.5s)
- 其他优化
- 异步处理+流式对象储存
- 结果
背景:
需求
- 我在工作的使用收到一个导出数据数据的需求,导出为Excel文件
处理
- 我先查阅了网上的文档,他们提供的方案都是分页查询的方式
- 不使用一次查询全表数据的原因是如果数据量特别庞大的情况下会消耗 MySQL 的大量内存空间,造成服务器卡顿,影响服务性能
确定分页大小
-
选择合适的分页大小
- 分页太大的问题: 浪费内存,造成服务器卡顿
- 分页太小: 查询次数太多,导出慢
-
我从 100 大小开始,每次增加 100, 一直增加到 4000的分页大小
发现导出的性能先递增,到两千后变化就不大了;所以我选择两千的分页大小
bug: 数据导出超时
- 当测试测的时候出现导出超时的问题,就给我提了一个 bug
问题分析:
- 测试时未使用任何筛选字段,导出全表数据 200 万条
- 前端的超时时间 1 分钟(我试了一下,导出全表的数据需要 5 分钟
任务
- 解决超时问题,即使导出全表数据也不能超时
行动
单元测试
- 我对代码进行单元测试,看各阶段耗时情况
- 发现查询耗时4min40s
- 数据处理与传输 20s
数据查询耗时占了约 93%; 核心优化数据查询
分页查询的问题
-- 分页查询
SELECT ..... OFFSET m LIMIT n
但是你使用EXPLAIN
语句就会发现分页查询是全表扫描,没有走任何索引逻辑
- 他的逻辑是从数据的第一条开始扫描,把前面扫描到的 m 条数据都丢弃掉然后取有面的 n 条数据
- 所以 m 越大(page 越大);查询越慢
深度分页的耗时情况
--浅分页(耗时 15ms)
SELECT * FROM users OFFSET 2000 LIMIT 2000--深度分页(耗时 1.12s)
SELECT * FROM users OFFSET 200000 LIMIT 2000
优化分页查询的查询性能
方案1: 偏移分页(查询耗时:4s)
原理是使用 WHERE id>m
代替OFFSET m
,查询直接走主键索引确定数据位置
--偏移分页
SELECT ..... WHERE id > m LIMIT n
EXPLAIN
的结果是 range 类型
代码示例:
// 定义分页参数starId := 0pageSize := 2000// 定义查询条件var users []User// 执行分页查询for {result := db.Limit(pageSize).Where("id >= ?", starId).Find(&users)if result.Error != nil {log.Fatal("err5:", result.Error)}if len(users) < pageSize {break}starId = users[len(users)-1].Id}
- 整个查询耗时 4s (本地测试),查询性能提高 70 倍
- 导出总耗时: 24s(数据查询耗时:4s; 数据处理与传输耗时 20s)
方案2: 快照导出(查询耗时:1.5s)
因为我之前有做过使用 go 实现 MySQLdump 的项目
项目地址:https://github.dev/dengjiayue/mysqldump/blob/master/mysqldump.go
与这边的数据导出的逻辑非常类似;
我参考我之前 MySQLdump 的导出逻辑进行进一步优化
代码示例:
data, err := db.Query("select * from users")if err != nil {log.Fatal("err1:", err)}defer data.Close()columns, err := data.Columns()if err != nil {log.Printf("[error] %v \n", err)}l := len(columns)val := &User{}valPointer := make([]interface{}, l)valPointer[0] = &val.IdvalPointer[1] = &val.NamevalPointer[2] = &val.TagvalPointer[3] = &val.PhonevalPointer[4] = &val.Create_time//计数num := 0for data.Next() {err := data.Scan(valPointer...)if err != nil {log.Fatal("err5:", err)}}
- 这边我使用了类似查询全表数据的逻辑
SELECT * FROM users;
实际上原生的 sql 包是对查询进行了处理的;并不是一次性获取所有的数据
- MySQL 会准备好返回的数据的快照(但是不会传输给服务端)
- 服务端使用 Next 命令(获取 MySQL 结果集中的一行数据)
- 然后再 Scan读取到对象中
这种方法的优势:
- 只需要一次查询命令
- 一行一行流式读取的,不会一次性占用很多的内存
- 整个查询耗时 1.5s (本地测试),查询性能提高 200 倍
- 导出总耗时: 21.5s(数据查询耗时:1.5s; 数据处理与传输耗时 20s)
其他优化
异步处理+流式对象储存
开两个协程
- 一个只用于查询数据,将查询到的数据放到 chan 中
- 一个只用于处理数据并传输: 从 chan 中读取数据,并生成 Excel 的二进制数据,然后传输到对象储存
我选择对象储存有两个原因:
- 避免客户端长时间等待接收数据
- 实现一个导出列表: 可以查看到每次导出的记录,与导出的状态(已完成,未完成,失败);避免用户等待久了多次点击导出接口,同时可以复用之前的导出记录(前面一个导出了一次,另一个人刚好也需要,就可以直接下载,不需要二次导出)
结果
- 200 万数据的导出时间缩短到 15s(查询耗时:1.5s;处理传输耗时 13.5s)
整体导出性能提升 20 倍(原导出时间 5min)