Некоторые вещи отлично сочетаются друг с другом: шоколад и арахисовое масло, кино и попкорн и т.д. Однако предостаточно существует и неудачных сочетаний: лимонад и чистка зубов, понедельник и счастье, GUID и кластеризованные индексы…

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

GUID – это глобальный уникальный идентификатор длиной 16 байт. Собственно, в GUID нет ничего плохого, но при определенных условиях применения они могут сильно ухудшать производительность SQL Server. GUID часто используются для уникальной идентификации строк в таблице, благодаря весьма малой вероятности исчерпания всех возможных значений в данном диапазоне, по сравнению с другими типами данных, используемыми для подобных задач.

  • Диапазон представимых целых чисел: от -231 до 231, т.е. 4 294 967 294 возможных комбинаций.
  • Диапазон представимых длинных целых чисел: от -263 до 263, т.е. 18 446 744 073 709 551 614 возможных комбинаций.
  • Число возможных GUID: -2122, т.е. 5 316 911 983 139 663 491 615 228 241 121 400 000.

Описание вычислений приведено в документе на сайте mrdee.blogspot.com/2005/11/how-many-guid-combinations-are-there.html. Кстати, точное значение 263 нельзя вставить в Microsoft Excel или в стандартный калькулятор Windows.

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

Непоследовательность GUID

По умолчанию GUID не являются последовательными, и в этом заключается причина возникновения проблемы фрагментации. В качестве иллюстрации рассмотрим код, приведенный в листинге 1, реализующий загрузку записей в таблицу, где столбец GUID используется в качестве ключа кластеризованного индекса и ограничения первичного ключа.

Если вставить строки в таблицу (каждая строка заканчивается по достижении длины в 1 000 байт с использованием приведенных выше типов данных), мы получим результаты вызова sys.dm_db_index_physical_stats, см. листинг 2 и экран 1.

 

Результаты вызова sys.dm_db_index_physical_stats
Экран 1. Результаты вызова sys.dm_db_index_physical_stats

Уже с самого начала мы наблюдаем почти стопроцентную фрагментацию просто за счет применения GUID, а не последовательного процесса с использованием целых или длинных целых чисел в автоинкрементном режиме через свойство IDENTITY столбца таблицы.

Чтобы увидеть, насколько быстро индекс на основе GUID становится фрагментированным, вернемся в начало. Усечем таблицу и выполним ввод 8 записей. Состояние фрагментации после ввода первых 8 записей показано на экране 2.

 

Состояние фрагментации после ввода первых восьми?записей
Экран 2. Состояние фрагментации после ввода первых восьми? записей

Этого и следовало ожидать; однако нам еще предстоит заполнить страницу. Что произойдет, если добавить еще одну запись, мы видим на экране 3.

Добавление еще одной записи
Экран 3. Добавление еще одной записи

После ввода 9-й записи наступает разбиение первой страницы. Так как GUID поступают в кластеризованный индекс непоследовательно, страницы будут разбиваться, чтобы сбалансировать содержимое (примерно поровну). В таблице 9 строк, поэтому равного разбиения быть не может. Что будет, если добавить еще две строки? Поскольку страницы заполнены лишь на 55%, а мы знаем, что на странице помещается 9 строк, после этого добавления мы должны по-прежнему иметь две страницы на уровне листьев индекса, не так ли (см. экран 4)?

 

Добавление еще двух строк
Экран 4. Добавление еще двух строк

Однако мы получили еще одно разбиение страницы всего лишь после добавления двух строк. Пространство расходуется неэкономно, и при том, что используются 16-разрядные GUID, а не 4-байтовые INT или 8-байтовые BIGINT, и даже несмотря на то, что указан стопроцентный коэффициент заполнения индекса, мы не достигли этой отметки ни перед первым, ни перед вторым разбиением страницы. Проведем последний эксперимент.

Итак, у нас имеется три страницы индексов, заполненных примерно на 50%. Мы знаем, что на странице умещается девять строк, и задан коэффициент заполнения 100%. Теоретически должна существовать возможность вставить 12 строк: (50% от 9 округляем до 4, так как нельзя вставить половину записи, и умножаем на 3). Как мы уже поняли, вероятность 99% заполнения этих трех страниц чрезвычайно мала без перестроения индексов, поэтому вопрос лишь в том, насколько сильно будет фрагментироваться индекс? Выясним это.

Добавив еще 12 строк в таблицу, получаем результат, показанный на экране 5.

 

Добавление еще 12 строк
Экран 5. Добавление еще 12 строк

Странно! Добавление двух строк, 2/11 от общего их числа, вызвало разбиение страницы, то есть добавление еще одной страницы на уровне листьев индекса. Добавление еще 12 строк, то есть 12/23 от общего их числа, также вызвало разбиение страницы, но только одно, хотя доля страниц, добавленных в целом, в этом случае была намного больше, чем при добавлении 10-й и 11-й строк, в результате которого мы получили второе разбиение.

Проведем еще один маленький эксперимент: усечем таблицу и вновь добавим 23 строки. Поскольку используется команда GO #, строки добавляются в рамках единичных транзакций. Выводятся результаты вызова sys.dm_db_index_physical_stats после вставки 8-й, 9-й, 11-й и 23-й строк. Поскольку мы проходим весь процесс заново, должны получиться аналогичные результаты (см. листинг 3 и экран 6).

 

Результат усечения
Экран 6. Результат усечения

По крайней мере, результаты близкие. Ввиду случайной природы значений GUID, получилась более благоприятная картина фрагментации внутри страниц; число строк на страницах осталось неизменным. Соответственно, общая средняя степень заполнения страниц не изменилась; на данном этапе индекс остается четырехстраничным. Следовательно, выполнение сценария с усечением таблицы и повторной загрузкой 23 строк все же приводит к изменению числа страниц индекса и картины фрагментации. Я проделал это несколько раз и наблюдал среднюю степень фрагментации 50 или 75% на четырех страницах уровня листьев, заполненных примерно на 71%, но также получил индекс с пятью страницами на уровне листьев с 40-процентной средней степенью фрагментации, то есть 57-процентным средним заполнением.

Другими словами, налицо последовательная непоследовательность. Я – администратор базы данных (DBA) и терпеть не могу последовательно непоследовательных вещей.

Ключи кластеризации INT и BIGINT с использованием свойства IDENTITY

Если вас не беспокоит возможность исчерпания доступных значений, существующая при использовании целых (INT) или длинных целых (BIGINT), я рекомендую всегда задействовать один из этих типов данных, а не GUID, для суррогатного ключа кластеризации. Это позволит выполнять вставку (INSERT) последовательно и реализовать уникальную идентификацию строк в таблице при более эффективном расходовании пространства и значительно меньшей степени фрагментации.

INT и BIGINT ведут себя одинаково, поэтому ограничимся рассмотрением INT, то есть целого длиной 4 байта, а не 16 байтов, как в примере с GUID. Построим новую таблицу с длиной строки 1000, как в примере с GUID, и выполним вставку строк 8, 9, 11 и 23, чтобы сравнить результаты (см. листинг 4).

Вначале результаты, полученные с GUID и INT, выглядят одинаково (см. экран 7).

 

Сравнение результатов при первом использовании GUID и INT
Экран 7. Сравнение результатов при первом использовании GUID и INT

Получилось одной страницей меньше (25% экономии по сравнению с вариантом GUID), то есть степень заполнения страниц повысилась. Однако при таких маленьких таблицах результаты не представляют особого интереса; администраторов баз данных больше беспокоит то, что происходит, когда базы данных становятся огромными и неуправляемыми. Сравним, что получилось с использованием кластеризованного индекса на основе GUID для 80 000 строк (см. экран 8), с результатами для того же количества строк, полученными с ключом INT (см. экран 9).

 

80 000-строчный кластеризованный индекс на?базе GUID
Экран 8. 80 000-строчный кластеризованный индекс на?базе GUID

 

80 000-строчный кластеризованный индекс на?базе INT
Экран 9. 80 000-строчный кластеризованный индекс на?базе INT
  • 10 000 страниц на уровне листьев при использовании кластеризованного индекса на основе INT против 14 712 страниц – в случае с GUID.
  • Последовательные кластеризованные индексы на основе INT практически не приводят к масштабной фрагментации; аналогичные результаты получаются для BIGINT.
  • Более «узкий» индекс в виде B-дерева при использовании INT по сравнению с GUID (в моем примере: 36 промежуточных страниц против 66).

Отдавайте предпочтение INT, BIGINT и SMALLINT

При проектировании базовой структуры таблицы я бы рекомендовал избегать применения GUID. Рассмотрите варианты INT или BIGINT применительно к предполагаемому масштабу (если точно известно, что масштаб невелик, рассмотрите также SMALLINT (короткое целое) в качестве возможного варианта). Использование GUID увеличит пул доступных значений для уникальной идентификации строк в таблице, но это будет достигнуто ценой больших затрат вычислительных ресурсов и ущерба для производительности. На мой взгляд, лучше добавить дополнительный столбец, чтобы решить проблему масштаба, но не прибегать к GUID. Неэкономное расходование пространства и фрагментация – достаточные причины для того, чтобы держать таблицы и GUID на почтительном расстоянии друг от друга.

Листинг 1. Код загрузки записей в таблицу

CREATE TABLE [dbo].[tblGUID_test]
(
[id] [UNIQUEIDENTIFIER] NOT NULL,
[name] [CHAR](969) NOT NULL,
date_stamp DATETIME NOT NULL,
CONSTRAINT [PK_tblGUID_test] PRIMARY KEY CLUSTERED (id)
WITH (PAD_INDEX = OFF, FILLFACTOR = 100)
)
GO

Листинг 2. Вызов sys.dm_db_index_physical_stats

INSERT INTO tblGUID_test(id, name, date_stamp)
VALUES (NEWID(), 'FOO', GETDATE());
GO 80000
SELECT ixP.index_level
, CAST(ixP.avg_fragmentation_in_percent AS DECIMAL(5,2)) AS avg_frag_pct
, ixP.page_count
, ixP.avg_record_size_in_bytes AS avg_bytes_per_record
, CAST(ixP.avg_page_space_used_in_percent AS DECIMAL(5,2)) AS avg_page_fill_pct
FROM sys.dm_db_index_physical_stats (DB_ID(), OBJECT_ID('tblGUID_test'), 1, NULL, 'detailed') ixP
INNER JOIN sys.indexes I
ON I.index_id = ixP.index_id
AND I.object_id = ixP.object_id;

Листинг 3. Усечение таблицы

TRUNCATE TABLE tblGUID_test;
GO
INSERT INTO tblGUID_test(id, name, date_stamp)
VALUES (NEWID(), 'FOO', GETDATE())
GO 23
SELECT ixP.index_level
, CAST(ixP.avg_fragmentation_in_percent AS DECIMAL(5,2)) AS avg_frag_pct
, ixP.page_count
, ixP.avg_record_size_in_bytes AS avg_bytes_per_record
, CAST(ixP.avg_page_space_used_in_percent AS DECIMAL(5,2)) AS avg_page_fill_pct
FROM sys.dm_db_index_physical_stats (DB_ID(), OBJECT_ID('tblGUID_test'), 1, NULL, 'detailed') ixP
INNER JOIN sys.indexes I
ON I.index_id = ixP.index_id
AND I.object_id = ixP.object_id;
GO

Листинг 4. Построение новой таблицы

CREATE TABLE [dbo].[tblINT_test]
(
[id] [INT] IDENTITY(1,1) NOT NULL,
[name] [CHAR](981) NOT NULL,
date_stamp DATETIME NOT NULL,
CONSTRAINT [PK_[tblINT_test] PRIMARY KEY CLUSTERED (id)
WITH (PAD_INDEX = OFF, FILLFACTOR = 100)
)
GO
--=====================================================
--RUN IN BATCHES OF 8, 1, 2, 12 REPORTING ON OUTCOMES:
--=====================================================
INSERT INTO tblINT_test(name, date_stamp)
VALUES ('FOO', GETDATE())