По материалам статьи Craig Freedman: Decorrelating Subqueries

В статье про скалярные подзапросы было несколько примеров, в которых оптимизатор мог переписать запрос с коррелированным подзапросом как запрос с соединением. Например, можно было видеть, как представленный ниже простой подзапрос с «in»:

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)

Мог быть переписан как левое полу-соединение:

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

Обратите внимание, что между двумя просмотрами таблицы отсутствует какая-либо корреляция. Просмотр любой таблицы может происходить независимо и будет использоваться такой алгоритм соединения, какой наиболее удобен.  Эту стратегию оптимизации мы называем декорреляцией. Невозможно удалить корреляцию для всех подзапросов. Например, как мы видели в предыдущих статьях, простая замена подзапроса «in» скалярным подзапросом «=» (при отсутствии уникального индекса) вынуждает оптимизатор добавить в план оператор Assert, и это не позволит избавиться от корреляции подзапроса. Даже когда мы можем декоррелировать подзапрос, в результате необязательно получаем лучший план, но появляется возможность проанализировать больше вариантов плана и найти из них лучший.

Декорреляция подзапросов с агрегацией

Вспомним представленный ниже запрос из предыдущей статьи:

select *
from T1
where T1.a > (select max(T2.a) from T2 where T2.b < T1.b)
  |--Filter(WHERE:([T1].[a]>[Expr1008]))
       |--Nested Loops(Inner Join, OUTER REFERENCES:([T1].[b]))
            |--Table Scan(OBJECT:([T1]))
            |--Stream Aggregate(DEFINE:([Expr1008]=MAX([T2].[a])))
                 |--Table Scan(OBJECT:([T2]), WHERE:([T2].[b]<[T1].[b]))

Мы не можем декоррелировать этот подзапрос. Теперь рассмотрим почти идентичный запрос, но заменим в предложении where подзапроса «<» на «=»:

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

Это, казалось бы, незначительное изменение позволяет нам убрать корреляцию и написать этот запрос как обычное соединение:

select T1.*
from T1, (select T2.b, max(T2.a) max_a from T2 group by T2.b) S
where T1.b = S.b and T1.a > S.max_a

Оба запроса выбирают такой план:

  |--Nested Loops(Inner Join, OUTER REFERENCES:([T2].[b], [Expr1008]))
       |--Stream Aggregate(GROUP BY:([T2].[b]) DEFINE:([Expr1008]=MAX([T2].[a])))
       |    |--Sort(ORDER BY:([T2].[b] ASC))
       |         |--Table Scan(OBJECT:([T2]))
       |--Index Spool(SEEK:([T1].[b]=[T2].[b] AND [T1].[a] > [Expr1008]))
            |--Table Scan(OBJECT:([T1]))

Исходный план выполняет подзапрос для каждой строки T1, он работает вполне эффективно за счет предварительного вычисления всех возможных результатов агрегации для подзапроса и объединения этого результата с T2. Чтобы вычислить все возможные результаты подзапроса, к подзапросу добавляется предложение «group by» и это учитывается в ключе соединения. Оператор буферизации индекса «Index Spool» создает индекс на лету, это позволяет повысить производительность соединения. Вместо соединения вложенных циклов «Nested Loops» с просмотром таблицы на внутренней стороне у нас есть соединение «Nested Loops» индекса с поиском по индексу на внутренней стороне.

Давайте рассмотрим, когда этот новый план будет лучше изначального. Если в T2.b хранится всего несколько уникальных значений и, следовательно, будет всего несколько групп для вычисления, а также если в T1 будет много строк, новый план может оказаться очень эффективным. С другой стороны, если в T2.b много уникальных значений, а в T1 всего несколько строк, может оказаться выгоднее вычислять только те агрегаты, которые нужны:

set nocount on
declare @i int
set @i = 0
while @i < 10000
  begin
    insert T2 values(@i, @i, @i)
    set @i = @i + 1
  end

select *
from T1
where T1.a > (select max(T2.a) from T2 where T2.b = T1.b)
   |--Filter(WHERE:([T1].[a]>[Expr1008]))
       |--Stream Aggregate(GROUP BY:([Bmk1000]) DEFINE:([Expr1008]=MAX([T2].[a]), [T1].[a]=ANY([T1].[a]), [T1].[b]=ANY([T1].[b])))
            |--Nested Loops(Inner Join, WHERE:([T1].[b]=[T2].[b]))
                 |--Table Scan(OBJECT:([T1]))
                 |--Table Scan(OBJECT:([T2]))

Этот план в основном такой же, как и исходный (с корреляцией), за исключением того, что агрегация делается после соединения.

Обратите внимание, что результаты агрегации после соединения мы группируем по [Bmk1000]. Это уникальный реляционный ключ для T1. Вы можете убедиться в том, что [Bmk1000] является ключом для T1, исследовав столбец для этих значений в результатах выполнения showplan_all. Группировка по ключу T1 необходима, так как одна строка T1 может соединяться с несколькими строками T2, но запрос предполагает вычисление MAX(T2.a) значений для каждой строки T1.

Нет необходимости в сортировке потока после агрегации, поскольку соединение «Nested Loops» выбирает все строки T2, которые подлежат соединению со строкой из T1, прежде чем перейти к следующей строке T1. Другими словами, результаты соединения уже «сгруппированы» по T1, даже если они и не отсортированы по T1.

Если у T1 есть кластерный или любой уникальный индекс, мы будем использовать этот ключ вместо столбца закладки [Bmk1000]. На самом деле, если мы создадим уникальный ключ для T1, мы можем переписать запрос иначе:

create unique clustered index T1a on T1(a)

select T1.a, min(T1.b) as T1b
from T1 join T2 on T1.b = T2.b
group by T1.a
having T1.a > max(T2.a)

План в принципе тот же:

 |--Filter(WHERE:([T1].[a]>[Expr1007]))
    |--Stream Aggregate(GROUP BY:([T1].[a]) DEFINE:([Expr1007]=MAX([T2].[a]), [Expr1008]=MIN([T1].[b])])))
        |--Nested Loops(Inner Join, WHERE:([T2].[b]=[T1].[b]))
           |--Clustered Index Scan(OBJECT:([T1].[T1a]))
           |--Table Scan(OBJECT:([T2]))

Теперь давайте рассмотрим вариант, когда в обеих таблицах много строк, размер соединения увеличивается, и оптимизатор может выбрать еще один вариант плана:

set nocount on
declare @i int
set @i = 0
while @i < 10000
  begin
    insert T1 values(@i, @i)
    set @i = @i + 1
  end
  
select *
from T1
where T1.a > (select max(T2.a) from T2 where T2.b = T1.b)
   |--Hash Match(Inner Join, HASH:([T1].[b])=([T2].[b]), RESIDUAL:([T1].[b]=[T2].[b] AND [T1].[a]>[Expr1007]))
       |--Clustered Index Scan(OBJECT:([T1].[T1a]))
       |--Hash Match(Aggregate, HASH:([T2].[b]), RESIDUAL:([T2].[b] = [T2].[b]) DEFINE:([Expr1007]=MAX([T2].[a])))
            |--Table Scan(OBJECT:([T2]))

Этот план по сути такой же, как исходный декоррелированный план, за исключением того, что вместо потоковой агрегации и соединения «Nested Loops»  используется хэш-агрегация и хеш-соединение.

Как видите, декорреляция подзапроса может открыть возможность получения множества альтернативных планов.

Комментарии (0)