MySQL大查询的问题

我们经常会遇到这样的问题,我的主机内存只有 100G,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?这个问题确实值得担心,被系统 OOM(out of memory)可不是闹着玩的。

但是,反过来想想,逻辑备份的时候,可不就是做整库扫描吗?如果这样就会把内存吃光,逻辑备份不是早就挂了?所以说,对大表做全表扫描,看来应该是没问题的。但是,这个流程到底是怎么样的呢?

全表扫描对 server 层的影响

我们已经知道,InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引。然后查到的每一行都可以直接放到结果集里面,然后返回给客户端。实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:

  • 获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是 16k。
  • 重复获取行,直到 net_buffer 写满,调用网络接口发出去。
  • 如果发送成功,就清空 net_buffer,然后继续取下一行,并写入 net_buffer。
  • 如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK,就表示本地网络栈(socket send buffer)写满了,进入等待。直到网络栈重新可写,再继续发送。

也就是说,MySQL 是“边读边发的”,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。

比如下面,我们故意让客户端不去读 socket receive buffer 中的内容,然后在服务端 show processlist 看到的结果。

    

如果你看到 State 的值一直处于“Sending to client”,就表示服务器端的网络栈写满了。

如果客户端使用–quick 参数,会使用 mysql_use_result 方法。这个方法是读一行处理一行。你可以想象一下,假设有一个业务的逻辑比较复杂,每读一行数据以后要处理的逻辑如果很慢,就会导致客户端要过很久才会去取下一行数据,可能就会出现上面的情况。

对于正常的线上业务来说,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存。

如果当你看到很多个线程都处于“Sending to client”这个状态,就意味着你要让业务开发同学优化查询结果,并评估这么多的返回结果是否合理。如果要快速减少处于这个状态的线程的话,将 net_buffer_length 参数设置为一个更大的值是一个可选方案。

如果由于客户端压力太大,迟迟不能接收结果,会导致 MySQL 无法发送结果而影响语句执行,但这还不是最糟糕的情况。最糟糕的是造成了“长事务”。至于长事务的影响,就要结合我们前面文章中提到的锁、MVCC 了。

  • 如果前面的语句有更新,意味着它们在占用着行锁,会导致别的语句更新被锁住;
  • 当然读的事务也有问题,就是会导致 undo log 不能被回收,导致回滚段空间膨胀。

与“Sending to client”长相很类似的一个状态是“Sending data”,这是一个经常被误会的问题。实际上,一个查询语句的状态变化是这样的(注意:这里,我略去了其他无关的状态):

  • MySQL 查询语句进入执行阶段后,首先把状态设置成“Sending data”;
  • 然后,发送执行结果的列相关的信息(meta data) 给客户端;
  • 再继续执行语句的流程;
  • 执行完成后,把状态设置成空字符串。

也就是说,“Sending data”并不一定是指“正在发送数据”,而可能是处于执行器过程中的任意阶段

所以,仅当线程处于“等待客户端接收结果”的状态,才会显示"Sending to client";而如果显示成“Sending data”,它的意思只是“正在执行”。

全表扫描对 InnoDB 的影响

内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool 起到了加速更新的作用。而实际上,Buffer Pool 还有一个更重要的作用,就是加速查询。而 Buffer Pool 对查询的加速效果,依赖于一个重要的指标,即:内存命中率。一般情况下,一个稳定服务的线上系统,要保证响应时间符合要求的话,内存命中率要在 99% 以上。

执行 show engine innodb status ,可以看到“Buffer pool hit rate”字样,显示的就是当前的命中率。

InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的,一般建议设置成可用物理内存的 60%~80%。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。

InnoDB 管理 Buffer Pool 的 LRU 算法,是用链表来实现的,最近刚刚被访问过的数据页会被放到链表头。如果某次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 P,但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 的数据页的内存,存入 新的的内容,然后放到链表头部。

这个算法乍一看上去没什么问题,但是如果考虑到要做一个全表扫描,按照这个算法扫描的话,就会把当前的 Buffer Pool 里的数据全部淘汰掉,存入扫描过程中访问到的数据页的内容。也就是说 Buffer Pool 里面主要放的是这个历史数据表的数据。对于一个正在做业务服务的库,这可不妙。你会看到,Buffer Pool 的内存命中率急剧下降,磁盘压力增加,SQL 语句响应变慢。

实际上,InnoDB 对 LRU 算法做了改进:

    

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域。图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 5/8 处。也就是说,靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域。改进后的 LRU 算法执行流程变成

  1. 图 中状态 1,要访问数据页 P3,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2。
  2. 之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm,但是新插入的数据页 Px,是放在 LRU_old 处。
  3. 处于 old 区域的数据页,每次被访问的时候都要做下面这个判断:
  • 若这个数据页在 LRU 链表中存在的时间超过了 1 秒,就把它移动到链表头部;
  • 如果这个数据页在 LRU 链表中存在的时间短于 1 秒,位置保持不变。1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000,单位毫秒。

这个策略,就是为了处理类似全表扫描的操作量身定制的。可以看到,这个策略最大的收益,就是在扫描这个大表的过程中,虽然也用到了 Buffer Pool,但是对 young 区域完全没有影响,从而保证了 Buffer Pool 响应正常业务的查询命中率。

内容来源: 林晓斌《MySQL实战45讲》

猜你喜欢

转载自blog.csdn.net/qq_24436765/article/details/112846556