在实际应用场景中,列表分页查询是很常见的。假设现在存在某张表,已知 ID 是主键,针对 user_name 建立了二级索引。
针对该表进行分页查询。
select * from table order by id limit offset, size;
那么同样都是获取 10 条数据,查询第一页和查询第一百页的速度一样吗?
首先先回忆下 MySQL 查询语句的执行过程:MySQL 内部可分为 server 层和存储引擎层,一条查询语句需要依次经过 连接器 -> 查询缓存 -> 解析器 & 预处理器 -> 分析器 -> 优化器 -> 执行器
,执行器通过调用存储引擎层提供的接口,将一行行的数据取出,当这些数据完全符合要求(即满足查询条件),则会放到结果集中,最后再返回给客户端。
MySQL 索引查询可分为主键索引查询和非主键索引查询,其中 InnoDB 存储引擎默认采用 B+ 树索引结构:如果是主键索引,叶子节点存放完整的行数据信息,非叶子节点仅存放索引;如果是非主键索引,叶子节点存放的是主键值,若想获得完整的行数据信息,则还需要根据主键值再查询一次主键索引,即回表查询。
但不管是主键索引还是非主键索引,它们的叶子节点数据都是有序的。以主键索引为例:这些数据根据主键 ID 大小,从小到大进行排序。
基于主键索引的 limit 执行过程
select * from table order by id limit 0, 10;
select * from table order by id limit 6000000, 10;
针对第一条语句,server 层会调用 InnoDB 接口,在 InnoDB 的主键索引中获取到第 0 ~ 10 条完整的行数据,一次性返回给 server 层,并放到 server 层的结果集中,最后返回给客户端。
而在第二条语句中,offset 设置为 6000000,则会在 InnoDB 的主键索引中获取第 0 ~ 6000000 + 10 条完整的行数据,返回给 server 层后根据 offset 的数值进行丢弃,只保留后面的 size 条数据,放到 server 层的结果集中,最后返回给客户端。
可以看到,当 offset 非 0 时,server 层会从引擎层获取到很多无用的数据,而获取这些数据本身也是耗时的。
此外,当进行 select * 查询时,需要拷贝完整的行数据,而拷贝完整数据和只拷贝行数据里的部份列字段的耗时是不一样的。更何况前面 offset 条数据最后都是不要的,所以即使将完整的行数据都拷贝了也没有意义。
修改优化如下。
select * from table where id >= (select id from table order by id limit 6000000, 1) order by id limit 10;
修改后的语句,先执行子查询,从主键索引中获取到 6000001 条数据,然后 server 层丢弃前 6000000 条,只保留最后一条数据的 ID。因为只会拷贝数据行内的 ID 列,而不是拷贝数据行的所有列,所以即使数据量较大,性能还是能有显著提升。
根据子查询获取到的 ID,InnoDB 再走一次主键索引,通过 B+ 树快速定位到 id=6000000 的行数据,然后再向后取 10 条数据。
基于非主键索引的 limit 执行过程
select * from table order by user_name limit 0, 10;
上述查询语句中,server 层会调用 InnoDB 接口,在 InnoDB 的主键索引中获取到第 0 条数据对应的主键 ID 后,再回表查询对应的完整行数据,然后再返回给 server 层,server 层将其放到结果集中,最后返回给客户端。
当 offset 非 0 时,也是会丢弃前面的 offset 条数据。非主键索引的 limit 过程,比主键索引的 limit 过程,多了一步回表查询的耗时。而当 offset 数值特别大的时候,server 层的优化器可能会因为要进行大量的回表操作,从而选择全表扫描。
修改优化如下。
select * from table t1, (select id from table order by user_name limit 6000000, 10) t2 where t1.id = t2.id;
先走非主键索引取出 ID,因为只取主键值,所以不需要回表,性能会稍微快些。在返回 server 层后,同样也是丢弃 offset 条数据,只保留最后的 10 个 ID,然后再用这 10 个 ID 去和 t1 表做 ID 匹配,此时走的是主键索引,将匹配到的 10 条行数据返回,这样就绕开了之前大量的回表操作。
深分页问题
但是上面的两种优化方式,始终无法解决丢弃大量数据的问题,不管是使用 MySQL 还是借助 ES,都只能减缓问题的严重性。
针对需要获取全表数据的使用场景,可以考虑采用分批处理,将当前批次的最大 ID 作为下次筛选的条件。
伪代码如下。
start_id := 0
for {
datas := [select * from table where id > start_id order by id limit 100]
if len(datas) == 0 {
break
}
handler(datas)
start_id = get_max_id_from(datas)
}
针对面向用户的分页展示,可以考虑限制搜索页数范围或者限制间隔页数查询或者直接采用上下页查询的方式。
总结
limit offset, size
比 limit size
慢,且 offset 的数值越大,SQL 的执行速度越慢参考资料