MySQL - 页

MySQL - 页,第1张

页是 InnoDB 管理存储空间的最小单位。一个页的大小一般是 16 KB。InnoDB 有许多种页用于不同的作用。其中数据页则是用于存储数据。数据页存储的内容为:

其中 Infimum + supremum 以及 User Records 为页中存储数据的部分。其中 Infimum 表示页中的最小记录,而 supremum 表示页中的最大记录。这两个记录不存储实际的值,而仅仅表示开头以及结尾。User Records 部分按行存储数据。User Records 中的每一条记录格式为:

插入到页中的记录是按主键大小进行排序。利用其中的 next_record 可以查找到下一条记录。在不考虑索引的情况下,如果我们要寻找其中的某条记录可以通过遍历链表的方式进行查找。但是如果当页中的数据过多,o(n) 的时间复杂度明显不满足快速查找的需求。因此 InnoDB 在页中设计了页目录。页目录中有多个槽,其规则如下:

因此实际搜索时,可以利用槽进行二分搜索,将算法复杂度降到了 。这个结构有点类似于一个两层的跳跃表。

由于一个页中实际能存储的数据有限,因此记录会被分配到多个页进行存储。页与页之间有着双向链表的结构。

在 innodb 中使用 B+ 树作为索引。实际上索引在 mysql 中也是作为页进行管理的。例如:

索引页与数据页类似,只是索引页中一条记录只存在两列。分别是页对应的最我号,以及页的页编号。当然,一个 b+ 树肯定存在多个级别,因此实际上的存存储格式为:

这里可以看出索引页与数据页其实并没有太多的区别。只不过数据页中存储着真实的数据,而索引页只存储索引。这里也可以看出主键索引实际上是聚集索引,当查找到最终的数据页时是可以直接获得数据。

许多个页组成的空间之为页空间。每个表空间对应着一个真实的文件 表名.ibd。每一个独立表空间中又会分为多个区。每一个区实际上是 64 个连续的页组成。每256个区划又会分为一组。

为什么会提出区的概念呢?原因是查找数据的时候,在页与页之间会通过双向链表进行查找。如果两个页随机分配物理地址,则其之间的物理位置可能非常远。那么在查找的时候无疑会形成大量的随机 IO。降低磁盘的性能。因此,当表中数据过大的时候,以区为单位进行分配连续的磁盘空间,可以减少随机 IO 的数量。

表空间中还有段的概念,当我们利用索引进行查询的时候。很多时候实际上是利用 B+ 树的叶子节点进行范围扫描。但是如果将索引页和数据页都存放在一个区中,那么数据页不一定是连续的磁盘空间。因此当进行范围扫描的时候又会存在随机 IO 的情况。因此索引页和数据页实际上是存放在不同的区中。存放索引页的区的集合又成为一个段,当然非索引页存放的区的集合则为另一个段。

我们知道,磁盘的速度是远远小于内存的速度。因此 InnoDB 会将查询的页缓存在内存 Buffer Pool 中,以免每一次请求都从磁盘中获取,加快查询速度。当然,内存不可能无止尽的使用。因此 InnoDB维护了一个 free 链表。 free 链表指向 Buffer Pool 中可用的部分。

当页面进行修改之后,缓存的中的页页不会马上落盘,这样的页称为脏页。InnoDB 维护了一个 flush 链表指向了脏页。当 buffer 的空间不足时,InnoDB 会进行刷页 *** 作,将脏页写入到磁盘中,腾出内存空间供新的页缓存使用。

一般来说,数据有冷热之分。如果经常刷新热点数据到磁盘中,肯定不划算。因为热点数据经常被查询修改,当写入到磁盘中后又会很快读入到缓存中,做了很多无用功。因此 InnoDB 采用了 LRU 算法统计哪些是热点数据,哪些是非热点数据。每次刷盘时从首先 LRU 链表的尾部将热点数据刷入到磁盘中。

InnoDB 并不是采用最简单的链表,而是划分区域的链表。其设计的原因是,InnoDB 在某些时候会采取预读的 *** 作,将一个区的数据全部读入到内存中。这些数据就会出现在 LRU 链表的头部。如果这些预读的数据最终不能被查询,那么真正的热点数据反而被挤到了链表的尾部,这样一旦存在预读行为 LRU 链表的功能就丧失了。同样,当用户进行扫描全表的 *** 作时,大量的页也会被加载到缓存中将 Buffer 占满。因此 InnoDB 将 LRU 分为两个区域-热数据(young 区)以及冷数据(old 区)。

对于第一种情况,当页被缓存到 Buffer 时首先会被放在 old 区。如果该页后续被继续访问,则会被放到 young 区中。而如果该页后续没有被继续访问到,则会逐渐移动到 old 区尾部。

对于扫描全表的情况,扫描全表有一个特点。即页中的每一条数据都会被访问到,同一个页第一次访问到最后一次访问的间隔时间一定很短。因此 InnoDB 设计了一个策略,如果当一个页加载到内存中,并且该页在第一此访问与最后一次访问间隔相差小于 1s (默认值),则该页就不会被加入到 young 区中。因此这种方式可以避免全表扫描时对 LRU 链表的污染。

很多应用往往只展示最新或最热门的几条记录,但为了旧记录仍然可访问,所以就需要个分页的导航栏。然而,如何通过MySQL更好的实现分页,始终是比较令人头疼的问题。虽然没有拿来就能用的解决办法,但了解数据库的底层或多或少有助于优化分页查询。

我们先从一个常用但性能很差的查询来看一看。

SELECT *

FROM city

ORDER BY id DESC

LIMIT 0, 15

这个查询耗时0.00sec。So,这个查询有什么问题呢?实际上,这个查询语句和参数都没有问题,因为它用到了下面表的主键,而且只读取15条记录。

CREATE TABLE city (

id int(10) unsigned NOT NULL AUTO_INCREMENT,

city varchar(128) NOT NULL,

PRIMARY KEY (id)

) ENGINE=InnoDB

真正的问题在于offset(分页偏移量)很大的时候,像下面这样:

SELECT *

FROM city

ORDER BY id DESC

LIMIT 100000, 15

上面的查询在有2M行记录时需要0.22sec,通过EXPLAIN查看SQL的执行计划可以发现该SQL检索了100015行,但最后只需要15行。大的分页偏移量会增加使用的数据,MySQL会将大量最终不会使用的数据加载到内存中。就算我们假设大部分网站的用户只访问前几页数据,但少量的大的分页偏移量的请求也会对整个系统造成危害。Facebook意识到了这一点,但Facebook并没有为了每秒可以处理更多的请求而去优化数据库,而是将重心放在将请求响应时间的方差变小。

对于分页请求,还有一个信息也很重要,就是总共的记录数。我们可以通过下面的查询很容易的获取总的记录数。

SELECT COUNT(*)

FROM city

然而,上面的SQL在采用InnoDB为存储引擎时需要耗费9.28sec。一个不正确的优化是采用 SQL_CALC_FOUND_ROWS,SQL_CALC_FOUND_ROWS 可以在能够在分页查询时事先准备好符合条件的记录数,随后只要执行一句 select FOUND_ROWS()就能获得总记录数。但是在大多数情况下,查询语句简短并不意味着性能的提高。不幸的是,这种分页查询方式在许多主流框架中都有用到,下面看看这个语句的查询性能。

SELECT SQL_CALC_FOUND_ROWS *

FROM city

ORDER BY id DESC

LIMIT 100000, 15

这个语句耗时20.02sec,是上一个的两倍。事实证明使用 SQL_CALC_FOUND_ROWS 做分页是很糟糕的想法。

下面来看看到底如何优化。文章分为两部分,第一部分是如何获取记录的总数目,第二部分是获取真正的记录。

高效的计算行数

如果采用的引擎是MyISAM,可以直接执行COUNT(*)去获取行数即可。相似的,在堆表中也会将行数存储到表的元信息中。但如果引擎是InnoDB情况就会复杂一些,因为InnoDB不保存表的具体行数。

我们可以将行数缓存起来,然后可以通过一个守护进程定期更新或者用户的某些 *** 作导致缓存失效时,执行下面的语句:

SELECT COUNT(*)

FROM city

USE INDEX(PRIMARY)

获取记录

下面进入这篇文章最重要的部分,获取分页要展示的记录。上面已经说过了,大的偏移量会影响性能,所以我们要重写查询语句。为了演示,我们创建一个新的表“news”,按照时事性排序(最新发布的在最前面),实现一个高性能的分页。为了简单,我们就假设最新发布的新闻的Id也是最大的。

CREATE TABLE news(

id INT UNSIGNED PRIMARY KEY AUTO_INCREMENT,

title VARCHAR(128) NOT NULL

) ENGINE=InnoDB

一个比较高效的方式是基于用户展示的最后一个新闻Id。查询下一页的语句如下,需要传入当前页面展示的最后一个Id。

SELECT *

FROM news WHERE id <$last_id

ORDER BY id DESC

LIMIT $perpage

查询上一页的语句类似,只不过需要传入当前页的第一个Id,并且要逆序。

SELECT *

FROM news WHERE id >$last_id

ORDER BY id ASC

LIMIT $perpage

上面的查询方式适合实现简易的分页,即不显示具体的页数导航,只显示“上一页”和“下一页”,例如博客中页脚显示“上一页”,“下一页”的按钮。但如果要实现真正的页面导航还是很难的,下面看看另一种方式。

SELECT id

FROM (

SELECT id, ((@cnt:= @cnt + 1) + $perpage - 1) % $perpage cnt

FROM news

JOIN (SELECT @cnt:= 0)T

WHERE id <$last_id

ORDER BY id DESC

LIMIT $perpage * $buttons

)C

WHERE cnt = 0

通过上面的语句可以为每一个分页的按钮计算出一个offset对应的id。这种方法还有一个好处。假设,网站上正在发布一片新的文章,那么所有文章的位置都会往后移一位,所以如果用户在发布文章时换页,那么他会看见一篇文章两次。如果固定了每个按钮的offset Id,这个问题就迎刃而解了。Mark Callaghan发表过一篇类似的博客,利用了组合索引和两个位置变量,但是基本思想是一致的。

如果表中的记录很少被删除、修改,还可以将记录对应的页码存储到表中,并在该列上创建合适的索引。采用这种方式,当新增一个记录的时候,需要执行下面的查询重新生成对应的页号。

SET p:= 0

UPDATE news SET page=CEIL((p:= p + 1) / $perpage) ORDER BY id DESC

当然,也可以新增一个专用于分页的表,可以用个后台程序来维护。

UPDATE pagination T

JOIN (

SELECT id, CEIL((p:= p + 1) / $perpage) page

FROM news

ORDER BY id

)C

ON C.id = T.id

SET T.page = C.page

现在想获取任意一页的元素就很简单了:

SELECT *

FROM news A

JOIN pagination B ON A.id=B.ID

WHERE page=$offset

还有另外一种与上种方法比较相似的方法来做分页,这种方式比较试用于数据集相对小,并且没有可用的索引的情况下—比如处理搜索结果时。在一个普通的服务器上执行下面的查询,当有2M条记录时,要耗费2sec左右。这种方式比较简单,创建一个用来存储所有Id的临时表即可(这也是最耗费性能的地方)。

CREATE TEMPORARY TABLE _tmp (KEY SORT(random))

SELECT id, FLOOR(RAND() * 0x8000000) random

FROM city

ALTER TABLE _tmp ADD OFFSET INT UNSIGNED PRIMARY KEY AUTO_INCREMENT, DROP INDEX SORT,ORDER BY random

接下来就可以向下面一样执行分页查询了。

SELECT *

FROM _tmp

WHERE OFFSET >= $offset

ORDER BY OFFSET

LIMIT $perpage

简单来说,对于分页的优化就是。。。避免数据量大时扫描过多的记录。

1、首先我们建立一个表表的数据,这个表里有25条数据,id从1到25。(下图是部分截图)

2、要分页数据,首先我们假设一页有10条数据,我们可以用mysql的limit关键字来限定返回多少条数据。并且用orderby来排序数据,这里用id来排序。所以第一页的sql可以如图这样写。

3、执行后得到的数据如图,就是id从1到10的前10条数据,因为我们是按id升序来排序的。

4、上面第一页的sql是简化的写法,完整的写法如图,得到的结果和上图的一模一样。代码里limit0,10的意思是从第一条数据开始,取10条数据。(注意的是第一条数据是从0开始的)

5、那么第二页的数据,关键是要知道是从哪一条数据开始,可以用这个公式得到:(页码-1) *每页显示多少条,即(2-1)*10=10,所以sql语句如图,limit10,10。

6、执行后,结果正确,得到id从11到20的10条数据。

7、同理第三页数据的sql如图,<br/>就是limit20,10。

8、查询的结果如图,因为这页只剩下5条数据了,所以只显示5条数据。如果你有更多页的数据,后面的数据只需要按上面的公式,得到从哪行开始,就可以写对应的sql语句了。


欢迎分享,转载请注明来源:内存溢出

原文地址: http://outofmemory.cn/zaji/7340580.html

(0)
打赏 微信扫一扫 微信扫一扫 支付宝扫一扫 支付宝扫一扫
上一篇 2023-04-04
下一篇 2023-04-04

发表评论

登录后才能评论

评论列表(0条)

保存