SQL Server: Распараллеленный Просмотр

Ирина Наумова, Александр Гладченко

В этой статье я собираюсь рассмотреть то, как SQL Server распараллеливает просмотр таблицы (сканирования - scans). Оператор просмотра - один из немногих операторов, которые адаптированы к параллелизму. Большинство других операторов ничего не знают о параллелизме, и не заботятся о том, выполняются ли они параллельно; оператор просмотра является в этом случае исключением.

Как же в действительности работает распараллеленный просмотр?

Потоки, которые составляют распараллеленный просмотр, сообща трудятся над тем, чтобы выполнить полный просмотр всех строк в таблице. Априори, нет никакого явного закрепления строк или страниц за конкретными потоками. Вместо этого движок хранилища раздаёт страницы потокам динамически. Доступ к страницам таблицы координирует поставщик распараллеленных страниц (parallel page supplier). Он гарантирует, что каждая страница будет отдана только одному потоку и, таким образом, попадёт на обработку только один раз.

В начале распараллеленного просмотра каждый поток запрашивает у поставщика несколько страниц. После этого, потоки начинают обработку полученных строк и возвращают обработанные строки на выходе оператора. Когда поток заканчивает обработку полученного набора строк, он запрашивает у поставщика следующий набор.

У этого алгоритма есть пара преимуществ:

 
Он независим от числа потоков. Мы можем добавлять и удалять потоки из распараллеленного просмотра, и это будет учитываться автоматически. Если мы удвоим число потоков, каждый поток получит на обработку (приблизительно) вполовину меньше страниц. И, если система ввода-вывода сможет поддержать необходимую производительность, просмотр будет выполнен вдвое быстрее.
Он гибок, и может противостоять разбалансировке. Если один поток работает медленнее чем другие потоки, этот поток запросит меньше страниц, в то время как другие, более быстрые потоки, возьмут на себя дополнительную нагрузку. Общее время исполнения будет ухудшаться плавно. (Сравните этот сценарий с тем, что бы произошло, если присваивание страниц потокам было бы статическим: тогда общее время исполнения определял бы самый медленный поток).

Примеры

Давайте начнём с простого примера. Чтобы получить распараллеленный план, нам понадобится довольно большая таблица; если таблица будет слишком маленькой, то оптимизатор может прийти к заключению, что лучше подходит последовательный план исполнения. Показанный ниже сценарий создаёт таблицу из 1000000 строк, которые (благодаря фиксированной длине столбца char (200)) займут приблизительно 27000 страниц.
Предупреждение: Если Вы решаете выполнить этот пример, учтите, что его исполнение может занять несколько минут, которые понадобятся для заполнения таблицы данными. create table T (a int, x char(200))

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

После этого, для самого простого запроса:

select * from T

     /--Table Scan(OBJECT:([T]))

Мы получаем последовательный план! Почему же мы не получили распараллеленный план? Параллелизм используется для ускорения запросов, за счёт использования нескольких процессоров в одной задаче. Стоимость же этого запроса зависит от стоимости чтения страниц с диска (которая облегчается упреждающим чтением, а не параллелизмом), и последующего возврата обработанных строк клиенту. Для исполнения этого запроса используется сравнительно небольшое число процессорных циклов и, фактически, вероятно он работал бы медленнее, если бы мы его распараллелили.

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

select * from T where a < 1000

     /--Parallelism(Gather Streams)
        /--Table Scan(OBJECT:([T]), WHERE:([T].[a]<CONVERT_IMPLICIT(int,[@1],0)))

Выполняя этот запрос параллельно, мы можем распределить затраты по оценке предиката по нескольким процессорам (впрочем, в этом примере, предикат обходится достаточно дёшево, и, вероятно, не имеет большого значения для времени исполнения, будет ли запрос работать параллельно или нет).

Балансировка нагрузки

Как я писал выше, алгоритм распараллеленного просмотра выделяет страницы потокам динамически. Мы можем наблюдать это с помощью следующего примера. Рассмотрим запрос, который возвращает все строки таблицы:

select * from T where a % 2 = 0 or a % 2 = 1

"Хитрый" предикат запутывает оптимизатор, который неправильно оценивает количество элементов и генерирует параллельный план:

     /--Parallelism(Gather Streams)
        /--Table Scan(OBJECT:([T]), WHERE:([T].[a]%(2)=(0) OR [T].[a]%(2)=(1)))

На SQL Server 2005 используя "SET STATISTICS XML ON" мы можем узнать, сколько строк обрабатывает каждый поток. Вот результирующий XML для двухпроцессорной системы:

<RelOp NodeId="2" PhysicalOp="Table Scan" LogicalOp="Table Scan" ...>
     <RunTimeInformation>
        <RunTimeCountersPerThread Thread="2" ActualRows="530432" ... />
        <RunTimeCountersPerThread Thread="1" ActualRows="469568" ... />
        <RunTimeCountersPerThread Thread="0" ActualRows="0" ... />
     </RunTimeInformation>
     ...
</RelOp>

Как видно, оба потока (1 и 2), обрабатывают примерно половину строк. Поток 0 является координатором, или ещё его называют основным потоком. Он выполняет только ту часть плана исполнения запроса, которая выше самого верхнего итератора обмена. Таким образом, мы не ожидаем, что какие либо строки будут обработаны ещё какими-либо операторами с распараллеливанием.
Давайте повторим эксперимент, но теперь выполним одновременно ещё один последовательный запрос. Этот запрос перекрёстного соединения будет работать в течение довольно продолжительного времени (он должен обработать один триллион строк), и будет использовать очень много процессорных циклов:

select min(T1.a + T2.a) from T T1 cross join T T2 option(maxdop 1)

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

select * from T where a % 2 = 0 or a % 2 = 1

<RelOp NodeId="2" PhysicalOp="Table Scan" LogicalOp="Table Scan" ...>
     <RunTimeInformation>
        <RunTimeCountersPerThread Thread="1" ActualRows="924224" ... />
        <RunTimeCountersPerThread Thread="2" ActualRows="75776" ... />
        <RunTimeCountersPerThread Thread="0" ActualRows="0" ... />
     </RunTimeInformation>
     ...
</RelOp>

На этот раз распараллеленный поток с идентификатором 1 обработал больше 90% строк, в то время как поток 2, который был занят исполнением показанного выше запроса с последовательным планом, обработал заметно меньше строк. Распараллеленный просмотр автоматически сбалансировал работу между двумя потоками. Так как у потока 1 было больше свободных циклов (он не конкурировал с последовательным планом), он запросил и просмотрел больше страниц.
Если Вы пробуете воспроизвести этот эксперимент, не забудьте потом уничтожить последовательный запрос! Иначе, он будет продолжать выполняться и тратить впустую процессорное время в течение довольно длительного времени.
Похожая балансировка нагрузки применима в равной мере в тех случаях, когда поток замедляется из-за внешних факторов (наподобие последовательного запроса в нашем примере) или из-за внутренних факторов. Например, если обработка некоторых строк будет обходиться дороже, чем других, то мы также увидим похожее поведение.

По материалам статьи Craig Freedman: Parallel Scan

Перевод Ирины Наумовой и Александра Гладченко


Страница сайта http://test.interface.ru
Оригинал находится по адресу http://test.interface.ru/home.asp?artId=27127