-
查询执行的内存授予(Query Execution Memory Grant)
有些操作符需要较多的内存才能完成操作。例如,SORT、HASH、HAS聚合等。执行计划通过操作符需要处理数据量的预估值(通过统计信息获得的预估行数)、操作符类型和列大小来获得所需要的内存总量。这个执行计划的内存总量,也叫做内存授予(Memory Grant)。
当多并发查询的环境中,如果 查询中有很多这种需要大量内存授予的操作符时,并发的执行计划所需的内存授予总量可能会超过服务器内存总量。SQL Server使用 资源信号量(Resource Semaphore) 来避免这种情况发生。当内存授予量超过可用内存量时,当前请求内存授予的查询必须等待其它查询完成并释放出它占用的内存授予量。
可通过 sys.dm_exec_query_memory_grants 查询当前查询内存授予的状态。当查询等待内存授予时会产生 Execution Warnings 事件。
分配内存授予时可以会出现两种情况:
- 分配小于请求量的内存,查询执行也能顺利完成
- 按请求量分配后,内存授予还是不够用。这会导致操作符的数据被 Spill Out到磁盘(tempdb)。当这种情况发生事,会产生一些警告事件:
- Exchange Spill 事件
- Sort Warnings 事件
- Hash Warning 事件
对于查询的内存授予内存是预先保留出来,而不是按需分配。所以在查询执执行中可能出现实现使用的内存量比授予理要少的情况,这部分多来的内存会用做数据缓存。因为有资源信号量的限制,所以过大内存授予量会让其它的查询缺少授予内存而等待。
SQL Server 中有一个相似的概念叫 查询编译资源信号量(query compile resource semaphore) 。它的作用机制跟资源信号量一样,但是它只针对查询的编译行为生效。通常查询编译不会成为系统的瓶颈,如果发生很可能是查询计划重用出问题了。
需要注意一点,并不是所有查询都需要内存授予才能执行。包含排序、大型扫描、HASH连接和聚合等操作的复杂查询才需要内存授予。在需要快速响应的系统中(如OLTP),如果发生内存授予问题,应当考虑从数据模型设计上做出调整。OLAP系统中的内存授予导致的延迟,通赏是正常的。
关于内存的相关资源:理解SQL Server的查询内存授予
-
数据组织方式(Data Organization)
SQL Server的数据组织方式有三种:
-
堆表:堆表中的数据是无序的。
-
聚集索引:聚集索引表中的数据是有序的,顺序跟聚集索引键相关。聚集索引是B-Tree结构。
-
非聚集索引:它的数据是表中数据子集,并且数据是有序的,顺序跟索引键相关
SQL Server 2012之后,还有列存储索引的数据组织方式。
-
-
数据访问(Data Access)
前面说到,在执行树叶级的操作符会访问实际的数据行。它们访问数据的操作符通常有三种:
-
扫描操作符(Scan Operator)
顾名思义,它会访问目标对象的所有数据行。像 Clustered Index Scan, Nonclustered Index Scan, Table Scan, Remote Index Scan and Remote Scan等操作符,它们都扫描方式,只对目标对象不一样。通常扫描成本是很高的,应当将之做为最后的数据访问。 -
查找操作符(Seek Operator)
查找是通过一个(或多个)键列定位到一行(或多行)数据。查找可以实现范围查找。查找只会出现在B-Tree上,即只有访问聚集或者非聚集索才会有查找操作。堆表的数据是无序的,所以办法通过某个键列定位到一行。查找是非常高效的,理想情况下应当做为数据访问的首选方式。 -
书签查找操作符(Bookmark Lookup Operator)
通过一种特殊的值(即书签)定位到数据行的操作符。书签由数据库引擎产生,不能人为指定,通常来自前一个操作符的输出数据。书签查找可以在任何数据组织方式的对象上发生。书签查找操作符包括:Bookmark Lookup, Row ID Lookup (堆表上的查找)或者 Key Value Lookup (B-Tree上的查找)。
严格来说,数据访问操作符还有 Inserted Scan 和 Deleted Scan ,它们访问是inserted和deleted 伪表。还有 Log Row Scan ,它从事务日志读取数据,而不是表。
还有一个叫范围扫描的概念。它是Seek操作符根据提供的两个键值,扫描键值区间内的数据时的操作。
现在回头再去看查询执行过程,我们就会明白数据访问操作符是如何驱动整个执行计划进行迭代的了:执行树根节点的操作符调用next()并没着子节点逐级调用next(),直到到达到数据访问操作符所在叶节点。而这些叶节点操作符通过读取实际的数据和返回相关数据来实现next()接口。
-
-
数据读取 (Data Reading)
数据访问操作符会只从缓存池(Buffer Pool)读取数据。如果缓存池中没有所需要的数据,则需要将数据从存储子系统读取到缓存池。缓存池的数据被所有查询共享。SQL Server会尽将尽可能多的数据缓存到缓存池,以备使用,直到用尽所有分配给SQL Server的内存量。可以通过 max server memory 选项控制缓存池的内存使用上限。无论是缓存池,还是磁盘IO,它们数据请求的都以8KB页为单位。
让我们来看看扫描(Scan)数据访问操作符是如何从堆上读取数据的:
- 当在扫描操作符上每一次调用next()时,它去找到第一条数据并返回。SQL Server 内部的元数据信息表存储着哪些页属于哪一个表的信息。数据访问操作符会请求一个指针,此指针指向此数据页在缓存池中副本的地址。如果此页在丰缓存池中,则请求会被阻塞,直到页被加载到缓存池。页包含一个由单个数据记录(Data Record)组成的数组。一个数据记录不一定是一行,有些变长和大型数据列,会跨多个页存储。数据访问操作符会定位页上的第一行数据,然后读取相关的列值并返回。操作符内部保存着当前的状态信息,此状态信息让它能够高效地返回到当前行的位置。
- 父级操作符取走返回的第一行数据。
- 当再次next(),数据访问操作符会利用之前保存的状态信息,快速地返回先前的位置,并向前移动一行定位到第二行,然后读取相关的列值并返回。
- 父级操作符取走返回的第二行数据。
- 当再次调用next(),发现数据行已经读取完了,操作符会向缓存池请求“下一页”。操作符定位到新页后,会定位到每一行数据,然后读取相关的列值并返回。
- 父级操作符取走返回的数据。
- 如此往复,直到读取完表中最后一页的最一行数据。操作符的保存的状态信息会指向“已超出表尾”,没有可以返回的数据了。
- 当年数据访问操作符完成它的工作,不再返回数据,父级操作符会开始处理自己的操作(如排序,HASH等)。
- 数据访问操作能被重绕(Rewind)。例如,扫描操作符作为嵌套循环连接的内表,当从外表中输入一行,就需要重绕内表的扫描操作符。重绕会导致数据访问操作符重置内部状态信息,即重新从第一页的第一行开始读取数据。
作为对比,再来看看数据访问操作如何在B-Tree上访问数据的:
- 当第一次调用 next(),数据访问操作符根据请求的键找到第一行数据并返回。SQL Server 有元数据表保存哪些页属于哪一个索引的信息。它不像堆表直接定位到第一页,而是通过元数据获取到B-Tree根页的ID,然后根据ID引用缓存池中的根页副本。根据搜索的键值,数据访问操作符定位到B-Tree中包含第一行数据的叶级页或者当前搜索键值“后续”行的叶级页。在树中检索路径经过的页都需要从缓存池中读取出来,同样的,如果这些页在缓存池中,就需要等待将它们从磁盘加载到缓存池中来。数据访问操作符在叶级页中检索并定位到与查找键值匹配的行并返回。
这个“后续”的意思,检索B-Tree可以是双向的(ASC OR DESC),而且根据查询提供的键值,可能找到完全匹配键值的行,也可能找不到匹配键值的行。匹配键值的行可以没有,但是叶级页总是存在的,并且页上的行与键值所指向的行在同一个B-Tree查找区间内,所以称之为“后续”。“后续”行位于键值匹配行的前面还是后面,由检索方向决定。检索方向与创建索引时指定的ASC或者DESC的排序方式是不同,前者就是指树中的检索方向,或者是指索引行的实际顺序。BY Joe .TJ
-
父级操作符取走返回的数据。
-
如果操作符用作范围扫描,则会再次调用next(),读取已返回行的后一行。操作符会保存前一个返回行的键值和位置信息。然后再上一点拒描述的B-Tree检索过程。如果当前叶级页的数据已经被取完了,则会定位到下一页的第一行并返回。索引页通过双向链表连接,每一页上都会前一页和下一页的指针。
-
父级操作符再次取走返回的数据。
-
因为范围扫描会包含范围结束位置的键值,所以当调用next()从当前行移动到后一行,而后一行的键值超过了结束位置的键值,会返回false。此处的“超过”与B-Tree检索方向和索引排序有关。
-
B-Tree操作符除能被重绕(Rewind),还能被重新绑定(Rebind)。重绕会重置操作符的状态,使其使用同样的键值重新开始查找或者范围扫描。重新绑定会改变用于查找的键值,也就是说之前的结果可能无效了。
-
预读(Read Ahead)
扫描操作每次读取完一页上的所有数据后,再去读取下一页,如果下一页不在缓存池,就需要等待将页从磁盘加载到缓存池。如果每读一页,都需要等待加载,那性能就太差了。SQL Server 使用预计的机制来优化这种操作。扫描操作符通过异步IO将现在还没有被使用到,但是很快会被用到的页提前加载到缓存池中来。
嵌套循环中还有一种特殊的预读叫随机预读(Random Prefetching),它是为了减少查找操作等待而提前将页加载到缓存池。
预读一般会预先读取500页左右的数据,如果页连续,则每一次异步IO最多可以读取64页(512KB)的数据,也就是说理想情况下8次IO可以完成一次预读。读取数据页时,根据IAM获取要预计的页地址,然后把连续的页合并到同一次IO中。索引页(指叶级页),则是通过B-Tree的中间级的索引页,获取需要预读的页地址,然后合并连续的页到同一次IO。两者中不连续的页,会单独执行IO。这里的“连续”都指的是逻辑上的连续。 BY Joe .TJ