DEV Community

azhadsuhaimi
azhadsuhaimi

Posted on

Why Do We Keep Forgetting About the Plan Cache in SQL Server?

Picture this: A user reports that the system is running painfully slow.

What is your immediate knee-jerk reaction?

For a long time, mine was to check table indexes, look at active locks, or blame the ORM for generating terrible SQL queries. But more often than not, the actual root cause was sitting right under my nose—inside SQL Server’s plan cache.

We talk a lot about optimizing queries during development, but we rarely talk about how much insight SQL Server passively collects for us in production while it's running.


The Unsung Hero: sys.dm_exec_query_stats

When SQL Server executes a query, it compiles an execution plan and caches it to save CPU time on subsequent runs. Along with that plan, it tracks execution metrics inside sys.dm_exec_query_stats.

It stores critical telemetry like:

  • Execution Count: Is a query slow because it’s inherently heavy, or because it’s being executed 50,000 times a minute (the classic N+1 problem)?
  • Worker Time: How much raw CPU time (total_worker_time) has this specific query consumed since the last service restart?
  • Logical Reads: Is the query thrashing memory and IO?

Instead of firing up SQL Server Profiler or Extended Events (which can add performance overhead to a live server), pulling directly from the plan cache is practically free.


The Tricky Part: Taming Large Batch Queries

If you've ever queried sys.dm_exec_query_stats and joined it with sys.dm_exec_sql_text, you’ve probably hit a common pain point: It gives you the entire batch or stored procedure text.

If a slow statement is hidden inside a 500-line stored procedure, looking at the entire text block doesn't immediately tell you which exact query ate the CPU.

To isolate the specific culprit, you have to do some offset math using statement_start_offset and statement_end_offset.

Here is a lightweight snippet I usually keep handy when I need to quickly inspect top CPU hogs without reading through endless blocks of SQL text:

SELECT TOP 10
    qs.execution_count AS [Execution_Count],
    qs.total_worker_time / 1000.0 AS [Total_CPU_ms],
    (qs.total_worker_time / 1000.0) / qs.execution_count AS [Avg_CPU_ms],
    qs.total_elapsed_time / 1000.0 AS [Total_Duration_ms],
    qs.total_logical_reads AS [Total_Logical_Reads],
    qs.creation_time AS [Plan_Cached_Since],
    DB_NAME(st.dbid) AS [Database_Name],
    OBJECT_NAME(st.objectid, st.dbid) AS [Object_Name],
    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 AS qs
CROSS APPLY sys.dm_exec_sql_text(qs.sql_handle) AS st
CROSS APPLY sys.dm_exec_query_plan(qs.plan_handle) AS qp
ORDER BY qs.total_worker_time DESC;

Enter fullscreen mode Exit fullscreen mode

Dividing total_worker_time by 1000 converts microseconds to readable milliseconds, and the SUBSTRING logic slices out the exact offending query statement.

What's Your First Move During a Performance Issue?
I’ve been working on organizing my own lightweight, read-only diagnostic scripts lately because I got tired of rewriting these DMV queries from memory during high-pressure troubleshooting.

But I’m curious to know how other developers handle this:

Do you regularly rely on Dynamic Management Views (DMVs) for quick health checks?

Or do you prefer APM tools like Datadog, New Relic, or built-in Extended Events?

Let’s discuss in the comments below!

Top comments (0)