Перевод: Как MS SQL Server выполняет запросы. Часть 2

Продолжаю публиковать перевод статьи Remus Rusanu (CC-BY), о том,  как MS SQL Server выполняет запросы. В этой части разберём, как данные хранятся внутри БД, а также как именно происходит их считывание в рамках запроса.

Первая и третья части доступны по соответствующим ссылкам.

Организация данных

Поговорим о том, как SQL Server хранит данные, потому что без этого будет сложно разобраться, как SQL Server обращается к ним при выполнении запроса. Есть три основных варианта хранения данных в SQL Server:

Куча. Это неупорядоченная таблица. Куча хранит данные всех колонок таблицы. Если таблица организована как куча, то при любом упоминании «кучи» подразумевается сама таблица – и наоборот.

Если при создании таблицы вы не указали Primary key – это куча. Если выполнили Select … INTO – то в результате тоже получите кучу. Подробнее о внутренней организации куч рассказано в документации.

Кластерные индексы. В отличие от кучи, это упорядоченная таблица. Кластерный индекс хранит в себе все колонки таблицы. Если таблица организована как кластерный индекс, то такой индекс и есть таблица. Кластерный индекс представляет собой сбалансированное дерево (B-Tree)

Если вы объявили первичный ключ (Primary key) на таблице, этот самый ключ будет использован для кластерного индекса (то есть, индекс будет построен по колонке PK). Если, конечно, вы не стали явно объявлять PK как NONCLUSTERED.

Подробнее о внутренней организации кластерного индекса также рассказано в документации.

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

Нексластерные индексы, так же, как и кластерные  - являются сбалансированными деревьями. Детальнее структура хранения некластерных индексов описана в BOL.

От переводчика. По моему скромному мнению, это самая неудачная часть статьи – всё объяснено, но понятнее от этого не становится. Поэтому позволю себе вмешаться.

И кластерные индексы, и некластерные представляют собой деревья. Но у кластерного индекса на последнем, листовом уровне, содержатся сами данные. А некластерный вместо этого хранит ссылки на основное хранилище данных – кластерный индекс или, если его нет, на записи кучи.

Начиная с SQL Server 2012 появились новые способы организации данных – некластерные колоночные индексы (nonclustered columnstore index), а ещё позже – и кластерные колоночные индексы (clustered columnstore index). Об особенностях их работы можно узнать в статьях по соответствующим ссылкам.

Доступ к данным

На "листьях" дерева выполнения (помним про то, как выглядит план запроса?) находятся операторы физического доступа к данным. Эти операторы возвращают строку данных из таблицы (или индекса) каждый раз, когда вызывается их метод next(). Есть три метода доступа к данным:

Оператор сканирования (Scan)

Оператор сканирования пробегается по всем строкам источника. Этот оператор не может выбрать одну конкретную строку – ему всегда приходится сканировать весь набор данных.

Если вы посмотрите на содержимое плана выполнения, вы скорее всего встретите один из этих операторов: Clustered Index Scan, Nonclustered Index Scan, Table Scan, Remote Index Scan или Remote Scan. Это действительно разные операторы, т.к. они обращаются к разным источникам (соответственно, отличается внутренняя механика работы). Но они едины в одном – каждому из этих операторов во время выполнения приходится выполнять полное сканирование источника, от начала до конца.

Поскольку таким операторам всегда приходится считывать весь источник данных, они оказываются довольно дорогими (как минимум, с точки зрения ввода-вывода). Обычно использование таких операторов – это удел тяжёлых аналитических запросов.

Оператор поиска (Seek)

Этот оператор может найти нужную строку при помощи ключа индекса. Поиск выполняется только в структурах сбалансированных деревьев – поэтому, он может работать только по кластерным и некластерным индексам.

Если индекс построен по нескольким колонкам, оператор поиска может сработать только если у него есть значения для самых левых (или первых) колонок в индексе. Разберём это на примере: предположим, у нас есть индекс, построенный по трём колонкам (A, B, C). В этом случае поиск может найти строки, если в условии поиска указаны значения для колонки A (A = ‘a’), для колонок A и B (A  = ‘a’ AND B = ‘b123’) или для всех трёх колонок (A  = ‘a’ AND B = ‘b123’ AND C = ‘c15’). Но если мы пропустим первую колонку (B = ‘b123’ AND C = ‘c15’) оператор поиска уже будет бессилен найти что-либо.

Кроме чтения отдельных строк, оператор поиска может выполнять поиск диапазонов. Если продолжить наш пример, оператор может найти все строки, где A находится между ‘a’ и ‘z’. Или строки, где A=’d’ и B между ‘b’ и ‘n’. Но не сможет найти строки с условием только по B:  B > ‘b’ AND B < ‘n’.

Если вы посмотрите на план выполнения, вы можете встретить операторы Clustered Index Seek или Remote Index Seek. Они обращаются к разным источникам, но каждый из них может быстро найти строку по ключам индекса или же перебирать строки в отведённом диапазоне. Очевидно, не существует оператора для поиска в куче – просто потому, что куча это по определению неупорядоченная структура, для выполнения поиска ее всегда приходится прочитывать полностью.

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

Оператор поиска по закладкам. (Bookmark lookup)

Это особенный оператор доступа, который может быстро найти строку при помощи специального типа значений – закладки (bookmark).  Мы не можем самостоятельно определить закладку – движок СУБД делает это самостоятельно. И в этом состоит особенность этого оператора – он не может быть «первичным», «исходным» оператором чтения данных. Кто-то должен предоставить ему значения закладок, поэтому этот оператор всегда обращается к ключам, которые до этого уже были прочитаны либо оператором сканирования, либо оператором поиска.

Этот оператор может применяться как вместе со структурированными источниками (индексы), так и с кучей. В плане выполнения он может отображаться как Bookmark LookupRow ID Lookup (поиск по куче) или Key Value Lookup (поиск, специфичный для B-деревьев).

Подытожив:

Строго говоря, операторы, применяемые для вставки, удаления или модификации строк – это тоже операторы доступа к данным. Например, Inserted Scan и Deleted Scan – это операторы для доступа к псевдо-таблицам inserted и deleted, которые появляются в контексте выполнения триггера. Или вот оператор Log Row Scan – ещё более  эзотерический способ доступа к данным, который обращается не к таблицам с данными, а к логу транзакций. Но если мы продолжим спускаться в такие детали, мы слишком далеко отклонимся от цели этой статьи.

Вы можете часто встречать упоминание концепции «сканирование диапазона» (Range Scan). В этом случае имеют в виду оператор поиска (Seek), который быстро находит одну строку по ключу, а затем перебирает последующие строки – иногда, до нахождения другого определённого значения ключа. Получается, что оператор поиска выполняет сканирование внутри диапазона, определённого начальным и конечным значениями ключей. Именно поэтому и используют термин «сканирование диапазона».

Если мы вспомним, как именно происходит выполнение запроса, мы поймём, что именно операторы доступа к данным приводят в действие цикл выполнения запроса. Когда метод next() вызывается для самого верхнего оператора, он передаёт вызов дальше по дереву выполнения, пока не доберётся до оператора доступа к данным. Этот оператор при реализации next() как раз считывает очередную строку данных из соответствующего источника (кучи или B-дерева) – и возвращает считанную строку вверх по дереву. Оператор помнит, где остановилось чтение и при следующем обращении прочитает следующую строку. Операторы доступа к данным не могут иметь подчинённых операторов, они всегда находятся на листовом уровне дерева выполнения. С другой стороны, операторы, располагающиеся выше по дереву, реализуют разные манипуляции со считанными данными: соединение, отбор, сортировка результатов, вычисление агрегатов и т.д.

Чтение данных

Операторы доступа к данным всегда считывают данные из кэша и никогда – с диска. Этот кэш называется Buffer Pool.  Если данных нет в кэше, оператор должен запросить их с диска (провоцируя обращения ввода-вывода к диску) и ждать, пока они не будут считаны с диска. Данные в кэше (Buffer pool) доступны всем выполняемым запросам – таким образом, после того, как данные были прочитаны и попали в кэш, любой следующий запрос уже воспользуется преимуществами кэша и не будет тратить время на обращение к диску.

SQL Server всегда читает в кэш столько данных, сколько может. При этом увеличивается частный объем памяти процесса (process allocated private memory), пока вся память системы не достанется SQL Server. Именно поэтому администраторы всегда настраивают максимальный порог потребления памяти (max server memory).

Buffer Pool, так же, как и отдельные операции чтения/записи, никогда не работает с отдельными строками. Вместо этого он работает со страницами, размер которых всегда равен 8Кб.

Посмотрим, как оператор доступа данных (в данном случае Сканирование) будет читать данные из кучи:

Для сравнения, вот как оператор доступа будет работать с отсортированным B-деревом (помним про индексы?):

Больше подробностей – в статье Showplan Logical and Physical Operators Reference

Упреждающее чтение (Read ahead)

Из описания работы оператора сканирования вы можете заметить, что каждый раз, когда он заканчивает чтение строк со страницы, ему тут же требуется получить из Buffer pool следующую страницу. Если нужной страницы в Buffer pool нет, приходится ждать, пока она не будет считана с диска. И если ожидания чтения с диска будут повторяться после каждой обработанной страницы, общая производительность запроса просядет.

Решение – заранее считать в память те страницы, которые пока ещё не были запрошены, но которые скоро понадобятся оператору доступа для выполнения запроса. SQL Server именно так и поступает: выполняет асинхронное упреждающее чтение тех страниц, которые будут запрошены оператором, ещё до того момента, как оператор запросит их для обработки строк. Таким образом, к тому моменту, как оператор обратится к странице, она уже будет находиться в Buffer pool – и доступ к такой странице будем практически мгновенным. Подробности этого механизма описаны в статьях Reading Pages и Sequential Read Ahead. Кроме того, существует отдельный вариант упреждающего чтения для доступа к случайным страницам в рамках вложенных циклов (Nested Loops): Random Prefetching.


в следующей части - обзор механизма блокировок и разбор процесса записи изменений в БД

@frrrost
24.08.2021 12:35 UTC
Первоисточник