MySQL千万数据查询优化之路

MySQL千万数据查询优化之路

本文主要针对 MySQL 在千万级别数据的分页查询性能进行优化, 下面是整个优化的过程.

先说结论

先说结论, MySQL 在千万级别数据的分页查询性能主要受到 2 个因素的影响:

  • 查询的偏移量
  • 查询的数据量

查询的偏移量优化

当 MySQL 执行查询语句分页 LIMIT 时, 有 2 个步骤需要先按照指定的排序规则对数据进行排序, 然后跳过指定的偏移量。

如果查询的偏移量比较大, 那么排序的时间就会比较长(B+树 索引可以极大优化该阶段性能)

但是 B+树 在跳过指定的偏移量时, 需要顺序遍历, O(n) 的复杂度, 千万级的偏移量也是比较慢

优化思路:

  • 给排序的字段加上B+树索引
  • 使用子查询确定查询范围(比如, 主键的范围, BETWEEN 等)
  • 连表查询, 小表驱动大表, 通过小表的索引来确定大表的范围, 减少偏移量

查询的数据量优化

  • 指定列替代 SELECT *
  • 减少不需要的列, 特别是大字段
  • 一次尽可能按需查询较少的数据条数
  • 缓存查询结果 (比如 redis) 来减少查询次数

准备数据

建表

CREATE TABLE `big_tables` (
  `id` bigint unsigned NOT NULL AUTO_INCREMENT,
  `name` varchar(256) DEFAULT NULL,
  `age` bigint DEFAULT NULL,
  `data` longblob,
  PRIMARY KEY (`id`)
) ENGINE=InnoDB AUTO_INCREMENT=10010001 DEFAULT CHARSET=utf8mb4 COLLATE=utf8mb4_0900_ai_ci;

导入数据

我使用的是 golang gorm 来导入数据, 代码如下:

var (
    configFile = "../config-dev.yaml"
    DB         *gorm.DB
)

type BigTable struct {
    ID   uint64 `gorm:"column:id;primary_key;auto_increment"`
    Name string `gorm:"column:name"`
    Age  int    `gorm:"column:age"`
    Data []byte `gorm:"column:data"`
}

func TestBitTable_InsertData(t *testing.T) {
    var err error

    DB.AutoMigrate(&BigTable{})

    // 关闭日志
    DB.Logger = logger.Default.LogMode(logger.Silent)

    // 批量插入 1000w 条数据, 每次插入 10000 条
    batches := 1000
    size := 10_000
    bigTables := make([]*BigTable, 0, size)
    for i := 0; i < batches; i++ {
        for i := 0; i < size; i++ {
            bigTables = append(bigTables, &BigTable{
                Name: utils.RandString(10),
                Age:  utils.RandInt(10),
                Data: utils.RandBytes(10),
            })
        }
        var task = func(idx int, db *gorm.DB) {
            err = db.CreateInBatches(bigTables, size).Error
            if err != nil {
                t.Error(err)
            }
            log.Printf("批次: %v, 完成 \n", idx)
        }

        task(i, DB)

        // 清空
        bigTables = bigTables[:0]
    }

    log.Printf("\n插入完成\n")
}

一分钟左右就可以导入 1000w 条数据

查看导入的数据

mysql> select count(*) from big_tables;
+----------+
| count(*) |
+----------+
| 10010000 |
+----------+
1 row in set (1.27 sec)

普通查询

查询 offset 为 1w, 10w, 100w, 1000w 的 1 条数据

命令

select * from big_tables limit 10000, 1;
select * from big_tables limit 100000, 1;
select * from big_tables limit 1000000, 1;
select * from big_tables limit 10000000, 1;

结果

mysql> select * from big_tables limit 10000, 1;
+-------+------------+-----+------------+
| id    | name       | age | data       |
+-------+------------+-----+------------+
| 10001 | I6pC5NBFD9 |   7 | x4zXHhnPnW |
+-------+------------+-----+------------+
1 row in set (0.10 sec)

mysql> select * from big_tables limit 100000, 1;
+--------+------------+-----+------------+
| id     | name       | age | data       |
+--------+------------+-----+------------+
| 100001 | PzpzEZDX9G |   0 | B48IvBLlWo |
+--------+------------+-----+------------+
1 row in set (0.13 sec)

mysql> select * from big_tables limit 1000000, 1;
+---------+------------+-----+------------+
| id      | name       | age | data       |
+---------+------------+-----+------------+
| 1000001 | 4niiNSTHtx |   5 | tdCK9VuVWJ |
+---------+------------+-----+------------+
1 row in set (0.52 sec)

mysql> select * from big_tables limit 10000000, 1;
+----------+------------+-----+------------+
| id       | name       | age | data       |
+----------+------------+-----+------------+
| 10000001 | R0HWlAyf7R |   0 | kHDTpsmtcg |
+----------+------------+-----+------------+
1 row in set (5.86 sec)

表格:

偏移量 查询时间
1w 0.10s
10w 0.13s
100w 0.52s
1000w 5.86s

可以看到 1w 到 10w 的查询时间基本不变, 但是 100w 到 1000w 的查询时间基本线性增长

因为 B+树 在跳过指定的偏移量时, 需要顺序遍历, O(n) 的复杂度

查询 offset 为 10, 100, 1000, 10000 条数据

命令

select * from big_tables limit 100000, 10;
select * from big_tables limit 100000, 100;
select * from big_tables limit 100000, 1000;
select * from big_tables limit 100000, 10000;

结果

mysql> select * from big_tables limit 100000, 10;
# 数据太多, 省略
10 rows in set (0.21 sec)

mysql> select * from big_tables limit 100000, 100;
# 数据太多, 省略
100 rows in set (0.35 sec)

mysql> select * from big_tables limit 100000, 1000;
# 数据太多, 省略
1000 rows in set (1.93 sec)

mysql> select * from big_tables limit 100000, 10000;
# 数据太多, 省略
10000 rows in set (21.20 sec)

表格

数据量 查询时间
10 0.21s
100 0.35s
1000 1.93s
10000 21.20s

可以看到, 数据量越大, 查询时间越长. 数据 1000-10000 的查询时间基本线性增长 (这里我的 MySQL 就在本机上, 如果是远程 MySQL 网络 IO 产生的时间将更长)

但是一般查询的数据量不会太大, 一般都是 10 条左右

优化方案如下:

  • 指定列替代 SELECT *
  • 减少不需要的列, 特别是大字段
  • 一次尽可能按需查询较少的数据条数
  • 缓存查询结果 (比如 redis) 来减少查询次数

优化方案比较简单, 容易理解, 后面就不再赘述了

优化: 偏移量导致的查询慢

1. 子查询

先查询 id 的位置, 然后再根据 id 的位置查询数据

命令

select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;

结果

mysql> select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;
+----------+------------+-----+------------+
| id       | name       | age | data       |
+----------+------------+-----+------------+
| 10000001 | R0HWlAyf7R |   0 | kHDTpsmtcg |
+----------+------------+-----+------------+
1 row in set (2.69 sec)

表格

是否使用子查询 偏移量 查询时间
1000w 2.69s
1000w 5.86s

可以看到, 使用子查询后, 查询时间减少了50%以上.

但是还是在秒级别, 达不到毫秒级别的业务需求

2. 子查询 EXPLAIN 分析

子查询命令

explain select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;

结果

mysql> explain select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
| id | select_type | table      | partitions | type  | possible_keys | key     | key_len | ref  | rows    | filtered | Extra       |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
|  1 | PRIMARY     | big_tables | NULL       | range | PRIMARY       | PRIMARY | 8       | NULL |   19290 |   100.00 | Using where |
|  2 | SUBQUERY    | big_tables | NULL       | index | NULL          | PRIMARY | 8       | NULL | 9750719 |   100.00 | Using index |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
2 rows in set (3.20 sec)

子查询(第二行):

  • 子查询的 typeindex, 表示使用了索引扫描全表
  • 子查询的 keyPRIMARY, 表示使用了主键索引
  • 子查询的 rows9750719, 表示扫描了 9750719 行数据 (粗略计算的, 因为 MySQL 每页 16KB)
  • 子查询的 ExtraUsing index, 表示使用了覆盖索引

主查询(第一行):

  • 主查询 typerange, 表示使用了索引范围扫描
  • 主查询 keyPRIMARY, 表示使用了主键索引
  • 主查询 rows19290, 表示扫描了 19290 行数据
  • 主查询 ExtraUsing where, 表示使用了 where 条件

从上面可以看出: 子查询使用了索引扫描全表, 扫描行数量千万级别, 所以查询时间很长

给主键加上唯一索引

给主键加上 B+树 的唯一索引

命令

# add unique index
alter table big_tables add unique index id(id) using btree;
# query2
select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;
# explain query2
explain select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;

结果

mysql> # add unique index
alter table big_tables add unique index id(id) using btree;
Query OK, 0 rows affected (35.82 sec)
Records: 0  Duplicates: 0  Warnings: 0

mysql> # query2
select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;
+----------+------------+-----+------------+
| id       | name       | age | data       |
+----------+------------+-----+------------+
| 10000001 | R0HWlAyf7R |   0 | kHDTpsmtcg |
+----------+------------+-----+------------+
1 row in set (1.25 sec)
mysql> # explain query2
explain select * from big_tables where id >= (
    select id from big_tables limit 10000000, 1
) limit 0, 1;
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
| id | select_type | table      | partitions | type  | possible_keys | key     | key_len | ref  | rows    | filtered | Extra       |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
|  1 | PRIMARY     | big_tables | NULL       | range | PRIMARY,id    | PRIMARY | 8       | NULL |   19290 |   100.00 | Using where |
|  2 | SUBQUERY    | big_tables | NULL       | index | NULL          | id      | 8       | NULL | 9750719 |   100.00 | Using index |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+---------+----------+-------------+
2 rows in set (1.47 sec)

表格

状态 偏移量 查询时间
不用子查询 1000w 5.86s
子查询 1000w 2.69s
子查询 + 唯一索引 1000w 1.25s

可以看到, 给主键加上唯一索引后, 查询时间减少了50%以上

如果主键不是递增的, 比如是字符串, 需要用 IN 查询

因为某些 mysql 版本不支持在 in 子句中使用 limit, 所以这里多嵌套了一层子查询

select * from big_tables where id in (
    select id from (
        select id from big_tables limit 10000000, 1
    ) as t
) limit 0, 1;

如果主键是线性递增, 可以使用 WHERE 优化

上面我们知道, 查询的消耗主要是在索引遍历的过程中, 如果id是连续递增的, 可以使用 WHERE 来优化

# query3
select * from big_tables where id >= 10000000 limit 0, 1;
# explain query3
explain select * from big_tables where id >= 10000000 limit 0, 1;

结果

mysql> # query3
select * from big_tables where id >= 10000000 limit 0, 1;
+----------+------------+-----+------------+
| id       | name       | age | data       |
+----------+------------+-----+------------+
| 10000000 | Hey8TWX966 |   7 | kSjxDkL1qj |
+----------+------------+-----+------------+
1 row in set (0.08 sec)

mysql> # explain query3
explain select * from big_tables where id >= 10000000 limit 0, 1;
+----+-------------+------------+------------+-------+---------------+---------+---------+------+-------+----------+-------------+
| id | select_type | table      | partitions | type  | possible_keys | key     | key_len | ref  | rows  | filtered | Extra       |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+-------+----------+-------------+
|  1 | SIMPLE      | big_tables | NULL       | range | PRIMARY,id    | PRIMARY | 8       | NULL | 19298 |   100.00 | Using where |
+----+-------------+------------+------------+-------+---------------+---------+---------+------+-------+----------+-------------+
1 row in set (0.13 sec)

性能从原来的 5.86s 降低到了 0.08s, 提升 73 倍.

因为 rows 扫描行数不再是千万级别, 而只有一页的大小

为什么给主键加上唯一索引查询更快

在 MySQL 中,新增的唯一索引需要查询 2 次,第一次是查询索引树,第二次是查询数据页,而主键索引是唯一索引,所以查询主键索引时,只需要查询一次索引树即可。

但是在测试中, 1000万的偏移量的查询下, 再给主键加上唯一索引查询更快, 这是为什么呢?

参考:

本文由mdnice多平台发布

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 202,607评论 5 476
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 85,047评论 2 379
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 149,496评论 0 335
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 54,405评论 1 273
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 63,400评论 5 364
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 48,479评论 1 281
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 37,883评论 3 395
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 36,535评论 0 256
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 40,743评论 1 295
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 35,544评论 2 319
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 37,612评论 1 329
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 33,309评论 4 318
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 38,881评论 3 306
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 29,891评论 0 19
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,136评论 1 259
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 42,783评论 2 349
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 42,316评论 2 342

推荐阅读更多精彩内容