Finding and fixing slow queries
A method for tuning: find the queries that cost the most, measure, read the plan, change one thing and measure again. Missing-index suggestions, fixing a sort with the right index, and parameter sniffing with its fixes.
Find the queries that cost the most in total, not the one someone complained about last. Measure one of them: logical reads with STATISTICS IO, and time. Read the plan: scans, lookups, sorts, estimate gaps, warnings. Change one thing: an index, a rewrite, a statistics update. Measure again, and check that nothing else got worse.
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'), CASE WHEN value > 100000 THEN 1 ELSE value * 7919 % 5000 + 1 END, 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, 120000); SELECT CASE WHEN CustomerId = 1 THEN 'Customer 1' ELSE 'Each other customer' END AS Who, MAX(n) AS SalesRowsFROM (SELECT CustomerId, COUNT(*) AS n FROM dbo.Sales GROUP BY CustomerId) AS cGROUP BY CASE WHEN CustomerId = 1 THEN 'Customer 1' ELSE 'Each other customer' END;Who SalesRows ------------------- --------- Customer 1 20020 Each other customer 20
Step 1: find the expensive queries
sys.dm_exec_query_stats. This lists the ten statements that have done the most logical reads in total since their plans were cached (your results depend on what has run):
SELECT TOP (10) qs.execution_count, qs.total_logical_reads, qs.total_logical_reads / qs.execution_count AS avg_reads, qs.total_elapsed_time / qs.execution_count / 1000 AS avg_ms, SUBSTRING(t.text, qs.statement_start_offset / 2 + 1, (CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(t.text) ELSE qs.statement_end_offset END - qs.statement_start_offset) / 2 + 1) AS statement_textFROM sys.dm_exec_query_stats AS qsCROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS tORDER BY qs.total_logical_reads DESC;Steps 2 to 5 on one query
SET STATISTICS IO ON;SELECT TOP (5) SaleDate, AmountFROM dbo.SalesWHERE CustomerId = 3000ORDER BY SaleDate DESC;SET STATISTICS IO OFF;SaleDate Amount ---------- ------ 2024-12-21 10.51 2024-08-15 80.51 2024-07-22 90.51 2024-06-28 10.51 2024-06-04 20.51 Table 'Sales'. Scan count 1, logical reads 2152, 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.
SELECT d.equality_columns, d.inequality_columns, d.included_columns, CAST(s.avg_user_impact AS int) AS est_improvement_pctFROM sys.dm_db_missing_index_details AS dJOIN sys.dm_db_missing_index_groups AS g ON g.index_handle = d.index_handleJOIN sys.dm_db_missing_index_group_stats AS s ON s.group_handle = g.index_group_handleWHERE d.database_id = DB_ID() AND d.object_id = OBJECT_ID(N'dbo.Sales');equality_columns inequality_columns included_columns est_improvement_pct ---------------- ------------------ ---------------- ------------------- [CustomerId] NULL NULL 99
CREATE INDEX IX_Sales_Customer_Date ON dbo.Sales (CustomerId, SaleDate DESC) INCLUDE (Amount); SET STATISTICS IO ON;SELECT TOP (5) SaleDate, AmountFROM dbo.SalesWHERE CustomerId = 3000ORDER BY SaleDate DESC;SET STATISTICS IO OFF;SaleDate Amount ---------- ------ 2024-12-21 10.51 2024-08-15 80.51 2024-07-22 90.51 2024-06-28 10.51 2024-06-04 20.51 Table 'Sales'. Scan count 1, logical reads 2, 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.
Parameter sniffing
CREATE OR ALTER PROCEDURE dbo.CustomerSummary @CustomerId intASSELECT COUNT(*) AS SalesRows, SUM(Quantity) AS Items, SUM(Amount) AS SpentFROM dbo.SalesWHERE CustomerId = @CustomerId;GOSET STATISTICS IO ON;EXEC dbo.CustomerSummary @CustomerId = 3000; -- compiled for 20 rowsEXEC dbo.CustomerSummary @CustomerId = 1; -- reuses that planSET STATISTICS IO OFF;SalesRows Items Spent
--------- ----- -------
20 83 1000.20
Table 'Sales'. Scan count 1, logical reads 62, physical reads 3, 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 Items Spent
--------- ----- ---------
20020 80086 921000.00
Table 'Sales'. Scan count 1, logical reads 60127, physical reads 5, page server reads 0, read-ahead reads 22, 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.EXEC sp_recompile N'dbo.CustomerSummary';GOSET STATISTICS IO ON;EXEC dbo.CustomerSummary @CustomerId = 1; -- compiled for 20,000 rowsEXEC dbo.CustomerSummary @CustomerId = 3000; -- reuses that planSET STATISTICS IO OFF;Object 'dbo.CustomerSummary' was successfully marked for recompilation.
SalesRows Items Spent
--------- ----- ---------
20020 80086 921000.00
Table 'Sales'. Scan count 1, logical reads 2152, 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 Items Spent
--------- ----- -------
20 83 1000.20
Table 'Sales'. Scan count 1, logical reads 2152, 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.Make one plan good for every value. Here, adding Quantity to the index’s INCLUDE list removes the lookups, and the seek is then right for both customers. OPTION (RECOMPILE) on the statement: compile a fresh plan on every call, for its own values. It costs compile time on every run, so use it for queries that run rarely or vary hugely. OPTIMIZE FOR UNKNOWN, or OPTIMIZE FOR (@CustomerId = 3000): plan for an average or a chosen value instead of the first one.SQL Server 2022 also has Parameter Sensitive Plan optimization, which can keep several plans for one query when the statistics show very uneven data. It needs compatibility level 160 and only kicks in for strongly skewed columns.
CREATE OR ALTER PROCEDURE dbo.CustomerSummary @CustomerId intASSELECT COUNT(*) AS SalesRows, SUM(Quantity) AS Items, SUM(Amount) AS SpentFROM dbo.SalesWHERE CustomerId = @CustomerIdOPTION (RECOMPILE);GOSET STATISTICS IO ON;EXEC dbo.CustomerSummary @CustomerId = 3000;EXEC dbo.CustomerSummary @CustomerId = 1;SET STATISTICS IO OFF;SalesRows Items Spent
--------- ----- -------
20 83 1000.20
Table 'Sales'. Scan count 1, logical reads 62, 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 Items Spent
--------- ----- ---------
20020 80086 921000.00
Table 'Sales'. Scan count 1, logical reads 2152, 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.A tuning checklist
| What you see | Likely cause | What to try |
|---|---|---|
SELECT SaleId, Amount FROM dbo.Sales WHERE Channel = N'Phone' AND SaleDate = '2024-03-01'; (it sends the channel as nvarchar). Fix the query so that it can seek, create an index for it, and measure the reads with STATISTICS IO.
Show a hint
Show the solution
CREATE INDEX IX_Sales_Channel_Date ON dbo.Sales (Channel, SaleDate) INCLUDE (Amount); SET STATISTICS IO ON;SELECT SaleId, AmountFROM dbo.SalesWHERE Channel = 'Phone' AND SaleDate = '2024-03-01';SET STATISTICS IO OFF;SaleId Amount ------ ------ 910 13.10 6390 1.90 11870 80.70 17350 69.50 22830 58.30 28310 47.10 33790 35.90 39270 24.70 44750 13.50 50230 2.30 55710 81.10 61190 69.90 66670 58.70 72150 47.50 77630 36.30 83110 25.10 88590 13.90 94070 2.70 99550 81.50 105030 70.30 110510 59.10 115990 47.90 Table 'Sales'. Scan count 1, 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.
Remember
Tune by method: find, measure, read the plan, change one thing, measure again. Find costly queries by total work in sys.dm_exec_query_stats or the Query Store. Missing-index suggestions are hints: check the key order and what the query sorts by. An index in the ORDER BY order lets TOP read a few rows and stop, with no sort. Parameter sniffing: a cached plan fits the values that compiled it, and may be bad for others. Fix sniffing with an index that suits every value, OPTION (RECOMPILE) or OPTIMIZE FOR.