Clustered indexes
The clustered index is the table itself, sorted by its key. Seeks and range scans on the key, why ORDER BY on the key is free, how to choose a clustering key, page splits and fragmentation, and rebuilding with a fill factor.
dbo.Sales, this time with a primary key, so you can start here:
DROP TABLE IF EXISTS dbo.Sales;CREATE TABLE dbo.Sales ( SaleId int NOT NULL CONSTRAINT PK_Sales PRIMARY KEY, SaleDate date NOT NULL, CustomerId int NOT NULL, ProductId int NOT NULL, Quantity int NOT NULL, Amount decimal(10,2) NOT NULL, Channel varchar(10) NOT NULL, Notes char(100) NULL);INSERT INTO dbo.Sales (SaleId, SaleDate, CustomerId, ProductId, Quantity, Amount, Channel)SELECT value, DATEADD(day, value * 37 % 1096, '2022-01-01'), value * 7919 % 5000 + 1, value % 12 + 1, value % 7 + 1, value * 31 % 9000 / 100.0 + 1, CASE value % 10 WHEN 0 THEN 'Phone' WHEN 1 THEN 'Shop' WHEN 2 THEN 'Shop' ELSE 'Web' ENDFROM GENERATE_SERIES(1, 100000); SELECT name, type_desc, is_unique, is_primary_keyFROM sys.indexesWHERE object_id = OBJECT_ID(N'dbo.Sales');name type_desc is_unique is_primary_key -------- --------- --------- -------------- PK_Sales CLUSTERED 1 1
PRIMARY KEY NONCLUSTERED. That is why every BuildMart table is already clustered on its key. Those indexes have names like PK__Products__B40CC6CD…, generated by SQL Server; naming the constraint, as here, gives the index a name you can use in scripts.
Seeks and range scans on the key
SET STATISTICS IO ON;SELECT SaleDate, Amount FROM dbo.Sales WHERE SaleId = 777; SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleId BETWEEN 10001 AND 12000;SET STATISTICS IO OFF;SaleDate Amount
---------- ------
2022-09-11 61.87
Table 'Sales'. Scan count 0, logical reads 3, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
SalesRows Total
--------- --------
2000 92290.00
Table 'Sales'. Scan count 1, logical reads 40, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.ORDER BY on the key is free
SET SHOWPLAN_TEXT ON shows the plan SQL Server would use instead of running the query; the next lessons look at plans in detail.
SET SHOWPLAN_TEXT ON;GOSELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY SaleId;SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY Amount DESC;GOSET SHOWPLAN_TEXT OFF;StmtText
-------------------------------------------------------------
SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY SaleId;
StmtText
---------------------------------------------------------------------------------------------------
|--Top(TOP EXPRESSION:((5)))
|--Clustered Index Scan(OBJECT:([BuildMart].[dbo].[Sales].[PK_Sales]), ORDERED FORWARD)
StmtText
------------------------------------------------------------------
SELECT TOP (5) SaleId, Amount FROM dbo.Sales ORDER BY Amount DESC;
StmtText
----------------------------------------------------------------------------------
|--Sort(TOP 5, ORDER BY:([BuildMart].[dbo].[Sales].[Amount] DESC))
|--Clustered Index Scan(OBJECT:([BuildMart].[dbo].[Sales].[PK_Sales]))ORDERED FORWARD) and stops after five. The second must read all 100,000 rows to find the five largest amounts, through a Top N Sort (the Sort(TOP 5, …) line), which keeps only the five largest it has seen so far.
Other columns still scan
SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleDate = '2023-06-01';SET STATISTICS IO OFF;SalesRows Total
--------- -------
91 4269.76
Table 'Sales'. Scan count 1, logical reads 1794, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.Choosing the clustering key
| Property | Why it matters |
|---|---|
int or bigint IDENTITY column has all four, which is why it is the usual choice. An email address fails on narrow and static; a random GUID fails on narrow and ever-increasing.
DROP TABLE IF EXISTS dbo.SalesByDate;SELECT * INTO dbo.SalesByDate FROM dbo.Sales;CREATE CLUSTERED INDEX CX_SalesByDate ON dbo.SalesByDate (SaleDate); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesByDateWHERE SaleDate >= '2023-06-01' AND SaleDate < '2023-07-01';SET STATISTICS IO OFF; SELECT OBJECT_NAME(object_id) AS TableName, page_count, avg_record_size_in_bytes AS AvgRowBytesFROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')WHERE object_id IN (OBJECT_ID(N'dbo.Sales'), OBJECT_ID(N'dbo.SalesByDate')) AND index_level = 0;SalesRows Total
--------- ---------
2737 125870.80
Table 'SalesByDate'. Scan count 1, logical reads 55, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
TableName page_count AvgRowBytes
----------- ---------- -----------
Sales 1786 142.4
SalesByDate 1887 149.356CREATE UNIQUE CLUSTERED INDEX … (SaleDate, SaleId) avoids the uniquifier and gives the same range seeks.
Page splits
DROP TABLE IF EXISTS dbo.SeqKeys, dbo.RandKeys;CREATE TABLE dbo.SeqKeys (Id int NOT NULL CONSTRAINT PK_SeqKeys PRIMARY KEY, Filler char(400) NOT NULL DEFAULT '');CREATE TABLE dbo.RandKeys (Id int NOT NULL CONSTRAINT PK_RandKeys PRIMARY KEY, Filler char(400) NOT NULL DEFAULT ''); SET NOCOUNT ON;DECLARE @n int = 1;BEGIN TRANSACTION;WHILE @n <= 10000BEGIN INSERT INTO dbo.SeqKeys (Id) VALUES (@n); -- 1, 2, 3, … INSERT INTO dbo.RandKeys (Id) VALUES (@n * 7919 % 10007); -- 7919, 5831, 3743, … SET @n += 1;END;COMMIT; SELECT OBJECT_NAME(object_id) AS TableName, page_count, CAST(avg_page_space_used_in_percent AS decimal(5,1)) AS PctFull, CAST(avg_fragmentation_in_percent AS decimal(5,1)) AS PctFragmentedFROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, 'DETAILED')WHERE object_id IN (OBJECT_ID(N'dbo.SeqKeys'), OBJECT_ID(N'dbo.RandKeys')) AND index_level = 0;TableName page_count PctFull PctFragmented --------- ---------- ------- ------------- SeqKeys 527 96.8 0.2 RandKeys 747 68.3 99.1
Rebuilding an index, and fill factor
ALTER INDEX … REBUILD builds the index again from scratch: pages packed in key order, with no fragmentation. REORGANIZE is a lighter option that shuffles and compacts the leaf pages in place.
ALTER INDEX PK_RandKeys ON dbo.RandKeys REBUILD; SELECT OBJECT_NAME(object_id) AS TableName, page_count, CAST(avg_page_space_used_in_percent AS decimal(5,1)) AS PctFull, CAST(avg_fragmentation_in_percent AS decimal(5,1)) AS PctFragmentedFROM sys.dm_db_index_physical_stats(DB_ID(), OBJECT_ID(N'dbo.RandKeys'), NULL, NULL, 'DETAILED')WHERE index_level = 0;TableName page_count PctFull PctFragmented --------- ---------- ------- ------------- RandKeys 527 96.8 0.0
ALTER INDEX PK_RandKeys ON dbo.RandKeys REBUILD WITH (FILLFACTOR = 80) fills leaf pages to 80%, so the next random inserts find space without splitting. The cost is 25% more pages for every scan to read. Use it only on indexes that really receive inserts in the middle; an IDENTITY key should stay at 100%.
Show a hint
Show the solution
DROP TABLE IF EXISTS dbo.SalesByMonth;SELECT * INTO dbo.SalesByMonth FROM dbo.Sales;CREATE UNIQUE CLUSTERED INDEX CX_SalesByMonth ON dbo.SalesByMonth (SaleDate, SaleId); SET STATISTICS IO ON;SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesWHERE SaleDate >= '2023-03-01' AND SaleDate < '2023-04-01'; SELECT COUNT(*) AS SalesRows, SUM(Amount) AS TotalFROM dbo.SalesByMonthWHERE SaleDate >= '2023-03-01' AND SaleDate < '2023-04-01';SET STATISTICS IO OFF; DROP TABLE dbo.SalesByMonth;SalesRows Total
--------- ---------
2829 130188.01
Table 'Sales'. Scan count 1, logical reads 1794, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.
SalesRows Total
--------- ---------
2829 130188.01
Table 'SalesByMonth'. Scan count 1, logical reads 55, physical reads 0, page server reads 0, read-ahead reads 0, page server read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob page server reads 0, lob read-ahead reads 0, lob page server read-ahead reads 0.Remember
A clustered index’s leaf level is the table: full rows in key order. One per table. A PRIMARY KEY is clustered by default. Name your constraints so the index has a usable name. Seeks and range seeks on the clustering key are cheap, and ORDER BY on the key needs no sort. A good clustering key is narrow, unique, static and ever-increasing: an IDENTITY int or bigint. Inserting into the middle of full pages causes page splits: half-empty pages out of order. REBUILD repacks an index; FILLFACTOR leaves free space, at the cost of more pages to read.