Automating Splitting Up a Large File in a Filegroup into Smaller Files

Problem

Recently, I read an article on extended events where you can watch proportional fill happen on your files.  It reminded of a process I wrote to solve a problem we had with page contention (PFS) with our systems processing upwards of 30K transactions per second.  So I wrote a process that will take any of our files that reached a certain size and create new files so we can start using proportional fill.  If the case that we already have multiple files in that group we just add four new files when that the existing files exceed the specified size so they can be used for future page allocations.

Normally, you would want to account for this in the initial design of the database but we all know that sometimes databases end up bigger than they were designed to be or in my case we have sharded environment so some databases are bigger than others so it depends on which customers get assigned to which databases and what activity they do on the size of the database.

 

Solution

The solution I offer allows you to break your files into any size you want by rebuilding your indexes. You will need some disk space for it to create the new files while it runs the process then it will drop the large file.  This will also take up some space in your transaction log so if you not running your transaction log backups frequently enough you could have a lot of disk space taken up by that so watch out for that. All the code can be downloaded from my github repository here.

Step 1

First, we create a table that will keep with when we are in the process of doing a filegroup management process.  The table will record when the files are added.  As part of this process every index as to be rebuilt so there is the RebuildColumn to tell us if that has been completed and the ResizeComplete column to tells rather or not the resize is completed.  The DropFile field to say whether to drop the file or not, this is used when you are just added files for the first time and have to rebuild the indexes it drops the original file and the DroppedFile filed let’s know it has been completed.

 

CREATE TABLE dbo.FilegroupMgt(
	FilegroupMgtID int IDENTITY(1,1) NOT NULL,
	DatabaseName nvarchar(128) NOT NULL,
	FilegroupName nvarchar(128) NOT NULL,
	AddedDatatimeGMT datetime NOT NULL CONSTRAINT DF_FilegroupMgt_AddedDatatimeGMT  DEFAULT (getutcdate()),
	RebuildComplete bit NOT NULL CONSTRAINT DF_FilegroupMgt_RebuildComplete  DEFAULT ((0)),
	ResizeComplete bit NOT NULL CONSTRAINT DF_FilegroupMgt_ResizeComplete  DEFAULT ((0)),
	DropFile bit NOT NULL CONSTRAINT DF_FilegroupMgt_DropFile  DEFAULT ((0)),
	DroppedFile bit NOT NULL CONSTRAINT DF_FilegroupMgt_DroppedFile  DEFAULT ((0)),
 CONSTRAINT PK_FilegroupMgt PRIMARY KEY CLUSTERED 
(
	FilegroupMgtID ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON))

Step 2

Next, we will create a table that our procedure can use to store our index rebuild statements as if the process is splitting up the filegroup file for the first time.

 

CREATE TABLE dbo.FilegroupMgtRebuilds(
	FilegroupMgtRebuildsID int IDENTITY(1,1) NOT NULL,
	DatabaseName nvarchar(128) NOT NULL,
	FilegroupName nvarchar(128) NOT NULL,
	SQL nvarchar(max) NOT NULL,
 CONSTRAINT PK_FilegroupMgtRebuilds PRIMARY KEY CLUSTERED 
(
	FilegroupMgtRebuildsID ASC
)WITH (PAD_INDEX = OFF, STATISTICS_NORECOMPUTE = OFF, IGNORE_DUP_KEY = OFF, ALLOW_ROW_LOCKS = ON, ALLOW_PAGE_LOCKS = ON))

Step 3

Next, we create a stored procedure that determines if we need to split the file into multiple filegroups.  Ok, this looks like a really long script but it really does only a couple of things.  First, we get max file ID and total size in all the filegroups and store them into a temp table.  Then we get the file details for each file and store them in a temp table.  Then on line 194, we start a cursor to go through the files where they exceed the size set in parameter @FileSizeGBs or is below the size on only as 10% of the size set in parameter @FileSizeGBs free.  Then we determine the number of files to add at the size specified in parameter @FileSizeGbs and insert a record into table FilegroupMGt.  If the filegroup only has one file we will set our parameter to tell we need to build rebuild statements.  We then also check to make sure we have enough space to add the file, if not we send an email out to get space added to the server to the email specified in the @email parameter use the mail profile specified in the @mailprofile parameter.  Finally, if it determines if we only have one file in the filegroup if so it adds the necessary files, it not it just adds to files with that size so that the algorithm can use those files and spread the write load.  If it only had one file in the filegroup it will call stored procedure FilegroupMgt_CreateRebuildStmts to create the rebuild statements from the indexes and store them in table FilegroupMgt_RunRebuildStmts.

 

----------------------------------------------------------------------------------
-- Procedure Name: dbo.FilegroupMgt_AddFiles
--
-- Desc: This procedure checks to see if the last file in a filegroup has reached
--	a certain size.  If so it adds another file to the filegroup if there is enough
--	space on the MP, if not and email will be sent to DBOPs.   
--
-- Parameters: 
--	INPUT
--		@FileSizeGBs - What size to make the files
--		@Debug - Defaults to print information instead of performing actions
--
--	OUTPUT
--
-- Auth: Tracy Boggiano
-- Date: 01/23/2015
--
-- Change History 
-- --------------------------------
-- Date - Auth: 9/29/2015 Tracy Boggiano
-- Description: Fix bug to limit number of files created when 4 or more already
--				exist to 4.
----------------------------------------------------------------------------------
CREATE PROCEDURE [dbo].[FilegroupMgt_AddFiles] 
(
	@FileSizeGBs TINYINT = 50,
        @mailprofile nvarchar(128), 
        @email nvarchar(128),
	@Debug BIT = 0
)
AS
SET NOCOUNT ON;

DECLARE @SQL NVARCHAR(MAX),
	@DatabaseID INT,
	@DataSpaceID INT,
	@DataAvailableGB INT,
	@DataVolumeMountPoint NVARCHAR(512),
	@Body NVARCHAR(MAX),
	@Subject NVARCHAR(255),
	@LogicalName NVARCHAR(128),
	@NumFiles TINYINT,
	@Folder NVARCHAR(260),
	@FileName NVARCHAR(260),
	@IsPercentGrowth BIT,
	@Growth INT,
	@FilegroupName NVARCHAR(128),
	@TotalSizeGBs DECIMAL(6, 1),
	@NumFilesNeeded INT,
	@FileLoop TINYINT,
	@FileNum TINYINT,
	@GBsNeeded INT,
	@CallRebuilds BIT = 0,
	@FileNumChar NVARCHAR(2),
	@FileID INT;

CREATE TABLE #Filegroups
(
	DatabaseName NVARCHAR(128) NOT NULL,
	FilegroupName NVARCHAR(128) NOT NULL,
	DataSpaceID INT NOT NULL,
	FileName NVARCHAR(128) NOT NULL,
	NumFiles INT NOT NULL,
	TotalSizeGB DECIMAL(6, 1) NOT NULL
);

CREATE TABLE #DatabaseFile
(
	DatabaseName NVARCHAR(128) NOT NULL,
	FilegroupName NVARCHAR(128) NOT NULL,
	PhysicalFilename NVARCHAR(260) NOT NULL,
	FileID INT NOT NULL,
	SizeGB DECIMAL(6, 2) NOT NULL,
	FreeSpaceGB DECIMAL(6,2) NOT NULL,
	LogicalName NVARCHAR(128),
	NumFiles INT NOT NULL,
	TotalSizeGB DECIMAL(6, 1) NOT NULL,
	Growth INT NOT NULL,
	IsPercentGrowth BIT NOT NULL,
	DataSpaceID INT NOT NULL
);

CREATE TABLE #SQL
(
	ExecSQL NVARCHAR(MAX) NOT NULL
);

CREATE TABLE #SpaceNeeded
(
	DatabaseName NVARCHAR(128) NOT NULL,
	FilegroupName NVARCHAR(128) NOT NULL,
	TempFile INT NOT NULL DEFAULT(0),
	TotalFGFiles INT NOT NULL DEFAULT (0),
	LogFile INT NOT NULL DEFAULT (0)
);

--Get max file ID and total size in filegroup
SET @SQL = (
	SELECT
		REPLACE('INSERT INTO #Filegroups 
						(
							DatabaseName, 
							FilegroupName, 
							DataSpaceID,

FileName,
NumFiles,
TotalSizeGB
)
SELECT ''' + d.name + ''' AS DatabaseName,
fg.name AS FilegroupName,
MAX(sdf.data_space_id) AS DataSpaceID,
MAX(Sdf.name) AS FileName,
COUNT(*) AS NumFiles,
SUM(sdf.size) / 128.0 / 1024 AS TotalSizeGB
FROM
' + d.name + '.sys.database_files sdf
INNER JOIN ' + d.name + '.sys.filegroups fg
ON sdf.data_space_id = fg.data_space_id
WHERE fg.type = ''FG''
GROUP BY
fg.name;', CHAR(13), '')
FROM dbo.databases AS d
FOR XML PATH('')
);

EXEC(@SQL);

--Get file details
SET @SQL = (
SELECT
REPLACE('USE ' + fg.DatabaseName + ';
INSERT INTO #DatabaseFile
(
DatabaseName,
FilegroupName,
PhysicalFilename,
FileID,
SizeGB,
FreeSpaceGB,
LogicalName,
NumFiles,
TotalSizeGB,
Growth,
IsPercentGrowth,
DataSpaceID
)
SELECT
tfg.DatabaseName,
tfg.FilegroupName,
msdf.physical_name AS PhysicalFilename,
msdf.file_id AS FileID,
msdf.size / 128.0 / 1024 AS SizeGB,
msdf.size / 128.0 / 1024 - CAST(FILEPROPERTY(msdf.name, ''SpaceUsed'') AS INT) / 128.0 / 1024 AS FreeSpaceGB,
msdf.name AS LogicalName,
tfg.NumFiles,
tfg.TotalSizeGB,
sdf.Growth,
sdf.is_percent_growth,
tfg.DataSpaceID
FROM #Filegroups tfg
INNER JOIN ' + fg.DatabaseName + '.sys.filegroups fg
ON tfg.FilegroupName = fg.name
INNER JOIN ' + fg.DatabaseName + '.sys.database_files sdf
ON sdf.Data_Space_ID = tfg.DataSpaceID AND ''' + fg.FilegroupName + ''' = tfg.FilegroupName
INNER JOIN (SELECT
physical_name,
file_id,
size,
name,
Data_Space_ID
FROM ' + fg.DatabaseName + '.sys.database_files sdf ) msdf
ON tfg.DataSpaceID = msdf.Data_Space_ID AND msdf.name = tfg.FileName
WHERE tfg.DatabaseName = ''' + fg.DatabaseName + '''
GROUP BY
tfg.DatabaseName,
tfg.FilegroupName,
msdf.physical_name,
msdf.file_id,
msdf.name,
msdf.size,
tfg.NumFiles,
tfg.TotalSizeGB,
sdf.Growth,
sdf.is_percent_growth,
tfg.DataSpaceID;', CHAR(13), '')
FROM #FileGroups AS fg
FOR XML PATH('')
);

EXEC (@SQL);

--Generate ALTER command and check for free space before creating, if not enough free space send email
--Put in cursor to be able to execute DMF sys.dm_os_volume_stats
DECLARE dfcursor CURSOR
READ_ONLY
FOR
SELECT d.database_id AS DatabaseID ,
df.DataSpaceID ,
df.LogicalName ,
NumFiles ,
REVERSE(RIGHT(REVERSE(PhysicalFilename), ( LEN(PhysicalFilename) - CHARINDEX('\', REVERSE(PhysicalFilename), 1) ) + 1)) AS Folder ,
LEFT(REVERSE(LEFT(REVERSE(PhysicalFilename), CHARINDEX('\', REVERSE(PhysicalFilename)) - 1)), LEN(REVERSE(LEFT(REVERSE(PhysicalFilename), CHARINDEX('\', REVERSE(PhysicalFilename)) - 1))) - 4) AS Filename ,
IsPercentGrowth ,
Growth / 128 Growth ,
df.FilegroupName ,
TotalSizeGB,
df.FileID
FROM #DatabaseFile df
INNER JOIN dbo.databases d ON d.name = df.DatabaseName
INNER JOIN ( SELECT DatabaseName ,
df.FilegroupName ,
MIN(df.FileID) FileID
FROM #DatabaseFile df
INNER JOIN dbo.vAccessibleChangeableUserDBs d ON d.name = df.DatabaseName
GROUP BY DatabaseName ,
df.FilegroupName
) t ON d.NAME = t.DatabaseName
AND df.FilegroupName = t.FilegroupName
AND df.FileID = t.FileID
WHERE ( SizeGB = @FileSizeGBs
AND FreeSpaceGB / SizeGB <= .1
)
OR SizeGB > @FileSizeGBs

OPEN dfcursor

FETCH NEXT FROM dfcursor INTO @DatabaseID, @DataSpaceID, @LogicalName, @NumFiles, @Folder, @FileName, @IsPercentGrowth,
@Growth, @FilegroupName, @TotalSizeGBs, @FileID;
WHILE (@@fetch_status <> -1)
BEGIN
IF (@@fetch_status <> -2)
BEGIN
IF @NumFiles > 1
BEGIN
--Find number of files and double to avoid off balance files
SET @NumFilesNeeded = @NumFiles * 2

IF @NumFiles >= 4
BEGIN
SET @NumFilesNeeded = 4
END
END
ELSE
BEGIN
--Get number of files plus one because we are dropping the intial file
SET @NumFilesNeeded = CEILING(@TotalSizeGBs / @FileSizeGBs)
SET @CallRebuilds = 1
END

SET @GBsNeeded = @NumFilesNeeded * @FileSizeGBs

SELECT
@DataAvailableGB = (available_bytes / 1024 / 1024 / 1024) - (total_bytes / 1024 /1024 / 1024 * 0.10),
@DataVolumeMountPoint = volume_mount_point
FROM sys.dm_os_volume_stats(@DatabaseID, @FileID) AS dovs;

IF @DataAvailableGB >= @GBsNeeded
BEGIN
IF @Debug = 0
BEGIN
INSERT INTO dbo.FilegroupMGt
(
DatabaseName,
FilegroupName,
DropFile
)
VALUES
(
DB_NAME(@DatabaseID),
@FilegroupName,
CASE @NumFiles
WHEN 1 THEN 1
ELSE 0
END
);
END

--Add enough files to distribute data
IF @NumFilesNeeded + @NumFiles <> @NumFiles AND @NumFilesNeeded > 1
BEGIN
SET @FileLoop = @NumFiles + 1
SET @FileLoop = 1

IF @NumFiles > 1
BEGIN
SET @FileNum = @NumFiles + 2
END
ELSE
BEGIN
SET @FileNum = 2
END

WHILE @FileLoop <= @NumFilesNeeded
BEGIN
SET @FileNumChar = CASE LEN(@FileNum)
WHEN 1 THEN '0' + CAST(@FileNum AS NVARCHAR(2))
ELSE CAST(@FileNum AS NVARCHAR(2))
END

SET @SQL = 'ALTER DATABASE [' + DB_NAME(@DatabaseID) + '] ADD FILE (NAME = N'''
+ CASE @NumFiles
WHEN 1 THEN @LogicalName
ELSE LEFT(@LogicalName, LEN(@LogicalName) - 3)
END
+ '_' + @FileNumChar
+ ''', '
+ 'FILENAME = N''' + @Folder
+ CASE @NumFiles
WHEN 1 THEN @FileName
ELSE LEFT(@Filename, LEN(@FileName) - 3)
END
+ '_' + @FileNumChar + '.NDF'', '
+ 'SIZE = ' + CAST(CAST(@FileSizeGBs AS TINYINT) AS NVARCHAR(5)) + ' GB, FILEGROWTH = ' + CAST(@Growth as NVARCHAR(8))
+ CASE @IsPercentGrowth
WHEN 1 THEN '%'
ELSE 'MB'
END
+ ' ) TO FILEGROUP [' + @FilegroupName + ']; '

IF @Debug = 1
BEGIN
PRINT @SQL;
END
ELSE
BEGIN
EXEC (@SQL);
END

SET @FileNum = @FileNum + 1
SET @FileLoop = @FileLoop + 1
END
END
END
ELSE
BEGIN
IF @GBsNeeded > @FileSizeGBs
BEGIN
IF @Debug = 1
BEGIN
PRINT 'Mount Point or Drive ' + @DataVolumeMountPoint + ' on ' + @@SERVERNAME
+ ' for database ' + DB_NAME(@DatabaseID) + ' and Filegroup ' + @FilegroupName
+ ' needs to be expanded to add new ' + CAST(@GBsNeeded as NVARCHAR(4))
+ ' GBs for rebalancing.';
END
ELSE
BEGIN
SET @Body = 'Mount Point or Drive ' + @DataVolumeMountPoint + ' on ' + @@SERVERNAME
+ ' for database ' + DB_NAME(@DatabaseID) + ' and Filegroup ' + @FilegroupName
+ ' needs to be expanded to add new ' + CAST(@GBsNeeded as NVARCHAR(4))
+ ' GBs for rebalancing.';
SET @Subject = 'Mount Point or Drive Expansion Needed on ' + @@SERVERNAME

EXEC msdb.dbo.sp_send_dbmail
@profile_name = @mailprofile,
@recipients = @email,
@body = @Body,
@subject = @Subject;
END
END
END
END
FETCH NEXT FROM dfcursor INTO @DatabaseID, @DataSpaceID, @LogicalName, @NumFiles, @Folder, @FileName, @IsPercentGrowth,
@Growth, @FilegroupName, @TotalSizeGBs, @FileID;
END

CLOSE dfcursor;
DEALLOCATE dfcursor;

DROP TABLE #FileGroups;
DROP TABLE #DatabaseFile;
DROP TABLE #SQL;
DROP TABLE #SpaceNeeded;

IF @CallRebuilds = 1
BEGIN
EXEC dbo.FilegroupMgt_CreateRebuildStmts @Debug = @Debug
END
GO

Step 4

Next,  create procedure FilegroupMgt_CreateRebuildStmts.  This procedure generates the rebuild statements needed to proportionally fill the files you created.

 

----------------------------------------------------------------------------------
-- Procedure Name: dbo.FilegroupMgt_CreateRebuildStmts
--
-- Desc: This procedure creates create index statements to rebalance files.
--
-- Parameters: 
--	INPUT
--			@Debug BIT = 0
--
--	OUTPUT
--
-- Auth: Tracy Boggiano
-- Date: 01/23/2015
--
-- Change History 
-- --------------------------------
-- Date - Auth: 09/14/2015 - Tracy Boggiano
-- Description: Add QUOTENAME around column names
----------------------------------------------------------------------------------
CREATE PROC [dbo].[FilegroupMgt_CreateRebuildStmts]
	@Debug BIT = 0
AS 
SET NOCOUNT ON

DECLARE
	@FileGroupMgtID INT,
	@DatabaseName NVARCHAR(128),
	@FilegroupName NVARCHAR(128),
	@SQL NVARCHAR(MAX),
	@FilegroupMgtRebuildsID INT

DECLARE CursorFilegroup CURSOR
FOR
SELECT 
	fgm.FilegroupMgtID,
	fgm.DatabaseName,
	fgm.FilegroupName
FROM
	dbo.FilegroupMgt fgm
WHERE
	RebuildComplete = 0
	OR ResizeComplete = 0

OPEN CursorFilegroup

FETCH NEXT FROM CursorFilegroup INTO @FileGroupMgtID, @DatabaseName, @FilegroupName

WHILE ( @@fetch_status = 0 )
BEGIN
	SET @SQL = REPLACE(REPLACE(REPLACE(CAST( -- <<--- Added this CAST
		'
		USE {{DatabaseName}};
	 
		DECLARE 
		@SchemaName VARCHAR(100),
		@TableName VARCHAR(256),
		@IndexName VARCHAR(256),
		@ColumnName VARCHAR(100),
		@is_unique VARCHAR(100),
		@IndexTypeDesc VARCHAR(100),
		@FileGroupName VARCHAR(100),
		@IndexOptions VARCHAR(MAX),
		@IndexColumnId INT,
		@IsDescendingKey INT,
		@IsIncludedColumn INT,
		@TSQLScripCreationIndex VARCHAR(MAX),
		@PartitionScheme AS NVARCHAR(128),
		@PartitionColumn AS NVARCHAR(128),
		@IndexType AS TINYINT,
		@ObjectID AS INT,
		@IndexID AS INT,
		@LOB AS TINYINT,
		@SkipXMLSpatial TINYINT,
		@IsFiltered BIT,
		@FilteredDefinition NVARCHAR(MAX),
		@DataCompressionDesc NVARCHAR(4);

	DECLARE CursorIndex CURSOR
	FOR
	SELECT
		SCHEMA_NAME(t.schema_id) SchemaName,
		t.name TableName,
		ix.name IndexName,
		ISNULL(ps.name, "") PartitionName,
		c.name PartitionColumn,
		CASE	WHEN ix.is_unique = 1 THEN "UNIQUE "
				ELSE ""
		END,
		ix.type_desc,
		CASE	WHEN ix.is_padded = 1 THEN "PAD_INDEX = ON, "
				ELSE "PAD_INDEX = OFF, "
		END + CASE	WHEN ix.allow_page_locks = 1 THEN "ALLOW_PAGE_LOCKS = ON, "
					ELSE "ALLOW_PAGE_LOCKS = OFF, "
				END + CASE	WHEN ix.allow_row_locks = 1 THEN "ALLOW_ROW_LOCKS = ON, "
							ELSE "ALLOW_ROW_LOCKS = OFF, "
						END
		+ CASE	WHEN INDEXPROPERTY(t.object_id, ix.name, "IsStatistics") = 1 THEN "STATISTICS_NORECOMPUTE = ON, "
				ELSE "STATISTICS_NORECOMPUTE = OFF, "
			END + CASE	WHEN ix.ignore_dup_key = 1 THEN "IGNORE_DUP_KEY = ON, "
						ELSE "IGNORE_DUP_KEY = OFF, "
					END + "SORT_IN_TEMPDB = OFF, FILLFACTOR = " + CAST(CASE ix.fill_factor
																		WHEN 0 THEN 100
																		ELSE ix.fill_factor
																		END AS VARCHAR(3)) AS IndexOptions,
		fg.name FileGroupName,
		ix.type,
		t.object_id,
		ix.index_id,
		ix.has_filter,
		ix.filter_definition,
		p.data_compression_desc
	FROM
		sys.tables t
	INNER JOIN sys.indexes ix
		ON t.object_id = ix.object_id
	INNER JOIN sys.partitions p
		ON ix.object_id = p.object_id
			AND ix.index_id = p.index_id
	LEFT OUTER JOIN sys.partition_schemes ps
		ON ix.data_space_id = ps.data_space_id
	LEFT OUTER JOIN sys.destination_data_spaces dds
		ON ps.data_space_id = dds.partition_scheme_id
			AND p.partition_number = dds.destination_id
	LEFT OUTER JOIN sys.index_columns ic 
			ON (ic.partition_ordinal > 0) 
			AND (ic.index_id = ix.index_id AND ic.object_id = CAST(t.object_id AS INT))
	LEFT OUTER JOIN sys.columns c 
			ON c.object_id = ic.object_id 
			AND c.column_id = ic.column_id 
	INNER JOIN sys.filegroups fg
		ON COALESCE(dds.data_space_id, ix.data_space_id) = fg.data_space_id
	WHERE
		ix.type > 0
		AND t.is_ms_shipped = 0
		AND fg.name = "{{FilegroupName}}"
		AND ix.is_unique_constraint = 0
	ORDER BY
		SCHEMA_NAME(t.schema_id),
		t.name,
		ix.name

	OPEN CursorIndex

	FETCH NEXT FROM CursorIndex INTO @SchemaName, @TableName, @IndexName, @PartitionScheme, @PartitionColumn,
		@is_unique, @IndexTypeDesc, @IndexOptions, @FileGroupName, @IndexType, @ObjectID, @IndexID, @IsFiltered,
		@FilteredDefinition, @DataCompressionDesc

	WHILE ( @@fetch_status = 0 )
	BEGIN
		DECLARE	@IndexColumns VARCHAR(MAX)
		DECLARE	@IncludedColumns VARCHAR(MAX)
 
		SET @LOB = 0
		SET @IndexColumns = ""
		SET @IncludedColumns = ""
		SET @SkipXMLSpatial = 0
 
		DECLARE CursorIndexColumn CURSOR
		FOR
		SELECT
			col.name,
			ixc.is_descending_key,
			ixc.is_included_column
		FROM
			sys.tables tb
		INNER JOIN sys.indexes ix
			ON tb.object_id = ix.object_id
		INNER JOIN sys.index_columns ixc
			ON ix.object_id = ixc.object_id
				AND ix.index_id = ixc.index_id
		INNER JOIN sys.columns col
			ON ixc.object_id = col.object_id
				AND ixc.column_id = col.column_id
		WHERE
			ix.type > 0
			AND SCHEMA_NAME(tb.schema_id) = @SchemaName
			AND tb.name = @TableName
			AND ix.name = @IndexName
			AND (ixc.partition_ordinal <> ixc.key_ordinal  --Lead column (fixes boo-boo)
			AND (ixc.partition_ordinal <> 1 and ixc.key_ordinal > 0) --PartitionID or other partitioned field 
				OR (ixc.partition_ordinal = 1 and ixc.key_ordinal > 0) 
				OR ixc.is_included_column = 1)  -- But Include if included column
		ORDER BY
			ixc.key_ordinal
 
		OPEN CursorIndexColumn 
		FETCH NEXT FROM CursorIndexColumn INTO @ColumnName, @IsDescendingKey, @IsIncludedColumn
 
		WHILE ( @@fetch_status = 0 )
		BEGIN
			IF @IsIncludedColumn = 0
				SET @IndexColumns = @IndexColumns + QUOTENAME(@ColumnName) + CASE	WHEN @IsDescendingKey = 1 THEN " DESC, "
																		ELSE " ASC, "
																	END
			ELSE
				SET @IncludedColumns = @IncludedColumns + QUOTENAME(@ColumnName) + ", "

			FETCH NEXT FROM CursorIndexColumn INTO @ColumnName, @IsDescendingKey, @IsIncludedColumn
		END

		CLOSE CursorIndexColumn
		DEALLOCATE CursorIndexColumn

		SET @IndexColumns = SUBSTRING(@IndexColumns, 1, LEN(@IndexColumns) - 1)
		SET @IncludedColumns = CASE	WHEN LEN(@IncludedColumns) > 0
									THEN SUBSTRING(@IncludedColumns, 1, LEN(@IncludedColumns) - 1)
									ELSE ""
								END

		/* Determine if the table contains LOBs */
		IF @IndexType = 1
		BEGIN
			SELECT @LOB = COUNT(*)
			FROM {{DatabaseName}}.sys.columns COLUMNS
			INNER JOIN {{DatabaseName}}.sys.types types 
				ON columns.system_type_id = types.user_type_id OR (columns.user_type_id = types.user_type_id AND types.is_assembly_type = 1)  
			WHERE [object_id] = @ObjectID
			AND types.name IN("image", "text", "ntext") ;

			SELECT @SkipXMLSpatial = COUNT(*)
			FROM {{DatabaseName}}.sys.columns COLUMNS
			INNER JOIN {{DatabaseName}}.sys.types types 
				ON columns.system_type_id = types.user_type_id OR (columns.user_type_id = types.user_type_id AND types.is_assembly_type = 1)  
			WHERE [object_id] = @ObjectID
			AND types.name IN("xml", "geometry", "geography") ;
		END
		ELSE IF @indexType = 2
		BEGIN
			SELECT @LOB = COUNT(*) 
			FROM {{DatabaseName}}.sys.index_columns index_columns 
			INNER JOIN {{DatabaseName}}.sys.columns columns 
				ON index_columns.[object_id] = columns.[object_id] AND index_columns.column_id = columns.column_id 
			INNER JOIN {{DatabaseName}}.sys.types types 
				ON columns.system_type_id = types.user_type_id OR (columns.user_type_id = types.user_type_id AND types.is_assembly_type = 1) 
			WHERE index_columns.[object_id] = @ObjectID 
			AND index_columns.index_id = @indexID 
			AND types.name IN("image", "text", "ntext") ;
		END

		SET @TSQLScripCreationIndex = ""

		SET @TSQLScripCreationIndex = "CREATE " + @is_unique + @IndexTypeDesc + " INDEX " + QUOTENAME(@IndexName)
			+ " ON " + QUOTENAME(@SchemaName) + "." + QUOTENAME(@TableName) + "(" + @IndexColumns + ") "
			+ CASE	WHEN LEN(@IncludedColumns) > 0 THEN CHAR(13) + "INCLUDE (" + @IncludedColumns + ") "
					ELSE " "
				END + CHAR(13) 
			+ CASE WHEN @IsFiltered = 1 THEN "WHERE " + @FilteredDefinition + " "
				ELSE " "
				END  
			+ "WITH ( ONLINE = " + CASE @LOB WHEN 0 THEN "ON" ELSE "OFF" END + ", DROP_EXISTING = ON, " 
			+ "DATA_COMPRESSION = " + @DataCompressionDesc + ", "
			+ @IndexOptions + ")" 
			+	CASE	@PartitionScheme
						WHEN "" THEN " ON " + QUOTENAME(@FileGroupName)
						ELSE " ON " + @PartitionScheme + "(" + QUOTENAME(@PartitionColumn) + ")"
				END
			+ ";"  

		IF @indexType IN (1, 2) AND @SkipXMLSpatial = 0
		BEGIN
			INSERT	INTO DBA.dbo.FilegroupMgtRebuilds
					( DatabaseName,
						FilegroupName,
						SQL )
			VALUES
					( DB_NAME(),
						"{{FilegroupName}}",
						@TSQLScripCreationIndex )
		END

		FETCH NEXT FROM CursorIndex INTO @SchemaName, @TableName, @IndexName, @PartitionScheme, @PartitionColumn,
			@is_unique, @IndexTypeDesc, @IndexOptions, @FileGroupName, @IndexType, @ObjectID, @IndexID, @IsFiltered,
			@FilteredDefinition, @DataCompressionDesc
	END

	CLOSE CursorIndex
	DEALLOCATE CursorIndex' AS NVARCHAR(MAX)) 
		,'{{DatabaseName}}', @DatabaseName)
		,'{{FilegroupName}}', @FilegroupName)
		,'"','''') 

	IF @Debug = 0
	BEGIN
		EXEC (@SQL);
	END
	ELSE
	BEGIN
		PRINT @SQL;
	END

	FETCH NEXT FROM CursorFilegroup INTO @FileGroupMgtID, @DatabaseName, @FilegroupName
END
CLOSE CursorFilegroup
DEALLOCATE CursorFilegroup

--Remove duplicate due to partitioning
DECLARE CursorDuplicates CURSOR
READ_ONLY
FOR 
SELECT 
	DatabaseName,
	MIN(FilegroupMgtRebuildsID)
FROM dbo.FilegroupMgtRebuilds
GROUP BY DatabaseName,
	SQL
HAVING COUNT(*)>1

OPEN CursorDuplicates

FETCH NEXT FROM CursorDuplicates INTO @DatabaseName, @FilegroupMgtRebuildsID
WHILE (@@fetch_status <> -1)
BEGIN
	IF (@@fetch_status <> -2)
	BEGIN
		DELETE FROM
			dbo.FilegroupMgtRebuilds
		WHERE
			SQL IN ( SELECT
							SQL
						FROM
							dbo.FilegroupMgtRebuilds
						WHERE
							FilegroupMgtRebuildsID = @FilegroupMgtRebuildsID
							AND DatabaseName = @DatabaseName )
			AND FilegroupMgtRebuildsID <> @FilegroupMgtRebuildsID
	END
	FETCH NEXT FROM CursorDuplicates INTO @DatabaseName, @FilegroupMgtRebuildsID
END

CLOSE CursorDuplicates
DEALLOCATE CursorDuplicates
GO

Step 5

Next, we need to create a procedure that will go through and rebuild the indexes if needed to.

 

----------------------------------------------------------------------------------
-- Procedure Name: dbo.FilegroupMgt_RunRebuildStmts
--
-- Desc: This procedure shrinks runs created index statements for rebalance.
--
-- Parameters: 
--	INPUT
--			@Debug BIT = 0
--
--	OUTPUT
--
-- Auth: Tracy Boggiano
-- Date: 01/23/2015
--
-- Change History 
-- --------------------------------
-- Date - Auth: 
-- Description: 
----------------------------------------------------------------------------------
CREATE PROCEDURE [dbo].[FilegroupMgt_RunRebuildStmts]
	@Debug BIT = 0
AS
SET NOCOUNT ON

DECLARE
	@Size TINYINT = 50,
	@FilegroupMgtRebuildsID INT,
	@DatabaseName NVARCHAR(128),
	@FilegroupName NVARCHAR(128),
	@SQL NVARCHAR(MAX),
	@OldDatabaseName NVARCHAR(128) = '',
	@OldFilegroupName NVARCHAR(128) = '',
	@Count INT=0,
	@ShrinkComplete BIT = 0,
	@DropFile BIT

CREATE TABLE #File (FileName NVARCHAR(128))

DECLARE fg_cursor CURSOR READ_ONLY
FOR
SELECT
	fgr.FilegroupMgtRebuildsID,
	fgr.DatabaseName,
	fgr.FilegroupName,
	fgr.SQL
FROM
	dbo.FilegroupMgtRebuilds AS fgr
INNER JOIN dbo.FilegroupMgt AS fm 
	ON fm.DatabaseName = fgr.DatabaseName AND fm.FilegroupName = fgr.FilegroupName
WHERE RebuildComplete = 0

OPEN fg_cursor

FETCH NEXT FROM fg_cursor INTO @FilegroupMgtRebuildsID, @DatabaseName, @FilegroupName, @SQL
WHILE (@@fetch_status <> -1)
BEGIN
	IF (@@fetch_status <> -2)
	BEGIN
		--Reset if DatabaseName or FilegroupName has changed
		IF @OldDatabaseName <> @DatabaseName OR @OldFilegroupName <> @FilegroupName
		BEGIN
			SET @Count = 0
			SET @ShrinkComplete = 0
			
			--If databasename or filegroup name changed mark rebuild complete
			IF @OldDatabaseName <> '' AND @Debug = 0
			BEGIN
				UPDATE dbo.FilegroupMgt SET RebuildComplete = 1 
				WHERE DatabaseName = @OldDatabaseName 
				AND FilegroupName = @OldFilegroupName 
			END

			--if filgroupname changed shrink file 
			IF @OldFilegroupName <> '' AND @Debug = 0
			BEGIN
				EXEC dbo.FilegroupMgt_ShrinkFile @DatabaseName = @OldDatabaseName,
					@FilegroupName = @OldFilegroupName,
					@Size = @Size,
					@Debug = @Debug,
					@ShrinkComplete = @ShrinkComplete OUTPUT
			END
			ELSE IF @Debug = 0 --if filegroupname is new then shrink for first run
			BEGIN
				EXEC dbo.FilegroupMgt_ShrinkFile @DatabaseName = @DatabaseName,
					@FilegroupName = @FilegroupName,
					@Size = @Size,
					@Debug = @Debug,
					@ShrinkComplete = @ShrinkComplete OUTPUT
			END

			IF @OldDatabaseName <> '' AND @ShrinkComplete = 1 AND @Debug = 0
			BEGIN
				UPDATE dbo.FilegroupMgt SET ResizeComplete = 1 
				WHERE DatabaseName = @OldDatabaseName 
				AND FilegroupName = @OldFilegroupName 
			END

			IF @Debug = 0
			BEGIN
				UPDATE dbo.FilegroupMgt SET RebuildComplete = 1 
				WHERE DatabaseName = @OldDatabaseName 
				AND FilegroupName = @OldFilegroupName 
			END

			SET @OldDatabaseName = @DatabaseName
			SET @OldFilegroupName = @FilegroupName
		END

		--Remove empty space before starting when changing databases or filegroups
		--OR IF processed 10 indexes and the file is not completely shrunk
		IF @Count <> 0 AND @Count % 10 = 0 AND @ShrinkComplete = 0 AND @Debug = 0--Every 10th index if Shrink not complete
		BEGIN
			EXEC dbo.FilegroupMgt_ShrinkFile @DatabaseName = @DatabaseName,
				@FilegroupName = @FilegroupName,
				@Size = @Size,
				@Debug = @Debug,
				@ShrinkComplete = @ShrinkComplete OUTPUT

			IF @ShrinkComplete = 1 
			BEGIN
				UPDATE dbo.FilegroupMgt SET ResizeComplete = 1 
				WHERE DatabaseName = @DatabaseName 
					AND FilegroupName = @FilegroupName  
			END
		END

		SET @SQL = 'USE ' + @DatabaseName + '; ' + @SQL

		IF @Debug = 0
		BEGIN
			EXEC (@SQL)
			
			DELETE FROM dbo.FilegroupMgtRebuilds 
			WHERE FilegroupMgtRebuildsID = @FilegroupMgtRebuildsID
		END
		ELSE
		BEGIN
			PRINT @SQL
		END

		SET @OldDatabaseName = @DatabaseName
		SET @OldFilegroupName = @FilegroupName

		SET @Count = @Count + 1
	END
	FETCH NEXT FROM fg_cursor INTO @FilegroupMgtRebuildsID, @DatabaseName, @FilegroupName, @SQL
END

CLOSE fg_cursor
DEALLOCATE fg_cursor

IF @ShrinkComplete = 0 AND @Debug = 0
BEGIN
	EXEC dbo.FilegroupMgt_ShrinkFile @DatabaseName = @DatabaseName,
		@FilegroupName = @FilegroupName,
		@Size = @Size,
		@Debug = @Debug,
		@ShrinkComplete = @ShrinkComplete OUTPUT
END

IF @Debug = 0
BEGIN
	UPDATE dbo.FilegroupMgt SET RebuildComplete = 1 
	WHERE DatabaseName = @OldDatabaseName 

	IF @ShrinkComplete = 1 
	BEGIN
		UPDATE dbo.FilegroupMgt SET ResizeComplete = 1 
		WHERE DatabaseName = @DatabaseName 
		AND FilegroupName = @FilegroupName 
	END
END

--Final Shrinks if Needed 
DECLARE CursorFinalShrink CURSOR
READ_ONLY
FOR 
SELECT 
	DatabaseName, 
	FilegroupName,
	DropFile 
FROM dbo.FilegroupMgt
WHERE (ResizeComplete = 0
	OR DropFile = 1)
	AND DroppedFile = 0

OPEN CursorFinalShrink

FETCH NEXT FROM CursorFinalShrink INTO @DatabaseName, @FilegroupName, @DropFile
WHILE (@@fetch_status <> -1)
BEGIN
	IF (@@fetch_status <> -2)
	BEGIN
		IF @DropFile = 1
		BEGIN
			TRUNCATE TABLE #File

			SET @SQL = REPLACE(REPLACE(REPLACE(CAST( 
					'USE {{DatabaseName}};
					SELECT TOP 1
						df.name
					FROM
						sys.database_Files df
					INNER JOIN sys.filegroups fg
						ON df.data_space_id = fg.data_space_id
					WHERE fg.name = "{{FilegroupName}}"
					ORDER BY df.name;
				' AS NVARCHAR(MAX)) 
					,'{{DatabaseName}}', @DatabaseName)
					,'{{FilegroupName}}', @FilegroupName)
					,'"','''')

			INSERT INTO #File
			EXEC (@SQL);

			SELECT @SQL = REPLACE(REPLACE(REPLACE(CAST(
				'USE {{DatabaseName}}; DBCC SHRINKFILE ("{{FileName}}", EMPTYFILE);
				ALTER DATABASE {{DatabaseName}} REMOVE FILE {{FileName}};
				' AS NVARCHAR(MAX))
					,'{{DatabaseName}}', @DatabaseName)
					,'{{Filename}}', FILENAME)
					,'"', '''')
			FROM #File 

			IF @Debug = 0
			BEGIN
				EXEC (@SQL);

				UPDATE dbo.FilegroupMgt SET ResizeComplete = 1, 
					DroppedFile = 1,
					RebuildComplete = 1 
				WHERE DatabaseName = @DatabaseName 
				AND FilegroupName = @FilegroupName 
			END
			ELSE
			BEGIN
				PRINT @SQL
			END
		END
		ELSE IF @Debug = 0
		BEGIN
			EXEC dbo.FilegroupMgt_ShrinkFile @DatabaseName = @DatabaseName,
				@FilegroupName = @FilegroupName,
				@Size = @Size,
				@Debug = @Debug,
				@ShrinkComplete = @ShrinkComplete OUTPUT
		
			IF @ShrinkComplete = 1 
			BEGIN
				UPDATE dbo.FilegroupMgt SET ResizeComplete = 1 
				WHERE DatabaseName = @DatabaseName 
				AND FilegroupName = @FilegroupName 
			END
		END
	END
	FETCH NEXT FROM CursorFinalShrink INTO @DatabaseName, @FilegroupName, @DropFile
END

CLOSE CursorFinalShrink
DEALLOCATE CursorFinalShrink
GO

Step 6

To use what you have so far you need to create a job with the following code in it.  Just point it to the database you store your objects in, I have a DBA database I use for all processes.  We schedule this process to just one once a day a 9 AM Monday through Friday.

 

USE DBA
GO
IF NOT EXISTS(SELECT 1 FROM dbo.FilegroupMgtRebuilds) 
	AND NOT EXISTS (SELECT 1 FROM dbo.FilegroupMgt WHERE RebuildComplete = 0 OR ResizeComplete = 0)
BEGIN
	EXEC dbo.FilegroupMgt_AddFiles
END

 

Step 7

Then you schedule another job to actually rebuild your indexes.  We have this schedule to run every hour just in case it gets stopped for some reason.

USE DBA
GO
EXEC dbo.FilegroupMgt_RunRebuildStmts @debug =0

Conclusion

If you need to split up a large filegroup this is a solution we have had in place at work for a couple of years that works and has relieved page allocation contention for us and allowed us to take advantage for proportion page fill.  So set up a test database and give it a try with a small size and see if it something that will work for you.

Related Posts

Leave a Comment

This site uses Akismet to reduce spam. Learn how your comment data is processed.