<style> .reveal-up, .reveal-left, .reveal-right, .reveal-stagger > * { opacity: 1 !important; transform: none !important; } </style>
  1. Learn
  2. SQL
  3. Indexes and performance
All lessons in this course

Lesson 47 of 54 18 minAdvanced

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.

This lesson puts the module together into a method you can use on a real system:

  1. Find the queries that cost the most in total, not the one someone complained about last.
  2. Measure one of them: logical reads with STATISTICS IO, and time.
  3. Read the plan: scans, lookups, sorts, estimate gaps, warnings.
  4. Change one thing: an index, a rewrite, a statistics update.
  5. Measure again, and check that nothing else got worse.

Start from a fresh Sales table. This time there is one very large customer: customer 1 is a trade account with 20,000 extra sales.

Create dbo.Sales with a trade account
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;
Output
Who                  SalesRows
-------------------  ---------
Customer 1               20020
Each other customer         20

Step 1: find the expensive queries

SQL Server keeps figures for every cached plan in 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):

Query.sql
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;

Sort by total, not average: a query that does 2,000 reads and runs 50,000 times a day matters more than a 100,000-read report that runs once. The cache forgets plans when the server restarts or memory runs short, so for history use the Query Store: it records plans and run-time figures in the database itself, and is on by default for new databases in SQL Server 2022. In SSMS, open the database’s Query Store folder and choose Top Resource Consuming Queries.

Steps 2 to 5 on one query

The website shows each customer their five most recent purchases. Measure it:

Query.sql
SET STATISTICS IO ON;SELECT TOP (5) SaleDate, AmountFROM dbo.SalesWHERE CustomerId = 3000ORDER BY SaleDate DESC;SET STATISTICS IO OFF;
Output
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.

Every page for five rows. The plan would show a Clustered Index Scan with a WHERE predicate, and a Top N Sort. While compiling it, the optimizer noticed that an index would have helped, and recorded a missing index suggestion:

Query.sql
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');
Output
equality_columns  inequality_columns  included_columns  est_improvement_pct
----------------  ------------------  ----------------  -------------------
[CustomerId]      NULL                NULL                               99

Treat suggestions as hints. This one asks for an index on CustomerId alone. That would find the customer’s 20 rows, but each would still need a key lookup for SaleDate and Amount, and then a sort. With SaleDate as the second key column, sorted descending, the rows are already in the order the query wants, and SQL Server can read the first five and stop:

Query.sql
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;
Output
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.

A few reads instead of a full scan, with no sort. Before keeping the index, ask what it costs: one more structure to maintain on every insert. For a query the website runs on every page view, that is an easy trade.

Parameter sniffing

Stored procedures (and parameterized queries from applications) are compiled once and their plan is reused. The optimizer builds that plan for the parameter values of the first call: it “sniffs” them. That is usually good, since compiling costs time. It goes wrong when values need very different plans. Customer 3000 has 20 sales; customer 1 has over 20,000.

Query.sql
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;
Output
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.

The first call compiled a plan for 20 rows: seek IX_Sales_Customer_Date, then a key lookup per row, because Quantity is not in the index. The second call reused it for over 20,000 rows, so it did over 20,000 lookups. Now clear the procedure’s plan and call it in the other order:

Query.sql
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;
Output
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.

Now the plan is a scan, fine for customer 1 but wasteful for the 4,999 ordinary customers. The same procedure is fast or slow depending on which call happened to compile it. That is why parameter sniffing problems seem to come and go, often after a restart or a statistics update clears the plan.

Interactive

Which call compiles the plan?

First call
Fix
CREATE OR ALTER PROCEDURE dbo.CustomerSummary @CustomerId int AS
SELECT COUNT(*), SUM(Quantity), SUM(Amount)
FROM dbo.Sales
WHERE CustomerId = @CustomerId;
Call 1: customer 3000 (20 rows)
Compiled a new plan
Index Seek + Key Lookup
62 logical reads
Call 2: customer 1 (20,020 rows)
Reused the cached plan
Index Seek + Key Lookup
60,127 logical reads
The plan was compiled for customer 3000 and reused for customer 1: 20,020 key lookups, 60,127 reads instead of a scan.

The fixes, best first:

  • 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.
Query.sql
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;
Output
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 seeLikely causeWhat to try
Scan of a big table for a few rowsNo index on the WHERE columnsAn index with the equality columns first
Scan although an index existsNon-sargable WHERE or implicit conversionLeave the column alone; match data types
Seek plus many Key LookupsIndex does not cover the queryINCLUDE the missing columns
Sort before a TOPNo index in the ORDER BY orderAdd the ORDER BY columns to the index key
Estimated rows far from actualStale or missing statisticsUPDATE STATISTICS; check for new values beyond the histogram
Fast for some values, slow for othersParameter sniffingA covering index, OPTION (RECOMPILE) or OPTIMIZE FOR
Same query fast in SSMS, slow from the appDifferent plans: different SET options or parameter typesCompare the plans; check for nvarchar parameters
Quick check

A procedure is fast all morning and very slow after lunch, with no code change. Nothing else is wrong. What is a likely explanation?

Quick check

Why sort the plan cache by total logical reads rather than average?

Practice

The app runs 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

Channel is varchar, so drop the N prefix (in an app: declare the parameter as varchar(10)). Index (Channel, SaleDate) INCLUDE (Amount); SaleId is the clustering key, so it is in the index already.

Show the solution
Solution.sql
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;
Output
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.
0 of 54 lessons completed