我的主机内存只有 100G ,现在要对一个 200G 的大表做全表扫描,会不会把数据库主机的内存用光了?比如逻辑备份的时候,是做整库扫描。

全表扫描对 server 层的影响

现在要对一个 200G 的 InnoDB 表 db1. t ,执行一个全表扫描。当然,你要把扫描结果保存在客户端,会使用类似这样的命令:

mysql -h$host -P$port -u$user -p$pwd -e "select * from db1.t" > $target_file

 InnoDB 的数据是保存在主键索引上的,所以全表扫描实际上是直接扫描表 t 的主键索引.这条查询语句由于没有其他的判断条件,所以查到的每一行都可以直接放到结果集里面,然后返回给客户端。

实际上,服务端并不需要保存一个完整的结果集。取数据和发数据的流程是这样的:
1.  获取一行,写到 net_buffer 中。这块内存的大小是由参数 net_buffer_length 定义的,默认是16k 。
2.  重复获取行,直到 net_buffer 写满,调用网络接口发出去。
3.  如果发送成功,就清空 net_buffer ,然后继续取下一行,并写入 net_buffer 。
4.  如果发送函数返回 EAGAIN 或 WSAEWOULDBLOCK ,就表示本地网络栈( socket sendbuffer )写满了,进入等待。直到网络栈重新可写,再继续发送。

                                                        数据库内存的控制:Server和Innodb内存

从上面的过程可以看出:

1.  一个查询在发送过程中,占用的 MySQL 内部的内存最大就是 net_buffer_length 这么大,并不会达到 200G ;
2. socket send buffer  也不可能达到 200G (默认定义 /proc/sys/net/core/wmem_default ),如果 socket send buffer 被写满,就会暂停读数据的流程

也就是说, MySQL 是 “ “ 边读边发的 ” ” ,这个概念很重要。这就意味着,如果客户端接收得慢,会导致 MySQL 服务端由于结果发不出去,这个事务的执行时间变长。例如,我故意让客户端不去读 socket receive buffer 中的内容,在服务端show processlist 看到的结果 是State 的值一直处于 “Sending to client” ,就表示服务器端的网络栈写满了

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

因此,如果一个查询的返回结果不会很多的话,我都建议你使用 mysql_store_result 这个接口,直接把查询结果保存到本地内存

总之,查询的结果是分段发给客户端的,因此扫描全表,查询返回大量的数据,并不会把内存打爆。

全表扫描对 InnoDB 的影响

 InnoDB 内存的一个作用,是保存更新的结果,再配合 redo log ,就避免了随机写盘。

内存的数据页是在 Buffer Pool (BP) 中管理的,在 WAL 里 Buffer Pool  起到了加速更新的作用。而实际上, Buffer Pool  还有一个更重要的作用,就是加速查询:由于有 WAL 机制,当事务提交的时候,磁盘上的数据页是旧的,那如果这时候马上有一个查询要来读这个数据页,这时候内存数据页的结果是最新的,直接读内存页就可以。所以说, Buffer Pool 还有加速查询的作用。

而这个加速效果依赖于一个重要的指标,即: 内存命中率。InnoDB Buffer Pool 的大小是由参数 innodb_buffer_pool_size 确定的。 innodb_buffer_pool_size 小于磁盘的数据量是很常见的。如果一个 Buffer Pool 满了,而又要从磁盘读入一个数据页,那肯定是要淘汰一个旧数据页的。InnoDB 内存管理用的是最近最少使用 (Least Recently Used, LRU) 算法,这个算法的核心就是淘汰最久未使用的数据。

这个LRU算法使用链表来实现的:

                                                           数据库内存的控制:Server和Innodb内存

1.  在上图 的状态 1 里,链表头部是 P1 ,表示 P1 是最近刚刚被访问过的数据页;假设内存里只能放下这么多数据页;
2.  这时候有一个读请求访问 P3 ,因此变成状态 2 , P3 被移到最前面;
3.  状态 3 表示,这次访问的数据页是不存在于链表中的,所以需要在 Buffer Pool 中新申请一个数据页 Px ,加到链表头部。但是由于内存已经满了,不能申请新的内存。于是,会清空链表末尾 Pm 这个数据页的内存,存入 Px 的内容,然后放到链表头部
4.  从效果上看,就是最久没有被访问的数据页 Pm ,被淘汰了。

假设按照这个算法,我们要扫描一个 200G 的表,而这个表是一个历史数据表,平时没有业务访问它。那么,按照这个算法扫描的话,就会把当前的 Buffer Pool 里的数据全部淘汰掉(在我们扫描数据量比较大的一个表时,有可能将整个表的数据都缓存到LRU链表里,淘汰掉其他有用的数据)而这些页通常只是在这次查询中需要,并不是活跃数据。如果放入到LRU首部,那么非常可能将真正的热点数据从LRU列表中移除,

所以, InnoDB 不能直接使用这个 LRU 算法。实际上, InnoDB 对 LRU 算法做了改进:

                                                   数据库内存的控制:Server和Innodb内存

在 InnoDB 实现上,按照 5:3 的比例把整个 LRU 链表分成了 young 区域和 old 区域(新旧两个队列图中 LRU_old 指向的就是 old 区域的第一个位置,是整个链表的 5/8 处。也就是说,靠近链表头部的 5/8 是 young 区域,靠近链表尾部的 3/8 是 old 区域。

改进LRU算法流程如下:

1.  图 7中状态 1 ,要访问数据页 P3 ,由于 P3 在 young 区域,因此和优化前的 LRU 算法一样,将其移到链表头部,变成状态 2
2.  之后要访问一个新的不存在于当前链表的数据页,这时候依然是淘汰掉数据页 Pm ,但是新插入的数据页 Px ,是放在 LRU_old 处。(需要新插入的数据页,都被放到old区域)

3.  处于 old 区域的数据页,每次被访问的时候都要做下面这个判断

  • 若这个数据页在 LRU 链表中存在的时间超过了 1 秒,就把它移动到链表头部
  • 如果这个数据页在 LRU 链表中存在的时间短于 1 秒,位置保持不变。 1 秒这个时间,是由参数 innodb_old_blocks_time 控制的。其默认值是 1000 ,单位毫秒。

这个策略,就是为了处理类似全表扫描的操作量身定制的。刚刚的扫描 200G 的历史数据表为例:

1.  扫描过程中,需要新插入的数据页,都被放到 old 区域 ;
2.  一个数据页里面有多条记录,这个数据页会被多次访问到,但由于是顺序扫描,这个数据页第一次被访问和最后一次被访问的时间间隔不会超过 1 秒,因此还是会被保留在 old 区域;说明这次数据并不是真正的活跃数据,不会把他放到真正的buffer pool区域
3.  再继续扫描后续的数据,之前的这个数据页之后也不会再被访问到,于是始终没有机会移到链表头部(也就是 young 区域),很快就会被淘汰出去。

总之:第一次从磁盘读入内存的数据页,会先放在 old 区域。如果 1 秒之后这个数据页不再被访问了,就不会被移动到 LRU 链表头部,这样对 Buffer Pool 的命中率影响就不大

 InnoDB 引擎内部,由于有淘汰策略,大查询也不会导致内存暴涨。并且,由于 InnoDB 对LRU 算法做了改进,冷数据的全表扫描,对 Buffer Pool 的影响也能做到可控。

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

 

相关文章: