Заметки Дмитрия Пилюгина о Microsoft SQL Server 

Twitter RSS
Home SQL Server (все заметки) Нужно ли бороться с фрагментацией в таблице-куче
formats

Нужно ли бороться с фрагментацией в таблице-куче

Недавно, на одном из форумов был озвучен интересный вопрос. Есть сильно фрагментированная таблица (фрагментация более 80%), без кластерного индекса. Вопрос заключается в том, что нужно ли пытаться бороться с фрагментацией, например, создавая и удаляя для этого кластерный индекс. Влияет ли фрагментация на то, как используется стратегия чтения read-ahead.

 

 

 

В этой заметке я постарался исследовать этот вопрос. Заметка состоит из следующих частей:
— Немного теории о структурах данных – основные понятия
— Немного практики в изучении структур данных
— Что такое фрагментация и какая она бывает
— Фрагментация небольших таблиц
— Что такое read-ahead
— Фрагментация экстентов – эксперимент
— Полезные ссылки

Немного теории о структурах данных – основные понятия

Для дальнейшего удобства я буду именовать SQL Server как сиквел сервер или просто сервер*.

* Кстати, почему одни произносят «сиквел», а другие «эскуэль». Есть мнение, что произношение «сиквел» возникло в лохматых 1970-х. Когда в компании IBM впервые зародился SQL, то его аббревиатура была Structured English QUEry Language — SEQUEL, которая, впоследствии, при принятии языка как стандарта, выродилась в SQL, но т.к. англоговорящим людям проще произносить сиквел, нежели эскуэль , то произношение видимо прижилось. Этот интересный факт взят из книги Ицика Бен-Гана Inside Microsoft SQL Server 2008: T-SQL Querying

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

Каким образом организованы данные в файле? Конечно, можно открыть файл БД в каком-нибудь hex редакторе, но структура файла от этого понятнее не станет. Обычно, когда говорят о том, как сервер хранит данные, оперируют понятиями — страница и экстент. Чтобы не пересказывать документацию, я дам только краткое описание и ссылку, на более подробное описание.

Страница — структура данных, размером 8192 байт (8 кб), являющаяся основной единицей хранения. Данные таблиц хранятся именно в страницах. Так же страница — это минимальная порция данных, которая может быть прочитана сервером (это значит, если требуется прочитать только одну строку, сервер все равно считает с диска страницу, на которой располагается строка, целиком). Каждая страница в файле имеет свой номер. Это величина от 0 до ((Max File Size/8 KB)-1). Когда страница читается с диска, ее номер немедленно проверяется (сравнивается номер из заголовка страницы с номером, вычисленным по смещению адреса страницы в файле, если они не совпадают, генерируется ошибка). Почему я заостряю на этом внимание, потому, что важно запомнить, что номер страницы (или pageId) — напрямую соответствует ее смещению в файле. Более подробно тут.

Экстент — объединение 8-и физически непрерывных страниц в файле. Каждая страница принадлежит какому либо экстенту. Если в экстенте все 8 страниц принадлежат одному и тому же объекту (например, таблице), то экстент называется однородным. Если в экстенте хранятся страницы, принадлежащие разным объектам, то смешанным.
Более подробно можно посмотреть тут страницы и экстенты

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

Сразу возникает вопрос, как сервер, может определить, какие страницы принадлежат таблице, а какие нет. Неужели для этого нужно считывать весь файл БД последовательно и проверять каждую страницу на принадлежность к интересующей таблице?

Конечно же нет. Для этой цели, сервер может использовать карту размещения страниц таблицы (строго говоря, такие карты создаются не для таблицы или индекса в целом, а для любых типов так называемых «единиц распределения» allocation units, из которых состоит таблица или индекс, но для простоты и т.к. заметка посвящена куче, я буду просто писать «таблица», подразумевая таблицу-кучу с типом единицы распределения IN_ROW_DATA). Каждая такая карта, физически, представляет собой тоже страницу в файле и значит, ограничена 8192 байтами, на которых могут поместиться сведения о примерно 4ГБ данных. Если таблица превышает указанный порог, то страницы карт организуются в цепочку. Такие страницы карт — имеют специальное название — Index Allocation Map или страницы IAM. Страница IAM содержит заголовок страницы, массив указателей на одиночные страницы таблицы и битовую карту указателей на экстенты таблицы. По этому массиву и указателям, сервер может определить, какие из страниц или экстентов из 4ГБ адресного пространства принадлежат таблице, а какие нет. Сами страницы IAM всегда размещаются в смешанных экстентах.

Когда создается таблица, для нее создается две страницы: 1) IAM и 2) первая страница данных (интересно, что, строго говоря, страницы создаются, но не во время операции create table, а во время первого добавления данных, чуть позже мы это увидим в примере).

Доступ к страницам таблицы через страницы карт IAM называют allocation order scan, т.е. просмотр в порядке размещения. Страницы считываются из файла одна за другой в том порядке, в котором они размещены в файле, последовательным перемещением по диску.

Сервер так же умеет организовывать страницы в двусвязный список. Для этого в таблице должен быть создан кластерный индекс. Связность списка (т.е. что за чем следует) организуется по ключу индекса. В таком случае, каждая страница ссылается на предыдущую страницу и на последующую страницу. Конечно же, т.к. кластерный индекс является полноправным индексом, также создаются и страницы для верхних уровней индекса и вся структура в целом представляет собой B-дерево (для более подробного объяснения рекомендую ознакомиться с отличной статьей Индексы. Теоретические основы.)

* Вообще есть еще одна разновидность, «куча, организованная в связный список». Такую структуру используют некоторые системные таблицы. Некоторые сведения об этом можно найти тут

Когда страницы организованы в двусвязный список, то у сервера появляется возможность определить, какие страницы принадлежат таблице, просто перемещаясь от одной странице в списке к другой по указателям списка. Этот способ называется index order scan, т.е. просмотр в порядке индекса. В таком случае, упрощенно говоря, если первая страница лежит в середине файла, вторая в конце, а третья в начале, то серверу придется сначала переместить головку диска в середину файла, далее в конец файла, и потом в начало файла. Что медленнее, чем в случае allocation order scan, когда чтение идет последовательно в порядке размещения в файле. Поэтому при просмотре в порядке индекса, подобная «размазанность» или фрагментация будет влиять на скорость. Это известный факт и по этому, влияние фрагментации кластерного индекса при index order scan — в данной заметке не рассматривается.

Следует сказать об еще одном типе страниц — PFS страницы. PFS — Page Free Space — эти страницы тоже являются своего рода страницами битовых (точнее байтовых, по байту на страницу) карт, только имеют другое назначение. При помощи них, сервер отслеживает информацию о размещении и свободном месте на страницах, чтобы свободные страницы таблиц можно было бы легко и быстро найти для добавления в них данных. В отличие от IAM — которая имеют отношение к конкретному индексу или таблице, страница PFS — содержит сведения о файле в целом. Я специально упомянул этот тип страниц, т.к. при помощи него мы будем смотреть, как размещаются страницы в БД.

Немного практики в изучении структур данных

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

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

1) Не проводите опыты на рабочем сервере, для экспериментов необходим тестовый сервер. Например, я, использую сервер, установленный на моей локальной машине.
2) Используемая версия сервера Microsoft SQL Server 2008 R2 (RTM) — 10.50.1600.1 (Intel X86) Apr 2 2010 15:53:02 Copyright (c) Microsoft Corporation Express Edition on Windows NT 5.1 <X86> (Build 2600: Service Pack 3)

Используемые инструменты:
dbcc ind — выводит информацию о страницах индекса таблицы или кучи
dbcc page — выводит информацию об определенной странице
dbcc extentinfo — выводит информацию об используемых экстентах
sp_EnumDbPages — написанная мной демо процедура, выводит информацию о типе страниц и статусе их размещения в БД (из первой страницы PFS). Процедура написана только для демонстрации, не должна использоваться на реальных системах, не будет работать на SQL Server 2005 (без предварительной обработки напильником).
dbcc traceon(3604) — специальный флаг трассировки, по-умолчанию некоторые недокументированные команды dbcc направляют вывод результатов своей работы в errorlog, для того чтобы перенаправить их вывод на консоль, требуется включить этот флаг.

Приступим:
1) Создаем БД PagesAllocationDB.
2) Переключаемся в контекст созданой бд.
3) Создаем вспомогательную процедуру sp_EnumDbPages.

upd.18.05.2012:
Для 2012 сервера нет необходимости создавать вспомогательную процедуру. Вместо этого, можно воспользоваться новой недокументированной DMV sys.dm_db_database_page_allocations, в которой содержится похожая информация.
Вот вариант использования:

select * from sys.dm_db_database_page_allocations(db_id(),null,0,null,'DETAILED');

От туда можно узнать об ид страницы, типе страницы, типе размещения, принадлежности к объекту и множество другой полезной информации.

Создаем БД для тестов:

create database PagesAllocationDB;
go

Создаем процедуру для просмотра страниц БД

Spoiler for sp_EnumDbPages

use PagesAllocationDB;
go
if object_id('sp_EnumDbPages') is not null drop proc sp_EnumDbPages
go
create proc sp_EnumDbPages
	@DiscardExecOutput bit = 0
as
declare @db sysname = db_name();

-- dumb protection (as it is demo and not supposed to work on real large db)
if (select count(*) from sys.database_files where type = 0 and name = @db) > 1 begin
	raiserror('Won''t work correct on several files db',11,1);
	return;
end;
if exists (select * from sys.database_files where type = 0 and name = @db and size > 3000) begin
	raiserror('Not expected to work on more than 3000 pages',11,1);
	return;
end;

-- count max of pages in db
declare @lastpagenumber_in_db int;
set @lastpagenumber_in_db = (select top (1) size - 1 from sys.database_files where type = 0 and name = @db);

-- page to parse output of dbcc page
declare @dbcc_sql_res table(ParentObject nvarchar(4000),Object nvarchar(4000),Field nvarchar(4000),Value nvarchar(4000));

-- gathering information about pages allocation from PFS first page (assuming PFS pageId is usually (1:1))
declare @pages_from_pfs table(
	pagenumber int identity(0,1),
	PageID varchar(100),
	AllocationStatus varchar(100),
	AllocationExtentType varchar(100)
)

-- executing query to first PFS page
delete from @dbcc_sql_res
insert into @dbcc_sql_res(ParentObject,Object,Field, Value)
exec sp_executesql N'dbcc page (@db, 1, 1, 3) with tableresults', N'@db sysname', @db;

-- Parsing dbcc first PFS page output, unflattering ranges of pages like (1:1) - (1:3) to (1:1),(1:2),(1:3) pages
with
pfs as
(
	select
		Field,
		Value,
		startpage = convert(int, ltrim(rtrim(replace( replace(substring(Field,1,charindex('-',Field)-1),'(1:',''), ')', '')))),
		endpage = nullif(convert(int, ltrim(rtrim(replace( replace( replace(substring(Field, charindex('-',Field),len(Field)-charindex('-',Field)+1),'(1:',''), ')', ''), '-','')))),0),
		allocation_status = case when patindex('%NOT ALLOCATED%', value) > 0 then 'NOT ALLOCATED' else 'ALLOCATED' end,
		extent_type = case when patindex('%Mixed%', value) > 0 then 'MIXED' else 'UNIFORM' end
	from
		@dbcc_sql_res
	where
		ParentObject = 'PAGE HEADER:' and
		Object like 'PFS: Page Alloc Status%'
),
num as
(
	select top(@lastpagenumber_in_db+1) rn = (row_number() over (order by (select 1)))-1 from master..spt_values v1, master..spt_values v2
)
insert into @pages_from_pfs(PageID, AllocationStatus, AllocationExtentType)
select
	pageid = '(1:' + convert(varchar(10),n.rn) + ')',
	p.allocation_status,
	p.extent_type
from
	pfs p
	join num n on n.rn between p.startpage and isnull(p.endpage,p.startpage)
order by
	n.rn

-- result table for accumulation pages info
-- all of them are varchar(100) as i'dont know for sure what may be returned by dbcc page in those field's,
-- hoping only kind of numbers, but who knows..
declare @pages table(
	pagenumber int,
	PageID varchar(100),
	Type varchar(100),
	IndexID varchar(100),
	ObjectID varchar(100),
	ObjectName varchar(100),
	RecordsOnPage varchar(100),
	FreeBytes varchar(100),
	UsedBytes varchar(100)
) 

declare @page_to_fetch int;

declare cur cursor local read_only forward_only static for
select
	pagenumber
from
	@pages_from_pfs
where
	AllocationStatus = 'ALLOCATED'
open cur
fetch next from cur into @page_to_fetch

while @@fetch_status = 0 begin

	-- clear table variable
	delete from @dbcc_sql_res;
	-- dbcc sql exec statement to fetch info from the specified page
	insert into @dbcc_sql_res(ParentObject,Object,Field, Value)
	exec sp_executesql N'dbcc page (@db, 1, @page_to_fetch, 0) with tableresults', N'@db sysname, @page_to_fetch int', @db, @page_to_fetch;

	-- take from dbcc output only nessessary fields and put it into result table
	with page
	as
	(
		select Field, Value from @dbcc_sql_res
		where Field in ('m_pageId','m_type','Metadata: IndexId','Metadata: ObjectId','m_slotCnt','m_freeCnt','m_freeData')
	)
	insert into @pages(pagenumber, PageID,Type,IndexID,ObjectID,ObjectName,RecordsOnPage,FreeBytes,UsedBytes)
	select
		@page_to_fetch,
		rtrim(ltrim([m_pageId])),
		case
			when [m_type] = '1' then 'Data page'
			when [m_type] = '2' then 'Index page'
			when [m_type] = '3' then 'Text Mixed Page'
			when [m_type] = '4' then 'Text Page'
			when [m_type] = '7' then 'Sort Page'
			when [m_type] = '8' then 'GAM Page'
			when [m_type] = '9' then 'SGAM Page'
			when [m_type] = '10' then 'IAM Page'
			when [m_type] = '11' then 'PFS Page'
			when [m_type] = '13' then 'Boot Page'
			when [m_type] = '14' then 'Server Configuration Page'
			when [m_type] = '15' then 'File Header Page'
			when [m_type] = '16' then 'Differential Changed Map'
			when [m_type] = '17' then 'Bulk Change Map'
			else 'Unknown ('+[m_type]+')'
		end,
		[Metadata: IndexId],[Metadata: ObjectId],object_name([Metadata: ObjectId]),[m_slotCnt],[m_freeCnt],[m_freeData]
	from
		(
			select Field, Value from page
		) s
		pivot
		(
			min(Value) for Field in (
				[m_pageId],[m_type],[Metadata: IndexId],[Metadata: ObjectId],[m_slotCnt],[m_freeCnt],[m_freeData]
			)
		) p

	fetch next from cur into @page_to_fetch;

end
close cur
deallocate cur

if object_id('tempdb..##EnumDbPagesExecResults') is not null drop table ##EnumDbPagesExecResults;

-- return final output
select
	PageNumber = pfs.pagenumber,
	pfs.PageID,
	pfs.AllocationStatus,
	Type = case when pfs.AllocationStatus = 'ALLOCATED' then p.Type end,
	IndexID = case when pfs.AllocationStatus = 'ALLOCATED' then p.IndexID end,
	ObjectID = case when pfs.AllocationStatus = 'ALLOCATED' then p.ObjectID end,
	ObjectName = case when pfs.AllocationStatus = 'ALLOCATED' then p.ObjectName end,
	RecordsOnPage = case when pfs.AllocationStatus = 'ALLOCATED' then p.RecordsOnPage end,
	AllocationExtentType = case when pfs.AllocationStatus = 'ALLOCATED' then pfs.AllocationExtentType end
into ##EnumDbPagesExecResults
from
	@pages_from_pfs pfs
	left join @pages p on p.pagenumber = pfs.pagenumber;

if @DiscardExecOutput = 0 begin
	select * from ##EnumDbPagesExecResults order by pagenumber;
	drop table ##EnumDbPagesExecResults;
end;

Откроем отдельное окно и посмотрим, какие страницы у нас присутствуют в созданной БД.

use PagesAllocationDB;
go
exec sp_EnumDbPages;
go


PageID — ИД страницы (состоит из номера файла, в данном случае всегда 1, и номера страницы, при этом помним, что номер страницы отражает ее расположение в файле).
AllocationStatus — статус страницы, размещена ли она под какой-либо объект, или нет.
Type — тип страницы.
IndexID — ИД индекса.
ObjectID — ИД таблцы.
ObjectName — Имя таблицы.
RecordsOnPage — кол-во записей на странице.
AllocationExtentType — тип экстента в котором размещена страница (mixed — смешанный, uniform — однородный).

Мы видим, что нулевой идет страница File Header Page, за ней PFS Page, GAM Page и т.д.

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

create table MyTable(id int identity, a char(8000) default 'a');
go
dbcc ind (PagesAllocationDB,MyTable,0);
exec sp_EnumDbPages;
go

Если внимательно посмотреть на результаты, какие страницы выделены в БД под таблицу, то ни команда dbcc ind (вообще ничего не вывела), ни информация sp_EnumDbPages (можно поискать записи, у которых в колонке ObjectName было бы MyTable) — не покажет никаких следов созданной таблицы. Я специально просил открыть другое окно, чтобы была возможность сравнить с предыдущими результатами, когда таблицы еще не было в БД.
Таким образом, можно сделать вывод о том, что в момент создания таблицы, создаются только метаданные, а физическая структура данных не создается.

Теперь, добавим одну строчку (которая, напомню, занимает почти целую страницу). И посмотрим, что изменилось.

insert into MyTable default values;
go
dbcc ind (PagesAllocationDB,MyTable,0);
exec sp_EnumDbPages;
go

Во первых, команда dbcc ind — вывела следующее:

Было создано две страницы, у меня они получили номера 90 и 93. Одна из них, страница IAM (PageType=10), другая страница данных (PageType=1). Страница данных ссылается на страницу IAM (IAMPID = 93).

Процедура sp_EnumDbPages:

Мы видим, что там, где были пустые страницы, теперь располагаются страницы нашей таблицы. Обратите внимание, что страницы были выделены в смешанном (MIXED) экстенте.

Теперь добавим еще 7 строк, чтобы всего таблица стала занимать 8 строк. Посмотрим, как они разместились, а так же в каких экстентах.

insert into MyTable default values;
go 7
exec sp_EnumDbPages;
go
dbcc extentinfo(PagesAllocationDB,MyTable,-1)
go

page_id — номер страницы
ext_size — кол-во выделенных (зарезервированных) страниц
pg_alloc — кол-во используемых (размещенных) страниц

Мы видим, что страницы таблицы по-прежнему размещены в смешанных экстентах.
Давайте попробуем добавить еще одну строку.

insert into MyTable default values;
go
exec sp_EnumDbPages;
go
dbcc extentinfo(PagesAllocationDB,MyTable,-1)
go

Теперь мы видим, что был выделен экстент в 8 страниц, их которых, пока заполнена одна.

Если вы добавите еще одну строку, вывод dbcc extentinfo, покажет:

если вы продолжите добавлять записи, вы увидите, как будет увеличиваться значения колонки pg_alloc, пока оно не дойдет до 8, потом выделится следующие 8 страниц и т.д.

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

Попробуем удалить все записи из нашей таблицы. И посмотреть что изменилось.

delete from MyTable;
go
exec sp_EnumDbPages;
go
dbcc ind(PagesAllocationDB,MyTable,0)
go

И как мы увидим — ничего! У таблицы по-прежнему присутствует страница IAM, для нее по-прежнему выделены все структуры данных, и все страницы.

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

Поведение похоже на баг, но ребята из МС задокументировали это поведение в BOL — DELETE, Замечания : Удаление строк из кучи — так что это фича. =) Бороться можно тем, что при удалении использовать подсказку tablock.
Проверим, что останется после удаления таким способом. Воссоздадим ситуацию до удаления, пересоздав таблицу и заново добавив туда строки, после чего удалив их с подсказкой tablock.

drop table MyTable;
go
create table MyTable(id int identity, a char(8000) default 'a');
go
insert MyTable default values;
go 9
exec sp_EnumDbPages;
go
dbcc ind(PagesAllocationDB,MyTable,0)
go
delete from MyTable with(tablock);
go
exec sp_EnumDbPages;
go
dbcc ind(PagesAllocationDB,MyTable,0)
go

Мы видим, что действительно, страницы с данными удалились, но страница IAM осталась.
Теперь, в довершение ко всему, выполним команду truncate.

truncate table MyTable;
go
dbcc ind(PagesAllocationDB,MyTable,0)
go
exec sp_EnumDbPages;
go

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

Смешанные экстенты глобально помечены как «занятые», так что смешанный экстент не сможет быть выделен под какой-либо объект. Однородный экстент может быть освобожден, если освобождаются все его страницы, тогда информация о нем убирается из GAM и из страницы IAM, которой он принадлежал. Важно отметить, что если экстент помечен как выделенный для какой-либо таблицы, это не значит, что все страницы в нем инициализированы. Может ли однородный экстент стать смешанным? Может, если он будет сначала полностью освобожден как однородный и впоследствии будет захвачен как смешенный.

Теперь, можно закрыть все окна, создать новое и удалить БД, т.к. она нам больше не понадобится

use master;
go
drop database PagesAllocationDB;
go

Что такое фрагментация и какая она бывает

Фрагментация, это процесс разделения какой-либо целостной сущности, на множество разрозненных частей — фрагментов. Чем больше фрагментов, тем выше фрагментация.

Говоря о БД, я бы условно разделил виды фрагментации таким образом:

1. Фрагментация файлов БД в файловой системе
2. Фрагментация внутри БД
2.1 Фрагментация внешняя
2.1.1 Логическая фрагментация страниц
2.1.2 Фрагментация экстентов
2.2 Фрагментация внутренняя (плотность страниц)

Теперь вкратце по паре слов по каждой из них.

1. Фрагментация файлов БД в файловой системе
Вспомним, что БД, физически представляет из себя обычные файлы. А как мы знаем, файлы в файловой системе могут подвергаться фрагментации. И многие знают, что в Windows даже есть встроенная утилита «Дефрагментация диска» для борьбы с этим. Этот вид фрагментации в данной заметке нас не интересует.

2. Фрагментация внутри базы данных.
Вспомним, что мы оперируем такими понятиями как таблицы, индексы, страницы, экстенты и т.д. Данные относящиеся к разным объектам не обязательно располагаются в файле упорядоченно и непрерывно. Это и вызывает такого рода фрагментацию. Теперь конкретнее.

2.1.Внешняя фрагментация.
Внешняя фрагментация представляет собой фрагментацию страниц листового уровня индекса или фрагментацию экстентов, если таблица является кучей. Подробнее написано в документации, поэтому чтобы ее не пересказывать, скажу вкратце своими словами. Логическая фрагментация страниц, это когда в двусвязном списке индекса, логически следующая по списку страница, не является следующей в физическом смысле. Фрагментация экстентов — когда физически за экстентом А следующим является экстент В не принадлежащий к тому же объекту что экстент А (т.е. грубо говоря экстенты разных таблиц в файле перемешаны между собой). Проводимый ниже эксперимент касается только этого вида фрагментации.

2.2 Внутренняя фрагментация.
Внутренняя фрагментация, это фрагментация данных на уровне страниц. Здесь не имеется в виду то, что строки идут неупорядочено. Они запросто могут идти неупорядочено, порядок поддерживается на уровне массива указателей на строки. Т.е. при добавлении новой строки, проще изменить порядок указателей в массиве и добавить строку в любое свободное место на странице, чем сдвигать все строки по странице.

Подробнее об этом можно почитать, например, тут в разделе slot array. Это не будет фрагментацией. Здесь под фрагментацией понимается насколько плотно заполнены страницы. Страницы могут быть заполнены не полностью, например, из-за обновлений приводящих к переносу строк или расщеплений страницы в случае индексов и т.д. Понятно, что чем менее плотно лежат данные на странице, тем больше страниц потребуется для представления одной и той же информации. Допустим, на странице помещается 10 записей, всего в таблице 100 записей, если бы страницы были заполнены целиком, то нам потребовалось бы 100/10 = 10 страниц. Теперь допустим, страницы заполнены всего на 50%, тогда нам потребовалось бы 100/(0.5*10) = 20 страниц. Т.е. пришлось бы сделать в два раза больше чтений. Это все довольно прозрачно, поэтому влияние внутренней фрагментации тут не рассматривается.
Кстати, прежде чем бежать и добиваться 100% плотности на страницах, обязательно учтите то, что есть и обратная сторона медали. Если на таблице есть кластерный индекс, то возможен обратный эффект. Если потребуется добавить строку со значением ключа кластерного индекса на страницу, которая заполнена уже на 100% (это не касается последней страницы индекса), то сервер будет вынужден сделать расщепление страницы, что приведет к накладным расходам и еще большей фрагментации. Ведь не зря же придумали такую вещь как fillfactor. Необходимо все это учитывать.

Фрагментация небольших таблиц

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

Проведем маленький эксперимент: попробуем дефрагментировать таблицу с кластерным индексом размером менее 8КБ.
Пусть, например, это будет таблица сотрудников среднего предприятия, около 600 человек. И вот, воспользовавшись sys.dm_db_index_physical_stats, мы увидели, что у нее фрагментация составляет, более 70% в связи с чем принимаем решение срочно ее дефрагментировать всеми различными способами.

create database smallfragemnt;
go
use smallfragemnt;
go
create table dbo.Employee(
	EmplID smallint identity,
	LastName char(20) default('a'),
	FirstName char(20) default('a'),
	MidName char(15) default('a'),
	Phone char(15) default('a')
);
go
create clustered index ixc_Employee on dbo.Employee(EmplID);
go
declare @i int=0;
while @i < 600 begin
	insert into dbo.Employee default values;
	set @i+=1;
end;
select convert(varchar(100),'default') as method, avg_fragmentation_in_percent
into defragresults
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go
dbcc dbreindex(Employee,ixc_Employee);
insert into defragresults
select '1) dbcc dbreindex',avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go 3
dbcc indexdefrag(smallfragemnt,Employee,ixc_Employee) with no_infomsgs;
insert into defragresults
select '2) dbcc indexdefrag',avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go 3
alter index ixc_Employee on dbo.Employee rebuild;
insert into defragresults
select '3) rebuild',avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go 3
alter index ixc_Employee on dbo.Employee reorganize;
insert into defragresults
select '3) reorganize',avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go 3
drop index ixc_Employee on dbo.Employee;
create clustered index ixc_Employee on dbo.Employee(EmplID);
insert into defragresults
select '4) drop create',avg_fragmentation_in_percent
from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
go 3
select * from defragresults;
select page_count from sys.dm_db_index_physical_stats(db_id('smallfragemnt'),object_id('Employee'),null,null,null);
--dbcc extentinfo(smallfragemnt,Employee,-1)
go
use master;
go
drop database smallfragemnt;

Как мы видим, никакие наши ухищрения не помогают, фрагментация все равно присутствует, хотя вроде бы реально работающая таблица, в реальной системе, с полезными данными, аж по 600 сотрудникам. Все дело не в полезности данных и не в количестве строк, а в количестве страниц, если вся информация умещается в 8 страниц — то фрагментация не исчезнет как ни старайся. Она будет оставаться за счет того, что первые 8 страниц все равно выделяются в смешанных экстентах (касается версии сервера > 2000). Что будет если увеличить число сотрудников втрое? Давайте выполним скрипт еще раз, увеличив число записей в три раза «while @i < 600» —> «while @i < 1800»

Как и можно было ожидать, новые страницы выделялись в однородных экстентах, но первые 8 все равно внесли свой вклад во фрагментацию, по этому добиться нулевого значения нам не удалось! Но минутку, неужели нельзя добиться 0%, и фрагментация может снижаться только по мере того как будет снижаться процент страниц размещенных в смешанных экстентах по отношению к остальным? На самом деле нет. Есть очередная магическая цифра, это 24 страницы, как только размер таблицы начинает превышать 24 страницы, начинает использовать другой алгоритм и при перестроении индекса, первые 8 страниц будут выделены в одном экстенте.

Проверьте сами, увеличьте число записей так, чтобы таблица стала занимать более 24 страниц (в данном случае, это примерно 2400 записей), раскомментируйте команду dbcc extentinfo и выполните скрипт еще раз. Вот, что получилось у меня:

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

Что такое read-ahead

read-ahead — это стратегия доступа к данным известная как упреждающее чтение. Сервер пытается предугадать, какие страницы данных потребуются запросу еще до того, как они ему реально понадобятся.

Для выполнения read-ahead сервер использует функцию ReadFileScatter и набор сложных алгоритмов для выявления страниц, которые могут потребоваться в ближайшем будущем. Например, если в запросе используется индекс и искомые строки уже определены, то страницы данных самой таблицы могут быть получены при помощи read-ahead. Важно понимать, что этот вариант использования упреждающего чтения возможный, но далеко не единственный, это лишь один из многих возможных.

Для реализации read-ahead сервер предпринимает следующие шаги:
1. Получить требуемое количество буферов из списка свободных буферов.
2. Для каждой страницы:
2.1. Поиском по хэшу определить не находится ли уже требуемая страница в памяти
2.2. Если находится, то немедленно вернуть захваченный буфер в список свободных.
2.3. Установить верные параметры для вызова функции ReadFileScatter.
2.4. Запросить кратковременную I/O блокировку (latch), чтобы исключить обращения к буферу в момент его заполнения данными страницы из файла.
2.5. Если страница не найдена в хэш таблице, то добавить хэш в таблицу.
3. Вызвать функцию ReadFileScatter для чтения данных.
4. По завершению операции I/O, страница проверяется на валидность, проверяется номер страницы, проверяется не является ли страница неполной (torn)
5. Освобождается краткосрочная блокировка (latch).

Функция ReadFileScatter считывает данные из файла, начиная с позиции, указанной в структуре OVERLAPPED и распределяет данные в несколько буферов пользователя. Вот сигнатура функции:

BOOL ReadFileScatter(
    HANDLE hFile,                         // handle of file to read
    FILE_SEGMENT_ELEMENT aSegmentArray[], // array of buffer segments that receives data
    DWORD nNumberOfBytesToRead,           // number of bytes to read
    LPDWORD lpReserved,                   // reserved must be NULL
    LPOVERLAPPED lpOverlapped             // address of structure for data
   );

Обратите внимание, функция принимает параметр nNumberOfBytesToRead. Количество байт, которые необходимо прочитать. Согласно документации это значение может варьироваться от 8кб до 512 кб (т.е. от одной страницы, до восьми экстентов). Цель эксперимента как раз определить, как фрагментация будет влиять на размер кусков читаемых за одну физическую операцию ввода вывода.

Еще может возникнуть вопрос, что будет если, например, серверу требуется прочитать страницы 1,2,3,4, но страница 3 уже есть в кэше? В таком случае, сервер считает более эффективным не разбивать запрос на два запроса (страницы 1,2 и страница 4) чтобы получить недостающие страницы, а вместо этого считывает все 4 страницы единым блоком, а страница 3 остается в кэше неперезаписанной.

Фрагментация экстентов — эксперимент

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

Инструменты
Мы будем использовать три инструмента.
1. утилиту FileMon от Марка Руссиновича.
2. set statistics io, time
3. механизм сбора данных при помощи Extended Events

Подготовим данные для эксперимента, создадим две базы данных: heapdb и heapdb_fragmentation. В каждой из них по таблице куче — dbo.heap. Для удобства, размер одной записи в таблице сделаем равным примерно одной странице, чтобы не вставлять много записей.
Кроме того, в БД heapdb_fragmentation создадим вторую таблицу — dbo.mixer. Будем использовать ее для создания фрагментации экстентов.
Далее давайте добавим в каждую таблицу, для начала, по 800 страниц, т.е. 800 записей (т.к. в нашей таблице запись = странице), причем добавляя по 8 записей во вторую таблицу, мы будем также добавлять случайное число записей в таблицу dbo.mixer, для моделирования фрагментации.

------------------------------------------------------------
-- создаем БД без фрагментации
create database heapdb;
go
use heapdb;
go
if object_id('dbo.heap') is not null drop table dbo.heap;
create table dbo.heap(a char(8000) default 'a');
go
-- выполняем 100 итераций
declare @i int = 0;
while @i < 100 begin
	-- в каждой итерации добавляем по 8 записей (которые будут занимать 8 страниц, т.е. экстент)
	insert into dbo.heap(a) select top 8 'a'from master..spt_values
	set @i +=1;
end
go
------------------------------------------------------------
-- создаем БД с фрагментацией
create database heapdb_fragmentation;
go
use heapdb_fragmentation;
go
if object_id('dbo.heap') is not null drop table dbo.heap;
if object_id('dbo.mixer') is not null drop table dbo.mixer;
create table dbo.heap(a char(8000) default 'a');
create table dbo.mixer(a char(8000) default 'a');
go
-- выполняем так же 100 итераций
declare @i int = 0;
while @i < 100 begin
	-- в каждой итерации добавлем по 8 записей в исследуемую таблицу и некоторое кол-во записей (кратное 8) в таблицу mixer
	insert into dbo.heap(a) select top 8 'a'from master..spt_values
	insert into dbo.mixer(a) select top ((abs(checksum(newid()))%3+1)*8) 'a'from master..spt_values
	set @i +=1;
end
go
------------------------------------------------------------
-- посмотрим на получившиеся результаты
select * from sys.dm_db_index_physical_stats(db_id('heapdb'),object_id('heap'),null,null,'DETAILED')
select * from sys.dm_db_index_physical_stats(db_id('heapdb_fragmentation'),object_id('heap'),null,null,'DETAILED')

Итак, мы видим, что фрагментация кучи в первой БД менее 2%, во второй более 97%, при этом, таблицы занимают одинаковое число страниц (что исключает влияние внутренней фрагментации страниц).

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

Итак, ок, мы видим, что в одном случае страницы таблицы располагаются непрерывно, в другом, страницы таблиц перемешаны блоками по 8 штук.

Теперь откроем два окна, и скопируем в каждое из них следующий код.

Окно 1:

-- без фрагментации
use heapdb;
go
checkpoint 10;
dbcc dropcleanbuffers;
go
/*
select count(*) from dbo.heap;
*/

Окно 2:

-- фрагментация
use heapdb_fragmentation;
go
checkpoint 10;
dbcc dropcleanbuffers;
go
/*
select count(*) from dbo.heap;
*/

Далее, возьмем замечательную утилиту от Марка Руссиновича FileMon, которая не только показывает все физические обращения к файлу, но так же указывает и кол-во прочитанных байт в случае операции чтения.

Запустим ее и сразу приостановим сбор событий, т.к. сразу посыпется куча лишней информации. Чтобы получать информацию только интересующем нас процессе, необходимо установить фильтр на процесс sqlservr.exe.

После установки фильтра очистим все уже зарегистрированные события и запустим их сбор снова.

Вернемся в Sql Server Management Studio. Теперь выделим в окне 1 последнюю закомментированную строчку. Вы нажмем F5, т.е. выполним ее. То же самое проделаем в окне 2, выделим строчку и выполним ее. Вернемся в FileMon и остановим трассировку.

Вот какие результаты получились у меня.

Обратите внимание, на выделенные красным части. Понять к какой БД относится событие можно по имени файла. Каждая строчка соответствует одному реальному обращению к файлу. Ясно видно, что в случае с нефрагментированной кучей, сервер использовал намного меньше физических обращений к файлу, при этом читая файл кусками вплоть до length 524288 (512 КБ, как раз та самая цифра, что заявлена в документации). Тогда как в случае фрагментации серверу понадобилось гораздо больше физических обращений, но с меньшим размером читаемой порции данных (в среднем по 65536, т .е. по одному экстенту).

Можно сделать еще любопытнее! FileMon умеет сохранять результаты работы в лог, а так же указывает смещение, по которому проводится чтение в файле. Давайте сохраним и подгрузим результаты в БД.

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

Spoiler for Parsing file

create database file_mon_analyze;
go
use file_mon_analyze;
go
create table FileMonResults(num int, DBName varchar(100), PageNumber int, PagesReadCount int);
go
create table #FileMonImport(
	num varchar(100),
	t varchar(100),
	process varchar(100),
	op varchar(100),
	fname varchar(100),
	op_status varchar(100),
	s varchar(100)
);
bulk insert #FileMonImport
from 'c:\FilemonDump.LOG' -- файл лога, в который вы сохранили дамп лога FileMon
with (codepage = 1251, firstrow=0, fieldterminator = '\t', rowterminator = '\n')

insert into FileMonResults(num, DBName, PageNumber, PagesReadCount)
select
	convert(int,num),
	case
		when i.fname like '%heapdb_fragmentation.mdf%' then 'heapdb_fragmentation'
		when i.fname like '%heapdb.mdf%' then 'heapdb'
		else 'not_applicable' end,
	PageNumber = convert(bigint, ltrim(rtrim(substring(s, charindex('Offset:',s)+len('Offset:'), charindex('Length:',s) - (charindex('Offset:',s)+len('Offset:'))))))/8192,
	PagesReadCount = convert(bigint, ltrim(rtrim(substring(s, charindex('Length:',s)+len('Length:'), len(s) - (charindex('Length:',s)+len('Length:'))))))/8192
from
	#FileMonImport i
where
	ltrim(rtrim(op)) = 'READ'
drop table #FileMonImport
go
exec heapdb.dbo.sp_EnumDbPages 1
select
	fmr.DBName,
	fmr.num,
	er.PageID,
	fmr.PagesReadCount,
	er.Type,
	er.ObjectName
from
	##EnumDbPagesExecResults er
	join FileMonResults fmr on fmr.DBName = 'heapdb' and fmr.PageNumber = er.PageNumber
order by
	fmr.num
go
exec heapdb_fragmentation.dbo.sp_EnumDbPages 1
select
	fmr.DBName,
	fmr.num,
	er.PageID,
	fmr.PagesReadCount,
	er.Type,
	er.ObjectName
from
	##EnumDbPagesExecResults er
	join FileMonResults fmr on fmr.DBName = 'heapdb_fragmentation' and fmr.PageNumber = er.PageNumber
order by
	fmr.num
go
use master;
drop database file_mon_analyze;

Выполним и посмотрим, а какие собственно страницы были прочитаны.
Вот, что получилось у меня:

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

Интересно посмотреть, что покажет статистика:

set nocount on;
-- без фрагментации
use heapdb;
go
checkpoint; -- сбросим все страницы на физический носитель
dbcc dropcleanbuffers with no_infomsgs; -- очистим кэш данных
waitfor delay '00:00:01'; -- magic
go
set statistics io on -- включаем статистику
go
print 'heapdb:';
select count(*) from dbo.heap; -- инициируем просмотр
go
set statistics io off
go
-- фрагментация, выполняеем все те же действия
use heapdb_fragmentation;
go
checkpoint;
dbcc dropcleanbuffers with no_infomsgs;
waitfor delay '00:00:01';
go
set statistics io on
go
print 'heapdb_fragmentation:';
select count(*) from dbo.heap;
go
set statistics io off
go

Мои результаты:

heapdb:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 18, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
heapdb_fragmentation:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 64, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

Результаты похожи на то, что мы увидели при помощи FileMon. Число страниц, с том числе и прочитанных упреждающим чтением, совпадает. Но вот число физических обращений к самому файлу увеличилось почти в 4 раза, в случае фрагментированных данных.
Что интересно, пока я писал эту статью, я выполнял этот скрипт много раз и порой получал весьма обескураживающие результаты в поле physical reads. Иногда, например, что-то вроде этого.

Spoiler:
[qoute]heapdb_fragmentation:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 32, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
heapdb:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 1, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

heapdb_fragmentation:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 1, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
heapdb:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 17, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.

heapdb_fragmentation:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 36, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
heapdb:
Table ‘heap’. Scan count 1, logical reads 800, physical reads 6, read-ahead reads 800, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.[/qoute]

Поэтому, мне захотелось, конечно же, проверить, что же все-таки происходит альтернативным способом. И к счастью такой способ отыскался. Это способ сбора информации при помощи Extended Events. Этот механизм в некотором роде позволяет получать результаты напоминающие SQL Trace, однако набор событий, степень детализации и механизм работы — разные. Я не буду описывать механизм extended events, он хорошо подробно и с примерами описан тут. Если вы с ним не знакомы, можете пока просто выполнить скрипт, чтобы увидеть результаты, а про механизм, которым они были получены почитать позже.

Мы будем собирать информацию по следующим событиям: sql_statement_starting, sql_statement_completed, file_read, file_read_completed, physical_page_read, async_io_requested, async_io_completed.

К сожалению, есть один неприятный нюанс, связанный с событием file_read_completed. Для этого события в сиквел сервер 2008, 2008R2 не собирается информация о размере прочитанных из файла данных. Размер прочитанных данных фиксируется только в SQL Server 2012 (aka Denali) А так как все предыдущие испытания проводились на версии 2008R2, то и трассировку мы будем выполнять для нее.

Скрипт делится на три части.
1) В первой, мы создаем трассировку, включаем ее, выполняем запрос, при этом результаты трассировки сохраняются в xml файл. Проделываем эти операции поочередно на двух разных БД.
2) Во второй части, парсим результаты трассировок из разных файлов в таблицу.
3) В третьей анализируем результаты.

1.

use heapdb;
-- создаем трассировку
if exists(select * from sys.server_event_sessions where name='heapdb_trace')
    drop event session heapdb_trace on server;
declare @traceddl nvarchar(4000) = '
create event session heapdb_trace
on server
add event sqlserver.sql_statement_starting ( action (sqlserver.sql_text)),
add event sqlserver.sql_statement_completed ( action (sqlserver.sql_text)),
add event sqlserver.file_read ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlserver.file_read_completed ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlserver.physical_page_read ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlos.async_io_requested ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlos.async_io_completed ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+'))
add target package0.asynchronous_file_target( set filename=''c:\heapdb_trace.xel'',metadatafile=''c:\heapdb_trace.xem'')
with (max_memory = 8mb, event_retention_mode = allow_single_event_loss, track_causality = on, max_dispatch_latency=10seconds)'
exec (@traceddl);
go
--очищаем буферный кэш
checkpoint
go
dbcc dropcleanbuffers
go
--запускаем трассировку, выполняем запрос, ждем пока события запишутся в файл и останавливаем трассировку
alter event session heapdb_trace on server state=start;
go
select count(*) from heap;
go
waitfor delay '00:00:15';
go
alter event session heapdb_trace on server state=stop;
go
--- все то же самое для другой БД, только результаты запишем в другой файл
use heapdb_fragmentation;
go
if exists(select * from sys.server_event_sessions where name='heapdb_fragmentation_trace')
    drop event session heapdb_fragmentation_trace on server;
declare @traceddl nvarchar(4000) = '
create event session heapdb_fragmentation_trace
on server
add event sqlserver.sql_statement_starting ( action (sqlserver.sql_text)),
add event sqlserver.sql_statement_completed ( action (sqlserver.sql_text)),
add event sqlserver.file_read ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlserver.file_read_completed ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlserver.physical_page_read ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlos.async_io_requested ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+')),
add event sqlos.async_io_completed ( where (sqlserver.database_id = '+ cast(db_id() as varchar(3))+'))
add target package0.asynchronous_file_target( set filename=''c:\heapdb_fragmentation_trace.xel'',metadatafile=''c:\heapdb_fragmentation_trace.xem'')
with (max_memory = 8mb, event_retention_mode = allow_single_event_loss, track_causality = on, max_dispatch_latency=10seconds)'
exec (@traceddl);
go
checkpoint
go
dbcc dropcleanbuffers
go
alter event session heapdb_fragmentation_trace on server state=start;
go
select count(*) from heap;
go
waitfor delay '00:00:15';
go
alter event session heapdb_fragmentation_trace on server state=stop;
go
drop event session heapdb_trace on server;
drop event session heapdb_fragmentation_trace on server;

Теперь, загрузим данные из файлов в таблицы

<strong>2.</strong>
use tempdb;
go
if object_id('trace_res') is not null drop table trace_res;
if object_id('trace_res_parsed') is not null drop table trace_res_parsed;
go
create table trace_res (id int identity primary key, event_data xml, dbname varchar(20));
go
insert into trace_res(event_data, dbname)
select cast(event_data as xml), 'heapdb' from sys.fn_xe_file_target_read_file('c:\heapdb_trace*.xel','c:\heapdb_trace*.xem', null, null);
insert into trace_res(event_data, dbname)
select cast(event_data as xml), 'heapdb_fragmentation' from sys.fn_xe_file_target_read_file('c:\heapdb_fragmentation_trace*.xel','c:\heapdb_fragmentation_trace*.xem', null, null);
go
select
    id,
    event_data.value('(event/@name)[1]', 'varchar(50)') as event_name,
    dateadd(hh,
            datediff(hh, getutcdate(), current_timestamp),
            event_data.value('(event/@timestamp)[1]', 'datetime2')) as [timestamp],
    coalesce(event_data.value('(event/data[@name="database_id"]/value)[1]', 'int'),
             event_data.value('(event/action[@name="database_id"]/value)[1]', 'int')) as database_id,
    event_data.value('(event/data[@name="mode"]/text)[1]', 'nvarchar(4000)') as [mode],
    event_data.value('(event/data[@name="file_handle"]/value)[1]', 'nvarchar(4000)') as [file_handle],
    event_data.value('(event/data[@name="offset"]/value)[1]', 'bigint') as [offset],
    event_data.value('(event/data[@name="page_id"]/value)[1]', 'int') as [page_id],
    event_data.value('(event/data[@name="file_id"]/value)[1]', 'int') as [file_id],
    event_data.value('(event/data[@name="file_group_id"]/value)[1]', 'int') as [file_group_id],
    event_data.value('(event/data[@name="size"]/value)[1]', 'bigint') as ,
    event_data.value('(event/data[@name="wait_type"]/text)[1]', 'nvarchar(100)') as [wait_type],
    event_data.value('(event/data[@name="duration"]/value)[1]', 'bigint') as [duration],
    event_data.value('(event/action[@name="sql_text"]/value)[1]', 'nvarchar(4000)') as [sql_text],
    event_data.value('(event/data[@name="cpu"]/value)[1]', 'int') as [cpu],
    event_data.value('(event/data[@name="reads"]/value)[1]', 'bigint') as [reads],
    event_data.value('(event/data[@name="writes"]/value)[1]', 'bigint') as [writes],
    cast(substring(event_data.value('(event/action[@name="attach_activity_id"]/value)[1]', 'varchar(50)'), 1, 36) as uniqueidentifier) as activity_id,
    cast(substring(event_data.value('(event/action[@name="attach_activity_id"]/value)[1]', 'varchar(50)'), 38, 10) as int) as event_sequence
into
	trace_res_parsed
from
	trace_res
order by id;

Посмотрим кол-во обращений к файлу

<strong>3.</strong>
use tempdb;
go
declare @heapdb_activity varchar(50),@heapdb_fragmentation_activity varchar(50)
select @heapdb_activity = activity_id
from
	trace_res_parsed p
where
	event_name = 'sql_statement_starting' and
	sql_text like '%select count(*) from heap%' and
	dbname = 'heapdb';

select @heapdb_fragmentation_activity = activity_id
from
	trace_res_parsed p
where
	event_name = 'sql_statement_starting' and
	sql_text like '%select count(*) from heap%' and
	p.dbname = 'heapdb_fragmentation'

select
	event_name,
	dbname,
	occurences = count(*)
from
	trace_res_parsed
where
	activity_id in (@heapdb_activity, @heapdb_fragmentation_activity) and
	event_name in ('async_io_completed', 'async_io_requested', 'file_read', 'file_read_completed', 'physical_page_read')
group by
	event_name,
	dbname,
	activity_id
order by
	activity_id,
	event_name

Вот что получилось у меня:

Из этого эксперимента, так же явно видно, что в случае фрагментации, число обращений к файлу довольно сильно возросло. Если бы в 2008R2 собиралась статистика по размеру прочитанных данных, то картина получилась бы еще более ясной, и мы бы увидели разницу в размере порций данных, которыми читает сервер. Впрочем, ничего не мешает повторить эксперимент на сервере 2012, и убедиться в этом.

Имхо

В итоге, про фрагментацию экстентов, хотелось бы написать что-то однозначное типа «да это плохо» или «нет, можно об этом не думать». Но! Как обычно и бывает, серебряной пули нет, и все зависит от ситуации.
Если у вас, таблица куча довольно большая, используется в стиле append-only, т.е. только для добавления данных (что снижает риск внутренней фрагментации на страницах), а это довольно частый сценарий, например для записи каких-нибудь логов или показаний датчиков/приборов. Если при этом на таблице есть много некластерных индексов (что опять же высоко вероятно, т.к., как правило, такие таблицы используются для последующего анализа или построения каких-нибудь отчетов). То особого смысла бороться с фрагментацией экстентов нету. Да, действительно может потребоваться меньше физических обращений к диску, но на сколько меньше? в 8 раз при самой высокой фрагментации, когда каждый экстент фрагментирован, и то при условии, что страниц данных еще нет в кэше! И какую цену за это придется платить, перестроение всех некластерных индексов дважды!

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

Полезные ссылки

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

How It Works: SQL Server Page Allocations
Storage Internal–Page Structure(1) (расшифровка числовых кодов типов страниц)
Storage Internal–Page Structure(2)
Geek City: Removing Single Page Allocations (Частично описывается вывод extentinfo)
Using DBCC PAGE
Table and Index Organization
Heap Structures
SQL Server Storage Engine: on-disk structures
Считывание страниц
Whitepapaer: SQL Server 2000 I/O Basics
Read Ahead BOL
ReadFileScatter function
FileMon for Windows v7.04
Знакомство с расширенными событиями SQL Server
An XEvent a Day
Does Index Fragmentation Matter with SSD’s?

 

Один ответ

  1. Felix1001

    Дмитрий, добрый день
    Очень интересный и познавательный блог
    Эксперементировал у себя на тесте, при уровне изоляции RCSI удаление из таблицы даже с tablock не приводит к «освобождению» страниц и они остаются «занятыми» этой таблицей

Добавить комментарий

Ваш e-mail не будет опубликован. Обязательные поля помечены *

Анти-спам: введите результат (цифрами) *