CREATE NONCLUSTERED INDEX IX_Orders_KeyLookup_Covering ON Sales.Orders (CustomerID, OrderDate) INCLUDE (OrderID, ExpectedDeliveryDate, SalespersonPersonID) WITH (ONLINE = ON);DROP INDEX IX_Orders_KeyLookup_Covering ON Sales.Orders;rebuild/reorg should apply only to indexes with page_count >=1000 and fragmentation >=30%
The analysis text above is shown unchanged; review the flagged items before acting.
The existing index IX_Demo_KeyLookup_Orders_CustomerID_OrderDate lacks INCLUDE columns for the SELECT list; the optimizer must perform a Key Lookup to PK_Sales_Orders for each row.
Key Lookups multiply I/O and CPU for each qualifying row; removing them is the primary structural fix for this procedure.
Add the key-lookup columns as INCLUDE columns to the existing seek index so the engine can satisfy the query without a bookmark lookup. Review the full column list from the execution plan before altering the index.
Eliminates the Key Lookup operator entirely — the index satisfies both the seek predicate and the output projection in a single operator. Logical reads typically drop by a factor proportional to the qualifying row count (one lookup per row → zero lookups). Duration improvement is most pronounced when the query returns 100+ rows; for narrow result sets (< 50 rows) the win is modest.
```sql
-- Covering index eliminates the Key Lookup operator on Sales.Orders
CREATE NONCLUSTERED INDEX IX_Orders_KeyLookup_Covering
ON Sales.Orders (CustomerID, OrderDate)
INCLUDE (
OrderID,
ExpectedDeliveryDate,
SalespersonPersonID
) WITH (ONLINE = ON);
```
-- Covering index eliminates the Key Lookup operator
CREATE NONCLUSTERED INDEX IX_Orders_KeyLookup_Covering
ON Sales.Orders (CustomerID, OrderDate)
INCLUDE (
-- SELECT * detected — list every non-key output column
) WITH (ONLINE = ON);
SET STATISTICS IO, TIME ON; EXEC Demo.usp_Test_01_ParameterSniffing; SET STATISTICS IO, TIME OFF;
DROP INDEX IX_Orders_KeyLookup_Covering ON Sales.Orders; -- Rollback is instant; no data loss possible. The dropped index returns the SP to Key Lookup behaviour.
Without a captured baseline nobody can tell whether the applied change helped, hurt, or did nothing.
- Capture baseline Query Store metrics for current workloads (average duration, logical reads, plan count). 2. Deploy covering index on Sales.Orders in a test environment with ONLINE=ON. 3. Run representative parameter sets including @CustomerID=1195, @StartDate='2015-08-30', @EndDate='2016-09-23' and broad ranges. 4. Compare actual plan: confirm Key Lookup operator removed and Sort/Spill risk reduced. 5. Test with OPTION (RECOMPILE) on high-variance executions and monitor CPU overhead vs read savings.
- Validate index usage after 14 days; if unused, consider drop.
Produces before/after evidence for the corrective actions above; this step itself changes no code or database object.
DECLARE @object_name sysname = 'Demo.usp_Test_01_ParameterSniffing';
DECLARE @window_start datetimeoffset = DATEADD(day, -14, SYSDATETIMEOFFSET());
DECLARE @window_end datetimeoffset = SYSDATETIMEOFFSET();
DECLARE @change_time datetimeoffset = NULL; -- Set this after applying the tested change.
SELECT
CASE
WHEN @change_time IS NULL THEN 'current_window'
WHEN qsrsi.end_time < @change_time THEN 'before'
ELSE 'after'
END AS measurement_window,
qsp.plan_id,
qsp.query_plan_hash,
SUM(qrs.count_executions) AS count_executions,
CAST(
SUM(qrs.avg_duration * qrs.count_executions)
/ NULLIF(SUM(qrs.count_executions), 0) / 1000.0
AS decimal(18, 2)
) AS weighted_avg_duration_ms,
CAST(MAX(qrs.max_duration) / 1000.0 AS decimal(18, 2)) AS max_duration_ms,
CAST(
SUM(qrs.avg_cpu_time * qrs.count_executions)
/ NULLIF(SUM(qrs.count_executions), 0) / 1000.0
AS decimal(18, 2)
) AS weighted_avg_cpu_ms,
CAST(
SUM(qrs.avg_logical_io_reads * qrs.count_executions)
/ NULLIF(SUM(qrs.count_executions), 0)
AS decimal(18, 2)
) AS weighted_avg_logical_reads,
MAX(qrs.max_logical_io_reads) AS max_logical_reads,
MAX(qrs.last_execution_time) AS last_execution_time
FROM sys.query_store_query AS qsq
JOIN sys.query_store_plan AS qsp
ON qsp.query_id = qsq.query_id
JOIN sys.query_store_runtime_stats AS qrs
ON qrs.plan_id = qsp.plan_id
JOIN sys.query_store_runtime_stats_interval AS qsrsi
ON qsrsi.runtime_stats_interval_id = qrs.runtime_stats_interval_id
WHERE qsq.object_id = OBJECT_ID(@object_name)
AND qsrsi.end_time >= @window_start
AND qsrsi.start_time <= @window_end
GROUP BY
CASE
WHEN @change_time IS NULL THEN 'current_window'
WHEN qsrsi.end_time < @change_time THEN 'before'
ELSE 'after'
END,
qsp.plan_id,
qsp.query_plan_hash
ORDER BY MAX(qrs.last_execution_time) DESC;
Not applicable — this action only collects evidence and makes no database change.
- 1
Capture baseline Query Store metrics for current workloads (average duration, logical reads, plan count).
- 2
Deploy covering index on Sales.Orders in a test environment with ONLINE=ON.
- 3
Run representative parameter sets including @CustomerID=1195, @StartDate='2015-08-30', @EndDate='2016-09-23' and broad ranges.
- 4
Compare actual plan: confirm Key Lookup operator removed and Sort/Spill risk reduced.
- 5
Test with OPTION (RECOMPILE) on high-variance executions and monitor CPU overhead vs read savings.
- 6
Validate index usage after 14 days; if unused, consider drop.
- 7
Action Confidence: 55%
- 8
Runtime Impact Confidence: 93%
- 9
SQL Validity: Passed
- 10
Evidence Sufficiency: Sufficient
- 11
Production Readiness: Test Required
- 12
Mentioned execution count (280) differs significantly from actual (1)
- 13
Action confidence is lower than diagnosis confidence; test proposed changes before production.
| Signal | Value |
|---|---|
| Primary Pathology | KEY_LOOKUP |
| Runtime Bottleneck | CPU_BOUND |
| Query Design | POOR_QUERY_DESIGN |
| Index Health | OVER_INDEXED |
| Stability | STABLE |
| Risk | HIGH |
| Environment / Workload | None attributed |
| Contributing Factors | PARAMETER_SNIFFING |
Related — Unverified
- Key Lookup on Sales.Orders and Sales.OrderLines increases I/O per row
Environment / Workload
- Rebuild 12 fragmented index(es) on 2 tables — worst: FK_Sales_Orders_SalespersonPersonID at 98%
- Risk Level: Low
- Evidence: duration CV 114%, best/worst duration ratio 6.9x, logical reads ratio 220.7x; Query Store shows 5 plans but parameter_sniffing.plan_count=1 and compiled-parameter snapshot present; no compiled-vs-runtime mismatch confirmed.
- What Changes with Different Parameters: Plan shapes vary between index seek and clustered scan patterns based on date range selectivity and customer ID cardinality; worst plan uses 1103.6 reads vs 5 for best.
- Safe Mitigations: OPTION (RECOMPILE) for high-variance executions, or split wide date ranges to avoid a single broad plan.
- Verification Steps: Run with multiple known parameter sets; compare Query Store plan shapes and logical reads; confirm OPTION (RECOMPILE) reduces variance without excessive CPU overhead.
| Signal | Value |
|---|---|
| Bottleneck | CPU BOUND |
| Priority | P1 |
| Risk | HIGH |
| SQL Validity | Passed |
| Evidence | Sufficient |
| Production | Test Required |
| Query | POOR QUERY DESIGN |
| Index | OVER INDEXED |
| Stability | STABLE |
| Pathology | KEY LOOKUP |