Performance Tuning
Performance tuning without measurement is guesswork. SQL Server exposes wait statistics, DMVs, Query Store, and Extended Events — use them to find bottlenecks, apply targeted fixes, and verify improvements under load.
Tuning Methodology
- Establish baseline — CPU, disk latency, batch requests/sec, P95 query duration
- Identify bottleneck — waits, CPU, I/O, blocking, or memory pressure
- Hypothesize — missing index, stale stats, parameter sniffing, tempdb, MAXDOP
- Change one variable — isolate impact
- Verify — compare plans, logical reads, and business metrics
- Document — config changes and rationale in runbook
Never change max server memory and add indexes simultaneously — you won’t know what helped.
Wait Statistics
SELECT TOP 15
wait_type,
waiting_tasks_count,
wait_time_ms / 1000.0 AS wait_sec,
wait_time_ms / NULLIF(waiting_tasks_count, 0) AS avg_wait_ms
FROM sys.dm_os_wait_stats
WHERE wait_type NOT LIKE N'%SLEEP%'
AND wait_type NOT LIKE N'%IDLE%'
AND wait_type NOT LIKE N'%QUEUE%'
ORDER BY wait_time_ms DESC;
| Wait Type | Often Indicates |
|---|---|
PAGEIOLATCH_* |
Disk I/O — slow storage, memory pressure |
LCK_M_* |
Blocking — long transactions, missing indexes |
CXPACKET / CXCONSUMER |
Parallelism imbalance — MAXDOP, skew |
PAGEIOLATCH_EX on tempdb |
tempdb config — file count, size |
ASYNC_NETWORK_IO |
Client not consuming results fast enough |
WRITELOG |
Log disk bottleneck — commit-heavy workload |
Reset waits only for testing windows:
DBCC SQLPERF(N'waitstats', 1);
Memory Configuration
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
EXEC sp_configure 'max server memory (MB)', 24576; -- Leave 4-8 GB for OS on 32 GB box
RECONFIGURE;
SELECT
(total_physical_memory_kb / 1024) AS physical_mb,
(available_physical_memory_kb / 1024) AS available_mb
FROM sys.dm_os_sys_memory;
Page Life Expectancy (Buffer Manager counter) — below 300 seconds sustained suggests memory pressure.
SELECT cntr_value AS page_life_expectancy
FROM sys.dm_os_performance_counters
WHERE counter_name = N'Page life expectancy'
AND object_name LIKE N'%Buffer Manager%';
tempdb Optimization
tempdb serves sorts, hashes, row versions (RCSI), and user temp objects:
-- Multiple equally sized files (1 per CPU up to 8)
ALTER DATABASE tempdb ADD FILE (NAME = tempdev2, FILENAME = N'E:\TempDB\tempdev2.ndf', SIZE = 1024MB, FILEGROWTH = 256MB);
-- Repeat for tempdev3..8
SELECT name, size * 8 / 1024 AS size_mb, growth * 8 / 1024 AS growth_mb
FROM sys.master_files WHERE database_id = DB_ID(N'tempdb');
Monitor version store:
SELECT
SUM(version_store_reserved_page_count) * 8 / 1024 AS version_store_mb
FROM sys.dm_db_file_space_usage;
Parallelism and MAXDOP
-- Instance level
EXEC sp_configure 'max degree of parallelism', 4;
RECONFIGURE;
-- Database scoped (2016+)
ALTER DATABASE SCOPED CONFIGURATION SET MAXDOP = 4;
-- Query level
SELECT * FROM dbo.LargeFactTable WHERE Year = 2024 OPTION (MAXDOP 2);
Cost Threshold for Parallelism default 5 — raise to 50–100 on OLTP to reduce excessive parallelism on small queries.
Resource Governor
Throttle reporting workloads:
CREATE RESOURCE POOL ReportPool WITH (MAX_CPU_PERCENT = 30, MAX_MEMORY_PERCENT = 30);
CREATE WORKLOAD GROUP ReportGroup USING ReportPool;
CREATE FUNCTION dbo.ClassifierFunction()
RETURNS SYSNAME WITH SCHEMABINDING
AS BEGIN
RETURN CASE
WHEN APP_NAME() LIKE N'%ReportServer%' THEN N'ReportGroup'
ELSE N'default'
END;
END;
GO
CREATE CLASSIFIER FUNCTION dbo.ClassifierFunction;
ALTER RESOURCE GOVERNOR RECONFIGURE;
Useful when ad-hoc reports starve OLTP — not a substitute for proper indexing.
Index and Statistics Maintenance
-- Fragmentation-driven maintenance
ALTER INDEX IX_Orders_CustomerID ON dbo.Orders REORGANIZE;
ALTER INDEX ALL ON dbo.Orders REBUILD WITH (ONLINE = ON, MAXDOP = 4);
UPDATE STATISTICS dbo.Orders WITH FULLSCAN;
Automate with Ola Hallengren’s MaintenanceSolution — industry standard scripts.
Query Store Overview
ALTER DATABASE MyApp SET QUERY_STORE = ON (
OPERATION_MODE = READ_WRITE,
MAX_STORAGE_SIZE_MB = 1024,
INTERVAL_LENGTH_MINUTES = 15
);
SELECT TOP 10
qsq.query_id,
qsrs.avg_duration / 1000.0 AS avg_ms,
qsrs.count_executions,
LEFT(qt.query_sql_text, 200) AS query_text
FROM sys.query_store_query qsq
JOIN sys.query_store_plan qsp ON qsq.query_id = qsp.query_id
JOIN sys.query_store_runtime_stats qsrs ON qsp.plan_id = qsrs.plan_id
JOIN sys.query_store_query_text qt ON qsq.query_text_id = qt.query_text_id
ORDER BY qsrs.avg_duration DESC;
See the dedicated Query Store deep dive page for plan regression and forced plans.
Expensive Query DMVs
SELECT TOP 20
total_elapsed_time / execution_count / 1000 AS avg_ms,
execution_count,
total_logical_reads / execution_count AS avg_reads,
SUBSTRING(st.text, (qs.statement_start_offset/2)+1,
((CASE qs.statement_end_offset WHEN -1 THEN DATALENGTH(st.text)
ELSE qs.statement_end_offset END - qs.statement_start_offset)/2)+1) AS query_text
FROM sys.dm_exec_query_stats qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) st
ORDER BY total_elapsed_time / execution_count DESC;
Plan cache clears on restart — Query Store persists history.
Common Mistakes
| Mistake | Effect |
|---|---|
| Tuning without baseline | Cannot prove improvement |
| Adding indexes blindly | Write regression |
| MAXDOP = 0 on OLTP | CPU saturation on small queries |
| Ignoring blocking | Misdiagnosed as “slow disk” |
| One tempdb file | PFS/GAM contention |
| Disabling auto stats | Bad plans after data change |
Production Scenario: Morning Slowness
Symptoms: CPU 90%, CXPACKET waits spike at 8 AM.
Investigation:
- Query Store — top new query by duration
- Plan shows parallel scan on 200M row table
- Missing index on
WHERE CreatedAt >= @today
Fix:
CREATE NONCLUSTERED INDEX IX_Orders_CreatedAt ON dbo.Orders (CreatedAt) INCLUDE (Status, Total);
CPU drops to 25% — verify for one week.
Monitoring Tools
| Tool | Purpose |
|---|---|
| SSMS Activity Monitor | Quick live view |
| sp_WhoIsActive | Session-level detail |
| Extended Events | Lightweight tracing |
| Azure Monitor / SCOM | Enterprise alerting |
| SQL Sentry, Redgate | Commercial deep monitoring |
Troubleshooting Quick Reference
-- Active requests
SELECT session_id, status, command, wait_type, blocking_session_id, cpu_time, logical_reads
FROM sys.dm_exec_requests WHERE session_id > 50;
-- I/O stalls by file
SELECT DB_NAME(database_id) AS db, file_id, io_stall_read_ms, num_of_reads, io_stall_write_ms
FROM sys.dm_io_virtual_file_stats(NULL, NULL)
ORDER BY io_stall_read_ms DESC;
Measure first with waits and Query Store, then apply targeted fixes — not blanket configuration changes across production.