SQL Server 的查詢過程、執行計劃學習總結

SQL Server 的查詢過程、執行計劃

Building Blocks的概念

SQL Server的每一個查詢都是由Building Block組成的集合,Building Block分爲兩種,operators和iterators。一個iterator從它的子iterator中獲取數據,經過處理後返回給它的父iterator。

所有iterator都實現了一個接口, 這個接口中有兩個函數,Open和GetRow。Open函數告訴operator準備輸出結果行,GetRow函數返回一個新的結果。

Query Plan是一個由iterators組成的樹形結構,控制流從根到葉子流動,數據流從葉子到根流動。

Iterators的屬性

  • 內存:iterator的執行需要申請內存,當執行一個iterator之前,會估計它佔用的內存並預留出足夠的內存。
  • 非阻塞iterator和阻塞iterator:非阻塞iterator從子iterator消費輸入行,和向父iterator生產輸出行是同時進行的,接收到一行輸入就立刻處理和輸出。阻塞iterator得到所有的子iterator輸入行並處理結束後,纔會向父iterator輸出。非阻塞iterator更適用於OLTP場景。
  • 動態遊標支持:動態遊標查詢有一些特別的屬性,如果一個Query Plan是一個動態遊標Query Plan,那麼它必須可以一次返回一個集合,必須可以正向Scan和反向Scan,必須可以獲取Scroll Locks。支持動態遊標的Query Plan必須滿足,組成它的每個iterator必須能夠重置狀態、正反向Scan、非阻塞。所以不是每個Query Plan都能支持動態遊標。

Scan 和 Seek

  • Scan : 掃描整張表,通常是由於要查詢的列不存在索引。如下

    |--Table Scan(OBJECT:([ORDERS]), WHERE:([ORDERKEY]=(2)))

  • Seek: 查詢的列存在索引,一般會採用Seek查詢索引列,當然也有特殊,要考慮佔用Memory和IO次數。

    |--Index Seek(OBJECT:([ORDERS].[OKEY_IDX]), SEEK:([ORDERKEY]=(2)) ORDERED FORWARD)

  • | Scan | Seek
    —|---|—
    堆 | Table Scan | -
    聚集索引 | Clustered Index Scan | Clustered Index Seek
    非聚集索引 | index Scan | Index Seek

Book Lookup

  • 原因:where從句中的謂詞是非聚集索引,不包含需要查詢的列,需要先seek非聚集索引,再從seek行的聚集索引查找到查詢列。
create table T (a int, b int, c int);
create unique clustered index T_clu_a on T(a);
create index T_b on T(b);

select c from T where b = 2;

這裏的查詢需要首先利用非聚集索引b,seek到相應的行,因爲b是非聚集索引,索引b只包含a、b兩列,所以需要再根據聚集索引 a 查找 c。

Book Lookup 查詢聚集索引是隨機I/O,因此很耗時間,因此當查詢出現大量Book Lookup時,我們通常是修改查詢SQL或者添加其他索引。

Seek的謂詞

單列索引, 假設索引是 a

當索引只有單列時,SQL Plan中下面幾種查詢謂詞可以用到該索引

  • a = 3.14
  • a > 100
  • a between 0 and 99
  • a like ‘abc%’
  • a in (2, 3, 5, 7)

下面的查詢謂詞不會用到索引。

  • ABS(a) = 1
  • a + 1 = 9
  • a like ‘%abc’

多列索引,假設索引是(a, b)

當索引有多列時,下面的查詢謂詞會用到索引Seek。

  • a = 3.14 and b = ‘pi’
  • a = ‘xyzzy’ and b <= 0

下面的查詢謂詞不會用到索引。

  • b = 0
  • a + 1 = 9 and b between 1 and 9
  • a like ‘%abc’ and b in (1, 3, 5)

唯一聚集索引(unique clustered index) 包含表所有的列,並且索引的順序即是數據在磁盤中的物理順序。

非聚集索引只包含它的索引列和所有的聚集索引列。

索引的權衡

同樣一個SQL語句,由於查詢到的數據行數不同,所執行的SQL Plan可能不同,這是由於數據庫的Optimizer對使用內存空間大小和I/O次數的權衡所致。

  • 如果每次查詢都用聚集索引,那每次查詢都要將整行的數據讀到內存,而我們需要的往往是其中的某幾列,這顯然很浪費內存資源。
  • 如果總是使用非聚集索引,可能會造成BookMark Lookup的次數過多,導致隨機I/O的次數過多。

因此,權衡內存使用和隨機I/O而改善SQL執行計劃,是任何一款SQL優化器必須要做的。

Joins,表的連接

  • Inner join, 內連接
  • Outer join, 外連接
  • Cross join, 笛卡爾積
  • Cross apply,帶參數的動態笛卡爾積(暫時這麼解釋)
  • Semi-join, 半連接
  • Anti-semi-join, 反半連接

Semi-join。所謂的semi-join是指semi-join子查詢。當一張表在另一張表找到匹配的記錄之後,semi-jion返回第一張表中的記錄。與條件連接相反,即使在右節點中找到幾條匹配的記錄,左節點的表也只會返回一條記錄。另外,右節點的表一條記錄也不會返回。半連接通常使用IN或 EXISTS 作爲連接條件。

Anti-semi-join則與semi-join相反,即當在第二張表沒有發現匹配記錄時,纔會返回第一張表裏的記錄。當使用not exists/not in的時候會用到,兩者在處理null值的時候會有所區別。

Nested Loops Join

嵌套循環連接,是使用的比較頻繁的連接方式,它連接兩張表,將其中一張表的每一行和另一張表的每一行根據是否符合連接條件相連接。下面是僞代碼。

for each row R1 in the outer table
    for each row R2 in the inner table
        if R1 joins with R2
            return (R1, R2)

例子

select *
from Sales S inner join Customers C
on S.Cust_Id = C.Cust_Id
option(loop join)

因爲沒有任何索引,所以SQL Plan如下

|--Nested Loops(Inner Join, WHERE:([C].[Cust_Id]=[S].[Cust_Id]))
   |--Table Scan(OBJECT:([Customers] AS [C]))
   |--Table Scan(OBJECT:([Sales] AS [S]))

加一個唯一聚集索引

create clustered index CI on Sales(Cust_Id)

SQL Plan

|--Nested Loops(Inner Join, OUTER REFERENCES:([C].[Cust_Id]))
   |--Table Scan(OBJECT:([Customers] AS [C]))
   |--Clustered Index Seek(OBJECT:([Sales].[CI] AS [S]), SEEK:([S].[Cust_Id]=[C].[Cust_Id]) ORDERED FORWARD)

Nested loops join支持的連接類型如下。

  • Inner join
  • Left outer join
  • Cross join
  • Cross apply and outer apply
  • Left semi-join and left anti-semi-join

不支持所有的右連接,試想如果支持右連接,就要返回(null, R2), 每次輸入外部表的一行,就要遍歷一次內部表,這樣就會出現非常多重複的(null, R2)。

Merge Join ,合併連接

合併連接僞代碼如下。

get first row R1 from input 1
get first row R2 from input 2
while not at the end of either input
    begin
        if R1 joins with R2
            begin
                return (R1, R2)
                get next row R2 from input 2
            end
        else if R1 < R2
            get next row R1 from input 1
        else
            get next row R2 from input 2
    end

Merge Join要求兩個表必須根據Where後的equal謂詞排序,如果謂詞是索引的話,SQL Plan中就不用排序了,如果不是索引,則SQL plan中需要排序operator。

  • One-to-many merge join

一對多合併連接,要求查詢必須有equal謂詞,就是where中要有 = 號。外表的排序列(也是equal謂詞中的列)必須是unique的,這樣才能執行一對多合併連接。

一對多合併連接保證外表的連接列是unique的,所以外表保證了沒有重複行,所有內部表是一直往下走的,不會出現回頭。不需要一個外部的tempdb保存外表上一行匹配的內錶行。

  • Many-to-many merge join

多對多合併,可以沒有equal謂詞,沒有equal謂詞的時候,相當於Full Outer Join。

多對多的合併連接需要一個外部的tempdb保存上一個外表行匹配的內錶行,如果外表的下一行和上一行相同,直接把tempdb中的表與之連接即可。

例子。

select *
from T1 join T2 on T1.a = T2.a
option (merge join)

對應的SQL plan

|--Merge Join(Inner Join, MANY-TO-MANY MERGE:([T1].[a])=([T2].[a]), RESIDUAL:([T2].[a]=[T1].[a]))
   |--Sort(ORDER BY:([T1].[a] ASC))
   |    |--Table Scan(OBJECT:([T1]))
   |--Sort(ORDER BY:([T2].[a] ASC))
        |--Table Scan(OBJECT:([T2]))

給T1添加一個唯一聚集索引

create unique clustered index T1ab on T1(a, b)

SQL Plan

|--Merge Join(Inner Join, MANY-TO-MANY MERGE:([T1].[a])=([T2].[a]), RESIDUAL:([T2].[a]=[T1].[a]))
   |--Clustered Index Scan(OBJECT:([T1].[T1ab]), ORDERED FORWARD)
   |--Sort(ORDER BY:([T2].[a] ASC))
        |--Table Scan(OBJECT:([T2]))

給T2添加一個唯一聚集索引

create unique clustered index T2a on T2(a)

SQL Plan

|--Merge Join(Inner Join, MERGE:([T2].[a])=([T1].[a]), RESIDUAL:([T2].[a]=[T1].[a]))
   |--Clustered Index Scan(OBJECT:([T2].[T2a]), ORDERED FORWARD)
   |--Clustered Index Scan(OBJECT:([T1].[T1ab]), ORDERED FORWARD)

Hash Join ,哈希連接

前面說到的Merge Join,必須對連接列進行排序,速度上很慢,Hash Join其實是一種空間換時間的理念,不用進行排序,但是查詢中必須要有equal謂詞。

僞代碼如下。

for each row R1 in the build table
    begin
        calculate hash value on R1 join key(s)
        insert R1 into the appropriate hash bucket
    end
for each row R2 in the probe table
    begin
        calculate hash value on R2 join key(s)
        for each row R1 in the corresponding hash bucket
            if R1 joins with R2
                return (R1, R2)
    end

因爲hash join要用到一個hash table保存外表的key經過hash之後的列,所以需要很大的內存空間,容易出現內存溢出。在內存溢出時候,數據庫內核會將溢出的bucket暫時保存在磁盤中,當內存中hash join執行完之後,再將磁盤中的bucket讀到內存中,繼續遍歷內表進行hash Join。

Left deep vs. right deep vs. bushy hash join trees
  • Left deep hash join tree

Left deep的方式是本次hashjoin的結果作爲下一次hashjoin的bulid表。這種操作的好處是相鄰的兩個hash join可以同時進行,當第一個hash join 執行到probe操作時,每次在hashtable中匹配一個連接,可以將這個已連接的行輸出到下一個hashjoin,下一個hashjoin同時進行build hashtable操作。第三次的hashjoin的build就可以複用第一次hashtable的內存空間,所以left deep比較節省空間。

  • right deep hash join tree

right deep的方式是本次hashjoin連接表作爲下一次hashjoin的probe表。這種操作的好處是可以並行的進行build hashtable。因爲每次build hashtable是相互隔離的。但是它不能共用內存空間了。

  • bushy hash join tree

同時進行多個hashjoin,然後將這幾個hashjoin的結果分別作爲build表和probe表。這種方式的並行性很強,速度快。

要根據數據行的大小和具體情況選擇是利用left deep還是right deep還是 bushy。

hash join的例子

select *
from T1 join T2 on T1.a = T2.a 

SQL Plan

|--Hash Match(Inner Join, HASH:([T1].[a])=([T2].[a]), RESIDUAL:([T2].[a]=[T1].[a]))
   |--Table Scan(OBJECT:([T1]))
       |--Table Scan(OBJECT:([T2]))

CASE表達式子查詢

示例

create table T1 (a int, b int, c int);
select
    case
        when T1.a > 0 then
            T1.b
        else
            T1.c
    end
from T1;

SQL Plan

|--Compute Scalar(DEFINE:([Expr1004]=CASE WHEN [T1].[a]>(0) THEN [T1].[b] ELSE [T1]. END))
   |--Table Scan(OBJECT:([T1]))

Expr1004被賦值爲CASE表達式的值,Expr1004可以被後面執行的operator訪問到。

帶有when子查詢的CASE

create table T2 (a int, b int)
select
    case
        when exists (select * from T2 where T2.a = T1.a) then
            T1.b
        else
            T1.c
    end
from T1

對應的SQL Plan如下

|--Compute Scalar(DEFINE:([Expr1009]=CASE WHEN [Expr1010] THEN [T1].[b] ELSE [T1]. END))
   |--Nested Loops(Left Semi Join, OUTER REFERENCES:([T1].[a]), DEFINE:([Expr1010] = [PROBE VALUE]))
        |--Table Scan(OBJECT:([T1]))
        |--Table Scan(OBJECT:([T2]), WHERE:([T2].[a]=[T1].[a]))

在Left Semi Join中可以看到一個PROBE,探針,表示是否匹配,如果探針的值是true,則Nested loops會立即返回。

THEN 子查詢

創建一個新的表T3.

create table T3 (a int unique clustered, b int);
insert T1 values(0, 0, 0);
insert T1 values(1, 1, 1);
select
    case
        when T1.a > 0 then
            (select T3.b from T3 where T3.a = T1.b)
        else
            T1.c
    end
from T1;

它對應的SQL Plan是

|--Compute Scalar(DEFINE:([Expr1008]=CASE WHEN [T1].[a]>(0) THEN [T3].[b] ELSE [T1]. END))
   |--Nested Loops(Left Outer Join, PASSTHRU:(IsFalseOrNull [T1].[a]>(0)), OUTER REFERENCES:([T1].[b]))
        |--Table Scan(OBJECT:([T1]))
        |--Clustered Index Seek(OBJECT:([T3].[UQ__T3__412EB0B6]), SEEK:([T3].[a]=[T1].[b]) ORDERED FORWARD)

這裏出現了一個新的專有名詞,PASSTHRE。首先對T1進行Table Scan,接着PASSTHRU發揮作用,如果PASSTHRU是true,即isFalseOrNull [T1].[a] > (0)是true,則T1.a <= 0或T1.a = null, 這個時候就不再對T3進行Clustered Index Seek了直接將T1返回給上層。如果PASSTHRU是false,則繼續SeekT3,也就是執行then中的語句。符合原SQL語句的邏輯。

else子查詢和多個When子查詢的情況

create table T4 (a int unique clustered, b int)

create table T5 (a int unique clustered, b int)

select
    case
        when T1.a > 0 then
            (select T3.b from T3 where T3.a = T1.a)
        when T1.b > 0 then
            (select T4.b from T4 where T4.a = T1.b)
        else
            (select T5.b from T5 where T5.a = T1.c)
    end
from T1

對應的SQL Plan

|--Compute Scalar(DEFINE:([Expr1016]=CASE WHEN [T1].[a]>(0) THEN [T3].[b] ELSE CASE WHEN [T1].[b]>(0) THEN [T4].[b] ELSE [T5].[b] END END))
       |--Nested Loops(Left Outer Join, PASSTHRU:([T1].[a]>(0) OR [T1].[b]>(0)), OUTER REFERENCES:([T1].))
            |--Nested Loops(Left Outer Join, PASSTHRU:([T1].[a]>(0) OR IsFalseOrNull [T1].[b]>(0)), OUTER REFERENCES:([T1].[b]))
            |    |--Nested Loops(Left Outer Join, PASSTHRU:(IsFalseOrNull [T1].[a]>(0)), OUTER REFERENCES:([T1].[a]))
            |    |    |--Table Scan(OBJECT:([T1]))
            |    |    |--Clustered Index Seek(OBJECT:([T3].[UQ__T3__164452B1]), SEEK:([T3].[a]=[T1].[a]) ORDERED FORWARD)
            |    |--Clustered Index Seek(OBJECT:([T4].[UQ__T4__182C9B23]), SEEK:([T4].[a]=[T1].[b]) ORDERED FORWARD)
            |--Clustered Index Seek(OBJECT:([T5].[UQ__T5__1A14E395]), SEEK:([T5].[a]=[T1].) ORDERED FORWARD)

由多個PASSTHRU控制是否執行對應的then子查詢。很好理解。

ANDs和ORs子查詢

AND子查詢

select * from T1
where exists (select * from T2 where T2.a = T1.a) and
not exists (select * from T3 where T3.a = T1.b);

它對應的SQL Plan是

|--Nested Loops(Left Anti Semi Join, WHERE:([T3].[a]=[T1].[b]))
   |--Nested Loops(Left Semi Join, WHERE:([T2].[a]=[T1].[a]))
   |    |--Table Scan(OBJECT:([T1]))
   |    |--Table Scan(OBJECT:([T2]))
   |--Table Scan(OBJECT:([T3]))

SQL Server先執行exsits子查詢,然後以這個結果爲左表連接not exists執行的結果。

OR 子查詢

select * from T1
where exists (select * from T2 where T2.a = T1.a) or
      exists (select * from T3 where T3.a = T1.b)

這個時候不能直接連接了,因爲是Or。通常Optimizer會把這樣的SQL進行轉換。上面的SQL相當於下面的。

  • 第一種方式
select * from T1

where exists
    (
    select * from T2 where T2.a = T1.a
    union all
    select * from T3 where T3.a = T1.b
    )

SQL Plan

 |--Nested Loops(Left Semi Join, OUTER REFERENCES:([T1].[a], [T1].[b]))
       |--Table Scan(OBJECT:([T1]))
       |--Concatenation
            |--Table Scan(OBJECT:([T2]), WHERE:([T2].[a]=[T1].[a]))
            |--Table Scan(OBJECT:([T3]), WHERE:([T3].[a]=[T1].[b]))

Optimizer巧妙地將查詢變成了只有一個exists子查詢的SQL,子查詢是UNION操作。

  • 第二種方式
select * from T1
where exists (select * from T2 where T2.a = T1.a)
union
select * from T1
where exists (select * from T3 where T3.a = T1.b)

SQL Plan

 |--Sort(DISTINCT ORDER BY:([Bmk1000] ASC))
       |--Concatenation
            |--Hash Match(Right Semi Join, HASH:([T2].[a])=([T1].[a]), RESIDUAL:([T2].[a]=[T1].[a]))
            |    |--Table Scan(OBJECT:([T2]))
            |    |--Table Scan(OBJECT:([T1]))
            |--Hash Match(Right Semi Join, HASH:([T3].[a])=([T1].[b]), RESIDUAL:([T3].[a]=[T1].[b]))
                 |--Table Scan(OBJECT:([T3]))
                 |--Table Scan(OBJECT:([T1]))

注意這個時候最後由一個SORT DISTINCT操作,因爲現在將原SQL分爲兩個查詢的UNION,出現了重複的行,所以SORT DISTINCT操作是爲了去重。

聚合

聚合是指通過對查詢結果的一系列計算(聚合函數),返回給用戶一行結果的操作。將多行通過分類計算返回少量行。

標量聚合

create table t (a int, b int, c int)
select count(*) from t;

對應的SQL Plan

 |--Compute Scalar(DEFINE:([Expr1004]=CONVERT_IMPLICIT(int,[Expr1005],0)))
       |--Stream Aggregate(DEFINE:([Expr1005]=Count(*)))
            |--Table Scan(OBJECT:([t]))

這裏的Conpute Scalar的作用是將計算的到的Bigint類型轉化成int。

select min(a), max(b) from t

SQL Plan

 |--Stream Aggregate(DEFINE:([Expr1004]=MIN([t].[a]), [Expr1005]=MAX([t].[b])))
       |--Table Scan(OBJECT:([t]))

標量Distinct

select count(distinct a) from t

SQL Plan

|--Compute Scalar(DEFINE:([Expr1004]=CONVERT_IMPLICIT(int,[Expr1007],0)))
   |--Stream Aggregate(DEFINE:([Expr1007]=COUNT([t].[a])))
        |--Sort(DISTINCT ORDER BY:([t].[a] ASC))
             |--Table Scan(OBJECT:([t]))

這裏需要Distinct,再執行count,用到了Order By進行排序,然後從上到下Table Scan計算a的不重複個數。

多個Distinct

select count(distinct a), count(distinct b) from t

SQL Plan

|--Nested Loops(Inner Join)
   |--Compute Scalar(DEFINE:([Expr1004]=CONVERT_IMPLICIT(int,[Expr1010],0)))
   |    |--Stream Aggregate(DEFINE:([Expr1010]=COUNT([t].[a])))
   |         |--Sort(DISTINCT ORDER BY:([t].[a] ASC))
   |              |--Table Scan(OBJECT:([t]))
   |--Compute Scalar(DEFINE:([Expr1005]=CONVERT_IMPLICIT(int,[Expr1011],0)))
        |--Stream Aggregate(DEFINE:([Expr1011]=COUNT([t].[b])))
             |--Sort(DISTINCT ORDER BY:([t].[b] ASC))
                  |--Table Scan(OBJECT:([t]))

可以發現當多個Distinct計數時,是逐個查詢每兩個Distinct,然後做一個inner join,結果作爲下一個Distinct計數查詢的輸入。

Hash 聚合

Hash聚合一般用在有Group By子句的SQL Plan中。是一種以空間換取時間的做法。

僞代碼如下。

for each input row
  begin
    calculate hash value on group by column(s)
    check for a matching row in the hash table
    if we do not find a match
      insert a new row into the hash table
    else
      update the matching row with the input row
  end
output all rows in the hash table

其實和hash join有點像,只不過這裏的hash是以group by從句中的列作爲key的,同一個bucket中的行執行聚合。

當然這種聚合方式也會出現內存溢出的情況,處理方式和hash join基本一致。

create table t (a int, b int, c int)

set nocount on
declare @i int
set @i = 0
while @i < 100
  begin
    insert t values (@i % 10, @i, @i * 3)
    set @i = @i + 1
  end
  
select sum(b) from t group by a

這裏插入了100條數據。SQL Plan如下

|--Compute Scalar(DEFINE:([Expr1004]=CASE WHEN [Expr1010]=(0) THEN NULL ELSE [Expr1011] END))
   |--Stream Aggregate(GROUP BY:([t].[a]) DEFINE:([Expr1010]=COUNT_BIG([t].[b]), [Expr1011]=SUM([t].[b])))
        |--Sort(ORDER BY:([t].[a] ASC))
             |--Table Scan(OBJECT:([t]))

是沒有使用hash 聚合的,因爲100條數據太少了,優化器認爲,對100條數據進行排序的花費,和其hash聚合對內存和時間的花費差別不大,所以不用採用複雜的hash聚合。

下一條SQL

truncate table t

declare @i int
set @i = 100

while @i < 1000
  begin
    insert t values (@i % 100, @i, @i * 3)
    set @i = @i + 1
  end
  
select sum(b) from t group by a

這裏插入了1000條數據,SQL Plan如下。

 |--Compute Scalar(DEFINE:([Expr1004]=CASE WHEN [Expr1010]=(0) THEN NULL ELSE [Expr1011] END))
       |--Hash Match(Aggregate, HASH:([t].[a]), RESIDUAL:([t].[a] = [t].[a]) DEFINE:([Expr1010]=COUNT_BIG([t].[b]), [Expr1011]=SUM([t].[b])))
            |--Table Scan(OBJECT:([t]))

這裏用到了hash聚合,優化器任務,排序1000條數據很慢,而1000條數據佔用的內存不大,所以選擇Hash聚合。

Hash聚合實現Distinct

select distinct a from t

對應的SQL Plan如下。

 |--Hash Match(Aggregate, HASH:([t].[a]), RESIDUAL:([t].[a] = [t].[a]))
       |--Table Scan(OBJECT:([t]))

這裏使用聚合的RESIDUAL是t.a = t.a,很明顯聚合後的結果是,有多少中a,就有多少個hash bucket,這樣就能做到去重。

子查詢的去相關(Decorrelating)

當子查詢和父查詢沒有相關性的時候,我們就可以並行地執行一個SQL語句。所以,去相關性是SQL優化的一個重要方法。

下面一個例子.

create table T1 (a int, b int)
create table T2 (a int, b int, c int)

select *
from T1
where T1.a in (select T2.a from T2 where T2.b = T1.b)

SQL Plan如下

|--Nested Loops(Left Semi Join, WHERE:([T2].[b]=[T1].[b] AND [T1].[a]=[T2].[a])) 
       |--Table Scan(OBJECT:([T1])) 
       |--Table Scan(OBJECT:([T2]))

從SQL Plan中可以看出,子查詢和父查詢之間有相關性,因爲子查詢中有謂詞T2.b = T1.b。

這個去相關看得迷迷糊糊的,不太懂,還需要再看看。

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章