Очередная статья из серии «Inside the Storage Engine» посвящена устройству страниц баз данных. Страницы нужны для хранения записей — это блок размером 8192 байта (8 КБ) в файле данных базы. Они выровнены по границам 8 КБ внутри файлов данных, начиная со смещения 0 байт в файле. Ниже — упрощённая иллюстрация базовой структуры:
Заголовок страницы
Заголовок имеет длину 96 байт. В этом разделе я возьму пример дампа заголовка страницы из DBCC PAGE и объясню, что означают все поля. Я использую базу данных из статьи о расщеплении страниц и опускаю не существенную часть вывода DBCC PAGE.
DBCC TRACEON (3604);
DBCC PAGE (N'pagesplittest', 1, 143, 1);
GO
m_pageId = (1:143) m_headerVersion = 1 m_type = 1
m_typeFlagBits = 0x4 m_level = 0 m_flagBits = 0x200
m_objId (AllocUnitId.idObj) = 68 m_indexId (AllocUnitId.idInd) = 256
Metadata: AllocUnitId = 72057594042384384
Metadata: PartitionId = 72057594038386688 Metadata: IndexId = 1
Metadata: ObjectId = 2073058421 m_prevPage = (0:0) m_nextPage = (1:154)
pminlen = 8 m_slotCnt = 4 m_freeCnt = 4420
m_freeData = 4681 m_reservedCnt = 0 m_lsn = (18:116:25)
m_xactReserved = 0 m_xdesId = (0:0) m_ghostRecCnt = 0
m_tornBits = 1333613242
Вот что означают все эти значения (заметьте, что на самой странице они хранятся не совсем в таком порядке):
- m_pageId
- Определяет номер файла, частью которого является страница, и её позицию внутри файла. В нашем примере
(1:143)
означает страницу 143 в файле 1.
- Определяет номер файла, частью которого является страница, и её позицию внутри файла. В нашем примере
- m_headerVersion
- Версия заголовка страницы. Начиная с версии 7.0 это значение всегда равно 1.
- m_type
- Тип страницы. Наиболее часто встречаются значения:
- 1 — страница данных. Хранит записи данных в куче или на листовом уровне кластерного индекса.
- 2 — страница индекса. Хранит индексные записи на верхних уровнях кластерного индекса и на всех уровнях некластерных индексов.
- 3 — смешанная страница с частью LOB-значений (text mix). Такая страница содержит небольшие фрагменты LOB-значений, а также внутренние части дерева LOB-значений. Может совместно использоваться LOB-значениями в одном разделе индекса или кучи.
- 4 — страница дерева LOB-значений, содержащая крупные фрагменты LOB-значений, относящиеся к одному столбцу.
- 7 — страница сортировки. Хранит промежуточные результаты в ходе операции сортировки.
- 8 — страница GAM. Содержит глобальную информацию о выделении экстентов в интервале GAM (каждый файл данных разбит на интервалы по 4 ГБ — столько экстентов можно представить в битовой карте на одной странице базы). По сути фиксирует, распределён экстент или нет. GAM = Global Allocation Map (Глобальная карта распределения). Такая страница в каждом файле всегда впервые встречается по счёту второй .
- 9 — страница SGAM. Содержит глобальную информацию о выделении экстентов в интервале GAM. По сути фиксирует, доступен ли экстент для выделения смешанных страниц. SGAM = Shared GAM (совместно используемая GAM). Первая такая страница — страница 3 в каждом файле.
- 10 — страница IAM. Содержит сведения о том, какие экстенты внутри интервала GAM распределены конкретной части таблицы или индекса. IAM = Index Allocation Map (карта выделения индекса).
- 11 — страница PFS. Содержит информацию о распределении и свободном месте на страницах внутри интервала PFS (каждый файл данных также делится примерно на блоки по 64 МБ — столько страниц можно представить на одной странице при помощи «байтовой карты»). PFS = Page Free Space (свободное место на страницах). Первая такая страница — страница 1 в каждом файле.
- 13 — загрузочная (boot) страница. Содержит сведения о базе данных. Она в базе одна — страница 9 в файле 1.
- 15 — страница заголовка файла. Содержит сведения о файле. По одной на файл, это страница 0 в файле.
- 16 — страница карты изменений (diff map). Содержит сведения о том, какие экстенты в интервале GAM изменялись со времени последнего полного или дифференциального резервного копирования. Первая такая страница — страница 6 в каждом файле.
- 17 — страница карты ML. Содержит сведения о том, какие экстенты в интервале GAM изменялись в режиме bulk-logged со времени последней резервной копии. Именно это позволяет переключаться в режим массового журналирования для массовых загрузок и перестроений индексов, не разрывая цепочку резервного копирования. Первая такая страница — страница 7 в каждом файле.
- 18 — страница, освобождённая DBCC CHECKDB во время операции восстановления (repair).
- 19 — временная страница, которую использует
ALTER INDEX … REORGANIZE
(илиDBCC INDEXDEFRAG
) при выполнений операций над индексом. - 20 — страница, заранее выделенная в рамках операции массовой загрузки; впоследствии она будет отформатирована как «настоящая» страница.
- Тип страницы. Наиболее часто встречаются значения:
- m_typeFlagBits
- Хранит несколько значений о странице. Для страниц данных и индексов значение 4 означает, что все строки на странице имеют одинаковый фиксированный размер. Если у страницы PFS поле m_typeFlagBits равно 1, это означает, что по крайней мере одна из страниц в интервале PFS, отображаемом этой страницей PFS, содержит хотя бы одну призрачную запись.
- m_level
- Уровень, к которому относится страница в b-дереве.
- Уровни нумеруются от 0 на листовом уровне и увеличиваются до уровня корня (то есть вершины b-дерева).
- В SQL Server 2000 листовой уровень кластерного индекса (со страницами данных) имел уровень 0, и следующий уровень выше (со страницами индекса) тоже имел уровень 0. Затем уровень повышался до корня. Поэтому, чтобы определить, действительно ли страница находилась на листовом уровне в SQL Server 2000, нужно смотреть на m_type, а также на m_level.
- Для всех типов страниц, кроме страниц индексов, уровень всегда равен 0.
- m_flagBits
- Содержит набор флагов, описывающих страницу. Например,
0x200
означает, что на странице есть контрольная сумма страницы (как в нашем примере), а0x100
— что на странице включена защита от «разорванной страницы». - Начиная с SQL Server 2005 некоторые биты больше не используются.
- Содержит набор флагов, описывающих страницу. Например,
- m_objId
- m_indexId
- В SQL Server 2000 эти поля указывали фактические идентификаторы реляционного объекта и индекса, которым выделена страница. В SQL Server 2005 это уже не так: метаданные выделения полностью изменились, и теперь эти поля идентифицируют так называемую единицу выделения (allocation unit), к которой относится страница. В этой публикации объясняется, как вычисляется идентификатор единицы выделения. Учтите, что для баз, обновлённых с SQL Server 2000, они по-прежнему могут быть фактическими идентификатором объекта и индекса. Также для баз на всех версиях многие системные таблицы по-прежнему используют фактические идентификаторы объекта и индекса.
- m_prevPage
- m_nextPage
- Указатели на предыдущую и следующую страницы на данном уровне b-дерева; хранят 6-байтовые идентификаторы страниц.
- Страницы на каждом уровне индекса связаны в двусвязный список в соответствии с логическим порядком (определяемым ключами индекса). Указатели не обязательно указывают на физически соседние страницы в файле (из‑за фрагментации).
- Страницы на левой стороне уровня b-дерева будут иметь указатель m_prevPage, равный
NULL
, а на правой стороне — m_nextPage, равныйNULL
. - В куче, или если индекс состоит лишь из одной страницы, оба указателя будут равны
NULL
для всех страниц. Есть особый случай, когда они не равныNULL
, — если куча перестроена с помощьюALTER TABLE … REBUILD
. Эта операция использует код перестроения индекса для построения листового уровня кластерного индекса, но сами связи фактически ни для чего не используются. Подробности — здесь.
- pminlen
- Размер фиксированной части записей на странице.
- m_slotCnt
- Количество записей на странице.
- m_freeCnt
- Количество байтов свободного места на странице.
- m_freeData
- Смещение от начала страницы до первого байта после конца последней записи на странице. Неважно, есть ли свободное место ближе к началу страницы.
- m_reservedCnt
- Количество байтов свободного места, зарезервированного активными транзакциями, которые освободили место на странице. Это не даёт занять это место и позволяет транзакциям корректно откатываться. Существует весьма сложный алгоритм изменения этого значения.
- m_lsn
- Номер LSN (Log Sequence Number) последней записи в журнале, которая изменила страницу.
- m_xactReserved
- Величина, на которую в последний раз увеличивали поле m_reservedCnt.
- m_xdesId
- Внутренний идентификатор самой последней транзакции, которая увеличивала поле m_reservedCnt.
- m_ghostRecCnt
- Количество фантомных записей на странице.
- m_tornBits
- Содержит либо контрольную сумму страницы, либо биты, вытесненные механизмом защиты от «оборванных страниц» — в зависимости от того, какая форма защиты страниц включена для базы данных.
Обратите внимание, что я не включил поля, начинающиеся с Metadata: — они не являются частью заголовка страницы. Во время разработки SQL Server 2005 я существенно переработал внутренности DBCC PAGE и, чтобы пользователям инструмента не приходилось самим выполнять все запросы к системным таблицам для выяснения фактических идентификаторов объекта и индекса, изменил DBCC PAGE так, чтобы он делал это сам и выдавал дополнительные результаты.
Записи данных (Data records)
- Записи данных хранятся на страницах данных.
- Записи данных содержат строки из кучи (heap) или с листового уровня кластерного индекса.
- Запись данных всегда хранит все столбцы строки таблицы — либо по значению, либо по ссылке.
- Если какие-либо столбцы имеют LOB-типы данных (text, ntext, image, а также новые LOB-типы в SQL Server 2005 — varchar(max), nvarchar(max), varbinary(max), XML), то в записи данных хранится указатель, указывающий на LOB-record, которая находится на другой странице (корень разреженного дерева, где хранится LOB-значение). Исключение — когда схема настроена на хранение LOB-столбцов «в строке» (in-row), если это возможно. То есть когда LOB-значение достаточно мало, чтобы поместиться в пределы размера записи данных. Это даёт выигрыш в производительности, так как выборка LOB-столбца не требует дополнительного ввода-вывода для чтения LOB-записи.
- В SQL Server 2005 переменные по длине не-LOB столбцы (например, varchar, sqlvariant) также могут храниться «вне строки» (off-row) как часть механизма «переполнения строки» (row-overflow) — когда длина строки таблицы превышает 8060 байт. В этом случае формат хранения такой же, как у LOB-значений: в записи данных хранится указатель на страницу с продолжением записи.
- Существует различие в расположении столбцов между кучами и кластерными индексами:
- В куче столбцы расположены в порядке, заданном в операторе CREATE TABLE (разделённые, разумеется, на фиксированной и переменной длины).
- В кластерном индексе ключи кластера становятся первыми физическими столбцами в записи, а затем идут остальные — в порядке, указанном в CREATE TABLE.
Перемещённые и перенаправляющие записи (Forwarded/Forwarding records)
- Технически это записи данных, и встречаются они только в куче.
- Перемещённая запись — это запись в куче, которая после изменения стала слишком большой, чтобы поместиться «на месте» на исходной странице, и потому была перемещена на другую страницу. Она содержит обратный указатель на перенаправляющую запись.
- Перенаправляющая запись остаётся на прежнем месте и указывает на новое местоположение перемещённой записи. Её иногда называют заглушкой (forwarding-stub), поскольку в ней хранится лишь расположение реальной записи данных.
- Так сделано для того, чтобы не приходилось обновлять записи некластерных индексов, которые ссылаются непосредственно на исходное физическое местоположение строки.
- Хотя это оптимизирует обслуживание некластерных индексов при обновлениях, это может вызывать дополнительные операции ввода-вывода при выборках. Причина в том, что запись некластерного индекса указывает на прежнее расположение строки, и может понадобиться ещё один ввод-вывод, чтобы прочитать её реальное местоположение. Это аргумент в споре «куча против кластерного индекса» в пользу кластерных индексов.
Индексные записи (Index records)
- Индексные записи хранятся на страницах индексов
- Существует два типа индексных записей (они различаются только набором хранимых столбцов):
- Те, что хранят строки некластерного индекса на его листовом уровне.
- Те, что образуют B-дерево, составляющее кластерные и некластерные индексы (то есть записи на уровнях индекса выше листового уровня кластерного или некластерного индекса).
- Подробнее о различиях между ними будет рассказано в отдельной публикации, поскольку тема непроста (особенно отличия между SQL Server 2000 и 2005) и заслуживает отдельного разбора.
- Индексные записи обычно не содержат все значения столбцов таблицы (хотя некоторые — да, могут охватывать большое количество столбцов; это так называемые «покрывающие индексы»).
- В SQL Server 2005 записи некластерного индекса могут включать LOB-значения как INCLUDE-столбцы (с точно теми же деталями хранения, что и для записей данных) и также поддерживают «переполнение строки» для вынесенных за пределы строки данных (снова — в точности как у записей данных).
LOB-записи (Text records)
- LOB-записи хранятся на LOB-страницах.
- Существует несколько видов LOB-записей, образующих древовидную структуру хранения LOB-значений; они располагаются на двух типах LOB-страниц. Как они работают и как связаны между собой, будет разобрано в будущих статьях.
- Также LOB-записи используются для хранения значений столбцов переменной длины, вынесенных из записей данных или индексных записей в рамках механизма «переполнения строки».
«Фантомные» записи (Ghost records)
- Это записи, которые были логически удалены, но ещё не удалены физически со страницы. Причины сложны, но в общем наличие «фантомов» упрощает блокировки по диапазонам ключей и откаты транзакций.
- Запись помечается специальным битом, показывающим, что это «фантом», и не может быть физически удалена, пока не зафиксируется транзакция, приведшая к её «фантомному» состоянию. После фиксации она либо удаляется асинхронной фоновой задачей (так называемой задачей очистки «ghost-cleanup), либо «оживляется» — преобразуется обратно в реальную запись — вставкой записи с точно тем же набором ключей.
Прочие типы записей
- Существуют также записи, используемые для хранения различных карт распределения, промежуточных результатов сортировки, а также метаданных файла и базы данных (например, в заголовочной странице файла и загрузочной странице базы). Об этом — в следующих публикациях (очередь тем растёт!).
Структура записи
Все записи имеют одинаковую структуру, независимо от их типа и назначения, однако число и тип столбцов отличаются. Например, запись данных из таблицы со сложной схемой может иметь сотни столбцов разных типов, тогда как запись карты распределения может иметь единственный столбец, заполняющий всю страницу.
Структура записи для несжатых данных такова:
- Заголовок записи
- Длина 4 байта.
- Два байта метаданных записи (тип записи).
- Два байта — указатель вперёд по записи на битовую карту NULL значений.
- Часть фиксированной длины — содержит столбцы с типами фиксированной длины (например, bigint, char(10), datetime).
- Битовая карта NULL значений
- Два байта — число столбцов в записи.
- Переменное число байт — по одному биту на каждый столбец в записи, независимо от того, допускает ли столбец NULL.
- Это даёт оптимизацию при чтении столбцов с NULL-значением.
- Массив смещений столбцов переменной длины
- Два байта — число столбцов переменной длины.
- По два байта на каждый такой столбец — смещение до конца значения столбца.
- Метка версионирования
- 14-байтовая структура, содержащая метку времени и указатель в «хранилище версий» (version store) в базе tempdb.
Оптимизация с помощью битовой карты NULL значений (NULL bitmap optimization)
Почему битовая карта NULL — это оптимизация?
Во-первых, наличие битовой карты NULL устраняет необходимость в специальных представлениях NULL для типов фиксированной длины. Без неё как понять, что столбец равен NULL? Для столбцов фиксированной длины пришлось бы определять специальное «NULL-значение», что увеличит накладные расходы для хранения данных такого типа. Для varchar «нулевое» значение могло бы быть пустой строкой нулевой длины, но просто проверка длины не сработает — опять понадобилось бы специальное значение. Для прочих типов переменной длины можно проверять длину. Так что нам нужна битовая карта NULL.
Во-вторых, это экономит такты процессора. Если бы не было битовой карты NULL, пришлось бы выполнять дополнительные инструкции для столбцов фиксированной и переменной длины.
Для фиксированной длины:
- Прочитать хранимое значение столбца (возможен промах кэша данных ЦП).
- Загрузить предопределённое «NULL-значение» для данного типа (возможен промах кэша данных ЦП; при выборке множества строк — только при первом чтении).
- Сравнить два значения.
Для переменной длины:
- Вычислить смещение массива переменной длины.
- Прочитать число столбцов переменной длины (возможен промах кэша данных ЦП).
- Вычислить позицию нужного элемента в массиве смещений.
- Прочитать смещение столбца (возможен промах кэша данных ЦП).
- Прочитать следующее смещение (возможен ещё один промах кэша, если предыдущее смещение попало на границу cache line size).
- Сравнить их, чтобы понять, равен ли столбец NULL (одно и то же смещение означает пустое значение).
С битовой картой NULL нужно лишь:
- Прочитать указатель на битовую карту NULL (возможен промах кэша данных ЦП).
- Вычислить дополнительное смещение до интересующего бита NULL.
- Прочитать этот бит (возможен промах кэша данных ЦП).
Итак, для одиночного обращения к столбцу фиксированной длины выигрыш сопоставим, но для столбцов переменной длины и при выборках множества строк преимущество наличия битовой карты NULL очевидно.
Использование DBCC IND и DBCC PAGE для изучения строки
Создадим для демонстрации тестовую таблицу:
USE [master];
GO
IF DATABASEPROPERTY (N'recordanatomy', 'Version') > 0 DROP DATABASE [RecordAnatomy];
GO
CREATE DATABASE [RecordAnatomy];
GO
USE [RecordAnatomy];
GO
CREATE TABLE [example] ([destination] VARCHAR(100), [activity] VARCHAR(100), [duration] INT);
GO
INSERT INTO [example] VALUES ('Banff', 'sightseeing', 5);
INSERT INTO [example] VALUES ('Chicago', 'sailing', 4);
GO
Снова воспользуемся командой DBCC IND, чтобы найти нужную страницу:
DBCC IND ('recordanatomy', 'example', 1);
GO
Помните, нам нужен трассировочный флаг 3604, чтобы вывод DBCC PAGE шёл в консоль, а не в журнал ошибок. Вывод будет примерно таким:
Slot 0 Offset 0x60 Length 33
Record Type = PRIMARY_RECORD Record Attributes = NULL_BITMAP VARIABLE_COLUMNS
Memory Dump @0x5C76C060
00000000: 30000800 05000000 0300f802 00160021 †0..............!
00000010: 0042616e 66667369 67687473 6565696e †.Banffsightseein
00000020: 67†††††††††††††††††††††††††††††††††††g
Slot 0 Column 0 Offset 0x11 Length 5
destination = Banff
Slot 0 Column 1 Offset 0x16 Length 11
activity = sightseeing
Slot 0 Column 2 Offset 0x4 Length 4
duration = 5
Давайте воспользуемся описанной выше структурой записи, чтобы разобрать её побайтно и понять, как всё хранится.
- Байт 0 — байт TagA метаданных записи.
- Значение 0x30, что соответствует 0x10 (бит 4) и 0x20 (бит 5). Бит 4 означает, что у записи есть битовая карта NULL, а бит 5 — что у записи есть столбцы переменной длины. Если бы был установлен 0x40 (бит 6), это означало бы наличие метки версионирования. Если бы был установлен 0x80 (бит 7), это означало бы, что байт 1 содержит значение.
- Биты 1–3 байта 0 задают тип записи. Возможные значения:
- 0 = основная запись (primary record). Это запись данных в куче, которая не была переслана, или запись данных на листовом уровне кластерного индекса.
- 1 = перемещённая запись (forwarded record)
- 2 = перемещающая запись (forwarding record)
- 3 = индексная запись (index record)
- 4 = фрагмент большого объекта (blob fragment)
- 5 = «фантомная» индексная запись (ghost index record)
- 6 = «фантомная» запись данных (ghost data record)
- 7 = «фантомная» версионная записи (ghost version record). Это специальная 15-байтовая запись, содержащая однобайтовый заголовок записи и 14-байтовую метку версионирования; используется в некоторых сценариях (например, при «фантомности» версии записи LOB объекта).
- В нашем примере ни один из этих битов не установлен, что означает: запись — основная (primary). Если бы запись была индексной, байт 0 имел бы значение 0x36. Помните, что тип записи начинается с бита 1 (а не бита 0), поэтому значение типа из вывода выше нужно сдвигать влево на единицу (умножать на два), чтобы получить его положение в байте.
- Байт 1 — байт TagB метаданных записи. Он может быть равен 0x00 или 0x01. Значение 0x01 означало бы «фантомную перемещающую запись» (ghost forwarded record). В нашем случае это 0x00, что ожидаемо при значении байта TagA.
- Байты 2 и 3 — смещение битовой карты NULL внутри записи. Это 0x0008 (многобайтовые значения хранятся в формате «младший байт первым»). Значит, часть фиксированной длины начинается с байта 4 и имеет длину 4 байта. Мы ожидаем этого, зная схему таблицы.
- Байты 4–7 — часть фиксированной длины. Поскольку схема таблицы нам известна, интерпретируем их как 4-байтовое целое: 0x00000005 — ожидаемое значение столбца duration.
- Байты 8 и 9 — число столбцов в записи. Значение 0x0003 — верно. Поскольку столбцов всего три, битовая карта (по одному биту на столбец) помещается в один байт.
- Байт 10 — сама битовая карта NULL. Значение 0xF8. Переведём в двоичный вид для понимания: 0xF8 = 11111000₂. Это логично: биты 0–2 соответствуют столбцам 1–3 и равны 0 (столбцы не равны NULL). Биты 3–7 соответствуют несуществующим столбцам и выставлены в 1 — для наглядности.
- Байты 11 и 12 — число столбцов переменной длины. Значение 0x0002 — снова верно. Значит, в массиве смещений столбцов переменной длины будет две двухбайтные записи — это байты 13–14 и 15–16 со значениями 0x0016 и 0x0021 соответственно. Помните: элементы массива смещений указывают на конец значения столбца — так мы узнаём длину каждого столбца без явного хранения длины.
- Итак, последнее смещение — байты 15 и 16, значит начало первого столбца переменной длины находится в байте 17 (0x11), что совпадает с дампом DBCC PAGE. Конец первого столбца — 0x0016, следовательно, первое значение занимает байты с 17 по 21 включительно. Это 0x42616E6666. Из метаданных таблицы известно, что это первый столбец varchar — destination. Преобразуя в ASCII, получаем «Banff». По той же логике второе значение — байты с 22 по 32 включительно — «sightseeing». Оба значения соответствуют ожидаемым данным.
Массив слотов
Очень распространённое заблуждение — что записи на странице всегда хранятся в логическом порядке. Это неверно. Ещё одно заблуждение — что всё свободное место на странице всегда поддерживается как один непрерывный блок. Это тоже неверно. (Да, на картинке выше свободное пространство показано одним блоком, и так действительно часто бывает для страниц, которые заполняются постепенно).
Если запись удаляется со страницы, содержимое страницы не «схлопывается» мгновенно — издержки уплотнения несёт вставляющий, когда это становится необходимым, а не удаляющий.
Представим полностью заполненную страницу — это означает, что удаление записей образует «дыры» свободного места внутри страницы. Если нужно вставить новую запись, и одна из таких дыр достаточно велика, чтобы вместить её, зачем утруждать себя уплотнением? Достаточно поместить запись туда и продолжить.
А что, если запись по логике должна была оказаться в конце всех прочих записей на странице, а мы только что вставили её куда‑то в середину — разве это не нарушает порядок? Нет, потому что массив слотов упорядочен и перестраивается по мере вставок и удалений записей на страницах. Пока первая запись массива слотов указывает на логически первую запись на странице, всё в порядке.
Каждый элемент массива слотов — это всего лишь двухбайтовый указатель внутрь страницы, поэтому гораздо эффективнее перестраивать массив слотов, чем перекладывать множество записей на странице. Только когда Storage Engine убеждается, что на странице достаточно свободного места для новой записи, но оно «размазано» по странице, он уплотняет записи на странице, превращая свободное место в непрерывный блок. Любопытный факт: массив слотов растёт «назад» — от конца страницы, поэтому свободное пространство «сжимается» сверху новыми строками, а снизу — массивом слотов.
Комментариев нет:
Отправить комментарий