面试题必问-大表分页的优化
面试题必问-大表分页的优化
引子
先来看一段我们平时的分页代码
Request: http://127.0.0.1/article/list?page=1000&page_size=100
mysql > select count(1) from article where tag_id=1;
mysql > select id,title,author,create_time from article where tag_id=1 order by create_time desc
带入参数
mysql > select id,title,author,create_time from article where tag_id=1 order by create_time desc limit 100000,100;
分析一下这调sql语句存在的问题(下文所有分析均以Innodb引擎表为例)
order by create_time
按创建时间倒叙展示,这个需求很常见,一般分页都是伴随着排序的要求.如果create_time
没有创建索引,那么SQL不可避免的要用到filesort
,这意味着将进行全表扫描.表容量达到百万量级就很难一次性装入内存.
也就是说,filesort
操作不单要占用大量CPU
进行排序计算,也会产生很多磁盘IO
进行外部排序.- 排序完成后,要经过
offset 100000
条数据才能定位到我们需要的100
条数据,虽然B+一个叶子页上可以存储多条记录,但是扫描大量的offset
同样会引发较多IO
,
并且,这些IO操作很有可能是随机IO
.
针对以上两个主要问题,如下解决思路.
create_time
上增加索引,避免排序,避免全表扫描.- 将
limit <$offset>,<$rows>
改进为limit 0,<$rows>;
- 从需求上避免指定页数跳转,控制页的深度,隐藏展示数据总条数.redis里面做分页
来逐个详细分析一下上述方案.
Step.1 create_time
上增加索引,避免排序,避免全表扫描.
一般来将,create_time
与id
的增长方向是一致的,所以一般我们不用对create_time
列创建索引.
也就是说order by create_time desc
等价于order by id desc
.
但是在实际工作中,排序字段可是很多变的,比如约会
站点更注重最新活跃时间
,性别
这些字段.
select id,name,age,sex,latest_active_time from user where country='beijing` and sex='w' and age='18_25' order by latest_active_time desc.
如果开发人员在创建表时对社交心理学没有研究,那创建出来的索引就会偏离方向了.这个时候,没有现成的id
,可以给我们利用了.怎么办呢?
没有困难我们就自己制造困难.
msyql > alter table user add index latest_active_time(latest_active_time)
虽然思路没错,但是有几个问题.
扫描索引是很快的,因为索引结构通常比较小,而且在内存中排列方式通常是紧凑的.但是如果索引不能覆盖查询所需要的全部列,那就不得不每扫描到一条匹配的
索引记录,就回表查询一次(单行回表)不是批量回表.这些IO都是随机IO.因此按照索引顺序读取数据的速度通常要比顺序的全表扫描要慢,尤其是在IO负载比较高时.(认真读十遍).
(解释:因为索引顺序与数据行的插入顺序极有可能不一致,而数据行的物理存储顺序通常是与插入顺序一致).
另外,即使我们根据latest_active_time
得出了主键
值(innodb的二级索引中叶子结点存储的是主键值而不是行数据的指针),我们仍然需要根据从二级索引得到得主键序列
对主键索引进行回表查询,取出行数据中的目标字段.
如果MySQL可以使用同一个索引,既满足排序,又能用于查找行,那该有多好.
Tips:这不就是在说覆盖索引吗
介绍另外一种方案: 延迟关联
select <cols> from user innser join (
select primary_key_col from user where x.sex='w' order by rating limit 100000,10
) as x using(primary_key_col);
解析: 子查询利用覆盖索引原理
查询,快速
返回要查询的主键值
(虽然也有offset
,但是发生在索引结构中),然后根据主键值与原表进行关联获取需要的行.
这样处理的原理是减少了扫描丢弃行的开销.(select <row>
在索引中就可以全部命中,不需要根据主键值
额外回表一次).比较优雅的在子查询种完成了扫描
,排序
两个操作.
Tips: 利用索引的有序性尽量避免排序.(索引在创建时维护有序性),但是我们要知道,
order by
子句要满足最左前缀才要求,索引才可以生效.
我们要明确一点:无论如何创建索引,都无法彻底解决这个问题.反范式化,预计算,缓存是解决问题的仅有方案.
MySQL有两种方式可以生成有序的结果集
- 通过filesort
- 按索引顺序扫描
Tips:如果要按照反方向排序,我们可以存储列值的相反数.
Step.2 将limit <$offset>,<$rows>
改进为limit 0,<$rows>;
这个实现起来比较简单,一般有两种场景
Request: http://127.0.0.1/article/list?page=1000&page_size=100
mysql > select id,title,author,create_time from article ordery by id desc limit 100000,100;
如果你的id连续性比较好,可以直接通过数学计算得出offset后的起始id,转化为limit 100
.
mysql > select id,title,author,create_time from article where id<100000 ordery by id desc limit 0,100;
id<100000
一下子就可以排除掉100000
行数据的扫描工作.但是这种应用场景存在的不多.SQL里面包含where
时,数学公式很容易失效.
当页数比较深时,允许客户端将最后一条数据的ID传回到服务端,服务端先过滤,再排序.
Request: http://127.0.0.1/article/list?page=1000&page_size=100&last_id=100000
mysql > select id,title,author,create_time from article where id<100000 ordery by id desc limit 0,100;
Step.3 从需求上避免
很多时候,用户并不需要总条数,指定页数跳转这些功能.参考百度搜索列表,只展示10页的跳转连接.
参考资料
- 高性能mysql
- https://www.cnblogs.com/starry-skys/p/12921641.html