MSSQL Переиндексация в несколько потоков

Не раз слышал мнения 1сников, что переиндексации вообще не нужна. Так как мы живем сейчас в век ssd и nvme. И не важно какая у вас фрагментация индекса нужно просто делать обновление статистики и все. И как подтверждение кидают абзац от Microsoft

b3530052938cbfd5e585cac5a4e1e9bd.png

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

aac488cd76a852d2f9098a48e0df5481.png

И то что даже на ssd и NMVE последовательная скорость чтения запись всегда выше чем случайная

cd2dac9bbe793be91741556482227b54.png

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

d897b0c227c06d3d73ef88439caa7fc2.png13c92bd56bef8fec9040a524baf15559.png

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

Так что мое мнение переиндексация нужна! Но не нужно каждый день перестраивать все индексы) Благо всевозможный скриптов хватает в сети.

Идея появилась из-за проблемы, что база в 20ТБ не могла за выделение тех окно пройти «полную» переиндексацию. И через неделю-две начинались дикие тормоза и обновление статистики не давало явного ускорения. Пробовал чужие скрипты с паузой и прочие. Но во всех скриптах перестройка индексов идет в один поток (я говорю не про maxdop). Что пока один индекс не перестроиться, другие не начнут.  Так что вот представляю мой костыль)

Скрипт представляю в немного урезанном виде и на 2 потока.

Шаги в скрипте.

  1. Собрать статистику по всем индексам в базе их фрагментации и размеру. Занести данные в служебную базу.

  2. Выполнить задание перестройки по первой половине данных

  3. Выполнить задание перестройки по второй половине данных

  4. Обновить статистку, по все базе.

Шаги 2 и 3 выполняются параллельно.

Зачем, вы спросите, обновлять статистику по всей базе если переиндексация обновляет статистику? А я отвечу. Мы перестраиваем не все таблицы. А только что перестроенные индексы (при условии, что не было еще ни одной вставки) будут пропущены с сообщение, что нечего там обновлять. Это проще чем делать еще один шаг с условием. Что если переиндексация по таблице не проходила, то обнови статистику.

Можно все сделать разными jobs с привязкой старта, разным логом и прочим. Но так как это lite версия скрипта, пример будет создан на maintenance Plan.

fd4e331b954a6b707c4d0e5456a1075a.png

Так как я встречал моветон, имена БД через '–', а не '_', что заставляет городить конструкции с выделением имени БД в несколько кавычек (так как — в скриптах это может быть как действием так и символом).

 Поэтому в скрипте есть 2 разные «переменные» DATA-BASE (Имя базы) и DATA_BASE (Служебная переменная) Во всех файлах необходимо заменить  DATA-BASE имя вашей БД (к примеру SP-UPP), DATA_BASE заменить на имя базы без '-' (SPUPP или SP_UPP)

Мы создаем служебную базу profiler (если ее нет). Собираем статистику по индексам и заполняем базу profiler. Сбор статистики достаточно долгий процесс, который не сильно влияет на скорость. То есть 1 шаг лучше запускать заранее. К примеру тех окно у вас с 21:00 часа первый шаг у вас выполняется за 40 мин и не мешает пользователям, то статистику можно начать собирать в 20:20.

 1.

SET QUOTED_IDENTIFIER ON;

if DB_ID('profiler') IS NULL
BEGIN
 PRINT 'Creating Profiler database'
 CREATE DATABASE Profiler
END

declare @astor_name VARCHAR(255)
declare @astor_id INT
SELECT TOP 1 @astor_name = QUOTENAME(name), @astor_id=database_id FROM sys.databases where name like 'DATA-BASE'
DECLARE @rebuildOptions nvarchar(MAX) = N' WITH (maxdop = 10, ONLINE = ON, SORT_IN_TEMPDB = ON)'
DECLARE @partitionnum bigint;
DECLARE @partitions bigint;
DECLARE @objectid int;
DECLARE @indexid int;
DECLARE @partitioncount bigint;

PRINT 'Woring with ' + @astor_name 

DECLARE @indexTable VARCHAR(255) 
SET @indexTable= @astor_name + '.sys.indexes'

if OBJECT_ID('temporary_indexes_DATA_BASE') IS NOT NULL 
 DROP VIEW temporary_indexes_DATA_BASE;

EXEC('CREATE VIEW temporary_indexes_DATA_BASE AS 
  SELECT 
    idx.object_id as object_id, 
    idx.index_id as index_id,
    sch.name as schema_name,
    obj.name as table_name,
    idx.name as index_name,
    idx.type_desc as type_desc 
   FROM ' + @astor_name + '.sys.indexes as idx
   JOIN '  + @astor_name + '.sys.objects as obj ON obj.object_id = idx.object_id
   JOIN '  + @astor_name + '.sys.schemas as sch ON sch.schema_id = obj.schema_id')

DECLARE @FramentationReportTable VARCHAR(255) 
SET @FramentationReportTable = 'Fragmentation_DATA_BASE_'+REPLACE(convert(varchar, getdate(), 102), '.', '_')

IF OBJECT_ID('tempdb..##fragmentation_DATA_BASE') IS NOT NULL
 DROP TABLE ##fragmentation_DATA_BASE

DECLARE @reportNum INT 
SET @reportNum = 1

WHILE OBJECT_ID('[Profiler].dbo.' + @FramentationReportTable+'_' + CAST(@reportNum AS nvarchar(255))) IS NOT NULL  
 SELECT @reportNum = @reportNum + 1;

SELECT @FramentationReportTable = @FramentationReportTable + '_' + CAST(@reportNum AS nvarchar(255))

DROP SEQUENCE IF EXISTS Sequence;
CREATE SEQUENCE Sequence
    START WITH 1  
    INCREMENT BY 1  
    MINVALUE 1  
    MAXVALUE 2  
    CYCLE  

RAISERROR( N'Analyzing indexes',0,1) WITH NOWAIT
SELECT
 DB_NAME(stats.database_id) as db_name,
 idx.schema_name as schema_name,
 idx.table_name AS table_name,
    idx.index_name AS index_name,
 idx.type_desc as index_type,
    stats.partition_number AS partition_num,
    stats.avg_fragmentation_in_percent AS fragmentation,
 stats.avg_page_space_used_in_percent as page_fullness,
 stats.avg_record_size_in_bytes as record_size,
 stats.record_count as rows_count,
 stats.page_count as page_count
 --- ,next value for Sequence over (order by [record_count] desc) as num
INTO ##fragmentation_DATA_BASE
FROM sys.dm_db_index_physical_stats (@astor_id, NULL, NULL , NULL, 'SAMPLED') as stats
JOIN temporary_indexes_DATA_BASE idx ON idx.object_id = stats.object_id and idx.index_id = stats.index_id
DROP VIEW temporary_indexes_DATA_BASE

EXEC ('SELECT [db_name]
      ,[schema_name]
      ,[table_name]
      ,[index_name]
      ,[index_type]
      ,[partition_num]
      ,[fragmentation]
      ,[page_fullness]
      ,[record_size]
      ,[rows_count]
      ,[page_count]
   ,next value for Sequence over (order by ROUND ([fragmentation],0 ) desc, [rows_count] desc) as num INTO profiler.dbo.'+ @FramentationReportTable +' FROM ##fragmentation_DATA_BASE')

2 и 3 шаги почти идентичные кроме условия условий выбора и названия курсора.

2.    

DECLARE @FramentationReportTable_1 VARCHAR(255) 
SET @FramentationReportTable_1 = (SELECT top 1 table_name FROM Profiler.INFORMATION_SCHEMA.TABLES  WHERE table_Name LIKE 'Fragmentation_DATA_BASE_'+REPLACE(convert(varchar, getdate(), 102), '.', '_') + '%')
print @FramentationReportTable_1

exec ('
DECLARE bad_indexes_1 CURSOR FOR 
 
 select
 frag.db_name,
 frag.schema_name,
 frag.table_name,
 frag.index_name,
 frag.partition_num,
 case
  when frag.record_size*16 <= 403 then 95
  when frag.record_size*16 <= 806 then 90
  when frag.record_size*16 <= 1209 then 85
  else 80
 end as suggested_fillfactor
 
 
 from Profiler.dbo.'+ @FramentationReportTable_1 +' frag
 where frag.page_count > 24 and frag.fragmentation >= 5  and frag.num = 1  and frag.index_type <>''HEAP''
 order by ROUND ([fragmentation],0 ) desc, [rows_count] desc')


-- Open the cursor.
OPEN bad_indexes_1

DECLARE @db_name nvarchar(130);
DECLARE @schema_name nvarchar(130);
DECLARE @table_name nvarchar(130);
DECLARE @index_name nvarchar(130);
DECLARE @fragmentation bigint;
DECLARE @suggested_fillfactor int;
DECLARE @partition_num bigint;
DECLARE @partitionOption nvarchar(130);
DECLARE @fillfactorOption nvarchar(130);
DECLARE @object_name nvarchar(1000);
DECLARE @command nvarchar(1000);
DECLARE @time nvarchar(130)

WHILE (1=1) 
 BEGIN
 FETCH NEXT
           FROM bad_indexes_1
           INTO @db_name, @schema_name, @table_name, @index_name, @partition_num, @suggested_fillfactor;

 IF @@FETCH_STATUS < 0 BREAK
 
 

   IF @partition_num > 1
   begin
    SET @partitionOption = N' PARTITION=' + CAST(@partition_num AS nvarchar(10));
    set @fillfactorOption = N''
   end
  else 
   begin
    SET @partitionOption = N''
    set @fillfactorOption = N' FILLFACTOR=' + CAST(@suggested_fillfactor as nvarchar(10)) + N', '
   end

 SET @object_name = QUOTENAME(@db_name) + N'.' + QUOTENAME(@schema_name) + N'.' + QUOTENAME(@table_name)
 BEGIN TRY

    SET @command = N'ALTER INDEX ' + QUOTENAME(@index_name) + N' ON ' + @object_name + ' REBUILD ' + @partitionOption +' WITH(' + @fillfactorOption +  N'maxdop = 0, ONLINE = ON, SORT_IN_TEMPDB = ON, MAX_DURATION = 10 minutes, ABORT_AFTER_WAIT = BLOCKERS)'
  set @time = CURRENT_TIMESTAMP;
  print @time
  RAISERROR(@command, 0, 1)
  EXEC(@command)
  set @time = CURRENT_TIMESTAMP;
  RAISERROR(N'DONE', 0, 1) 
 END TRY
 BEGIN CATCH
     SET @command = N'ALTER INDEX ' + QUOTENAME(@index_name) + N' ON ' + @object_name + ' REBUILD ' + @partitionOption +' WITH(' + @fillfactorOption +  N'maxdop = 0, SORT_IN_TEMPDB = ON)'
 set @time = CURRENT_TIMESTAMP;
  print @time
  RAISERROR(@command, 0, 1)
  EXEC(@command)
  set @time = CURRENT_TIMESTAMP;
  RAISERROR(N'DONE', 0, 1) 
  print @time
 END CATCH
END

close bad_indexes_1
deallocate bad_indexes_1

3.    

DECLARE @FramentationReportTable_2 VARCHAR(255) 
SET @FramentationReportTable_2 = (SELECT top 1 table_name FROM Profiler.INFORMATION_SCHEMA.TABLES  WHERE table_Name LIKE 'Fragmentation_DATA_BASE_'+REPLACE(convert(varchar, getdate(), 102), '.', '_') + '%')
print @FramentationReportTable_2

exec ('
DECLARE bad_indexes_2 CURSOR FOR 
 
 select
 frag.db_name,
 frag.schema_name,
 frag.table_name,
 frag.index_name,
 frag.partition_num,
 case
  when frag.record_size*16 <= 403 then 95
  when frag.record_size*16 <= 806 then 90
  when frag.record_size*16 <= 1209 then 85
  else 80
 end as suggested_fillfactor
 
 
 from Profiler.dbo.'+ @FramentationReportTable_2 +' frag
 where frag.page_count > 24 and frag.fragmentation >= 5  and frag.num = 2  and frag.index_type <>''HEAP''
 order by ROUND ([fragmentation],0 ) desc, [rows_count] desc')


-- Open the cursor.
OPEN bad_indexes_2

DECLARE @db_name nvarchar(130);
DECLARE @schema_name nvarchar(130);
DECLARE @table_name nvarchar(130);
DECLARE @index_name nvarchar(130);
DECLARE @fragmentation bigint;
DECLARE @suggested_fillfactor int;
DECLARE @partition_num bigint;
DECLARE @partitionOption nvarchar(130);
DECLARE @fillfactorOption nvarchar(130);
DECLARE @object_name nvarchar(1000);
DECLARE @command nvarchar(1000);
DECLARE @time nvarchar(130)

WHILE (1=1) 
 BEGIN
 FETCH NEXT
           FROM bad_indexes_2
           INTO @db_name, @schema_name, @table_name, @index_name, @partition_num, @suggested_fillfactor;

 IF @@FETCH_STATUS < 0 BREAK
 
 

   IF @partition_num > 1
   begin
    SET @partitionOption = N' PARTITION=' + CAST(@partition_num AS nvarchar(10));
    set @fillfactorOption = N''
   end
  else 
   begin
    SET @partitionOption = N''
    set @fillfactorOption = N' FILLFACTOR=' + CAST(@suggested_fillfactor as nvarchar(10)) + N', '
   end

 SET @object_name = QUOTENAME(@db_name) + N'.' + QUOTENAME(@schema_name) + N'.' + QUOTENAME(@table_name)
 BEGIN TRY
 
    SET @command = N'ALTER INDEX ' + QUOTENAME(@index_name) + N' ON ' + @object_name + ' REBUILD ' + @partitionOption +' WITH(' + @fillfactorOption +  N'maxdop = 0, ONLINE = ON, SORT_IN_TEMPDB = ON, MAX_DURATION = 10 minutes, ABORT_AFTER_WAIT = BLOCKERS)'
  set @time = CURRENT_TIMESTAMP;
  print @time
  RAISERROR(@command, 0, 1)
  EXEC(@command)
  set @time = CURRENT_TIMESTAMP;
  RAISERROR(N'DONE', 0, 1) 
 END TRY
 BEGIN CATCH
   SET @command = N'ALTER INDEX ' + QUOTENAME(@index_name) + N' ON ' + @object_name + ' REBUILD ' + @partitionOption +' WITH(' + @fillfactorOption +  N'maxdop = 0, SORT_IN_TEMPDB = ON)'
 set @time = CURRENT_TIMESTAMP;
  print @time
  RAISERROR(@command, 0, 1)
  EXEC(@command)
  set @time = CURRENT_TIMESTAMP;
  RAISERROR(N'DONE', 0, 1) 
  print @time
 END CATCH
END

close bad_indexes_2
deallocate bad_indexes_2

В скриптах 2 и 3. Есть пара моментов, которые я хочу прояснить.

  • есть проверка на партиции, и в зависимости от этого будет меняться запрос.

  • есть изменение fillfactor в зависимости от record_size

  • идет 2 условия. Пробует перестроить индекс online и с ожидание если не удается, то перестраивает индекс просто.

  • RAISERROR со временем было сделано для того узнать время начала переиндексации по таблице и конца (Здесь осталось как отладочная команда). Эту инфу можно передать в другую базу для истории или диагностики.

4. Обновление статистики.

USE [DATA-BASE] 
        GO  
        EXEC sp_updatestats;

Если вдруг вы хотите добавить потоков, то необходимо будет в скрипте

  • Изменить параметр в SEQUENCE

  • Установить MAXVALUE на значение желаемых потоков

  • И создать шаг наподобие 2 или 3

Но тут нужно хорошо думать. Так как переиндексация достаточно сильно нагружаемый процесс. И можно поставить сервер колом если делать несколько alter index сразу.

После пары запусков с разными настройками удалось добиться 30% выигрыша по времени. И задание успевает выполниться за тех окно.

© Habrahabr.ru