Blog

  • Finding Bottlenecks in SQL Server XML and Graphical Execution Plans

    Finding Bottlenecks in SQL Server XML and Graphical Execution Plans

    A 40-operator plan is hard to scan visually. Every graphical plan is backed by XML you can query directly — genuinely useful once plans get wide.

    Hunting Plan XML, sketched out(every graphical plan is backed by searchable text)<RelOp PhysicalOp=”Hash Match”> <Warnings> <SpillToTempDb/> </Warnings></RelOp>SET SHOWPLAN_XML ON; or SSMS → “Show Plan XML”PlanAffectingConvertimplicit conversion changed the plan(the SARGability killer from Module 3)NoJoinPredicateaccidental CROSS JOIN —a missing join conditionSpillToTempDbHash/Sort ran out of memory,spilled to disk — found it ✓ColumnsWithNoStatisticsoptimizer flying blind on that columnSame search works across the wholeplan cache — CROSS APPLY finds everymatching plan, server-wide. 📌

    Getting the Raw XML

    SET SHOWPLAN_XML ON;
    GO
    SELECT * FROM dbo.OrderLog o JOIN dbo.Customer c ON o.customer_id = c.customer_id;
    GO
    SET SHOWPLAN_XML OFF;
    -- Or right-click a graphical plan in SSMS -> "Show Execution Plan XML"

    Searching for Specific Problems

    Once you have the XML, search (Ctrl+F in SSMS’s XML view, or programmatically) for these telltale strings:

    Search for Finds
    PlanAffectingConvert Implicit conversions that changed the plan — a direct hit for the SARGability issue from Module 3
    NoJoinPredicate An accidental CROSS JOIN — often a missing join condition bug
    SpillToTempDb A Hash Match or Sort that ran out of memory and spilled to disk — a serious performance red flag
    ColumnsWithNoStatistics Columns the optimizer had no statistics for at all

    Finding This Programmatically Across the Plan Cache

    This CROSS APPLY pattern is the exact same tool from the Developers & DBAs course’s window-functions chapter, now aimed at sys.dm_exec_cached_plans instead of a business table — the same skill, a new target.

    SELECT TOP 20 qp.query_plan, st.text
    FROM sys.dm_exec_cached_plans cp
    CROSS APPLY sys.dm_exec_query_plan(cp.plan_handle) qp
    CROSS APPLY sys.dm_exec_sql_text(cp.plan_handle) st
    WHERE CAST(qp.query_plan AS NVARCHAR(MAX)) LIKE '%SpillToTempDb%';

    This finds every cached plan currently spilling to tempdb — a genuinely powerful way to proactively hunt for memory-pressure problems across an entire server, not just one query you’re already suspicious of.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • Capturing SQL Server Deadlock Graphs from system_health: A Real Diagnostic Walkthrough

    Capturing SQL Server Deadlock Graphs from system_health: A Real Diagnostic Walkthrough

    You already know what a deadlock is. Here’s how to actually retrieve the deadlock graph after the fact — without having set up a trace in advance — because system_health is running by default on every SQL Server instance.

    Anatomy of a deadlock cycle(the wait-for graph, drawn out)SPID 52UPDATE OrdersSPID 67UPDATE OrdersRow: OrderID 500X lock (exclusive)Row: OrderID 900X lock (exclusive)waits forheld bywaits forheld byDEADLOCK!one SPID becomes the victimCommon myth: the victim isn’t thetransaction that “started” the deadlock —it’s whichever is cheapest to roll back(least log written), by default. 📌

    Pulling Deadlock Graphs You Never Explicitly Captured

    SELECT CAST(event_data.value('(event/data/value)[1]', 'VARCHAR(MAX)') AS XML) AS deadlock_graph,
        event_data.value('(event/@timestamp)[1]', 'DATETIME2') AS event_time
    FROM (
        SELECT XEventData.query('.') AS event_data
        FROM (
            SELECT CAST(target_data AS XML) AS TargetData
            FROM sys.dm_xe_session_targets st
            JOIN sys.dm_xe_sessions s ON s.address = st.event_session_address
            WHERE s.name = 'system_health' AND st.target_name = 'ring_buffer'
        ) AS Data
        CROSS APPLY TargetData.nodes('RingBufferTarget/event[@name="xml_deadlock_report"]') AS XEventData(XEventData)
    ) AS tab(event_data);

    Because system_health runs continuously by default, this query can retrieve deadlocks that happened before you even knew there was a problem — no advance trace setup required.

    Reading the Graph

    process-list Each <process> = one participant Includes the exact SQL text and waitresource resource-list Each resource = what’s being fought over Shows which process owns vs waits

    The victim-list element tells you which process SQL Server killed. Cross-reference the surviving process’s SQL text against the killed one’s — this is exactly how you confirm whether inconsistent access order (the classic cause) is really what happened.

    Beyond Theory: A Deadlock Involving a Table Scan

    Not every deadlock is the classic “two transactions, opposite order” case from Course 2. A single transaction doing a large table scan can deadlock against a small, targeted UPDATE if the scan acquires and holds shared locks across a wide range while the update needs an exclusive lock inside that range. The fix here isn’t reordering — it’s often reducing the scan’s lock footprint with a better index (tying directly back to Module 2 and 3).


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • Latches vs Locks in SQL Server: The Difference That Trips Up Even Experienced DBAs

    Latches vs Locks in SQL Server: The Difference That Trips Up Even Experienced DBAs

    Both sound like “something is blocking something.” They protect completely different things, and misdiagnosing one as the other sends you fixing the wrong problem.

    Locks vs latches: not the same fight(logical vs physical)LOCKSprotect LOGICAL data consistencyheld for the WHOLE transactionwait type: LCK_M_*LATCHESprotect PHYSICAL memory pagesheld for MICROSECONDSwait: PAGELATCH_*/PAGEIOLATCH_*VSThe most common latch-contention patternS1S2S3S4S5LAST PAGEIDENTITY columninsert herePAGELATCH_EX pileupCommon mistake: seeing PAGELATCH_EX andreaching for the LOCK playbook (shorter txns,isolation level) is the wrong fix. Latches needdifferent medicine: more files, hash keys. 📌

    Two Different Jobs

    Locks Protect LOGICAL data consistency Held for transaction duration Wait type: LCK_M_* Latches Protect PHYSICAL in-memory pages Held for microseconds, not transaction duration Wait type: PAGELATCH_*/PAGEIOLATCH_*

    Why the Distinction Matters in Practice

    A DBA seeing high PAGELATCH_EX waits and reaching for the usual lock-blocking playbook (shorten transactions, change isolation level) is solving the wrong problem — latch contention is about physical memory structure access, not logical transaction isolation. It needs a completely different fix.

    -- Distinguish the two directly from current waits
    SELECT wait_type, COUNT(*) AS waiting_now
    FROM sys.dm_os_waiting_tasks
    WHERE wait_type LIKE 'LCK%' OR wait_type LIKE '%LATCH%'
    GROUP BY wait_type;

    The Most Common Latch Contention Pattern

    PAGELATCH_EX waits on the last page of a table with an ever-increasing key (like an IDENTITY column) under very high concurrent insert load is the single most common latch contention scenario — every session is racing to insert into the same physical page. This exact pattern sets up the tempdb contention lesson next, which is the same underlying phenomenon at a system-table level.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • Diagnosing TempDB Contention in SQL Server: GAM, SGAM, PFS, and Multiple Data Files

    Diagnosing TempDB Contention in SQL Server: GAM, SGAM, PFS, and Multiple Data Files

    The previous lesson’s latch pattern shows up at system scale in one very specific, very common place: tempdb’s allocation pages, under heavy use of temp tables and table variables (yes — straight back to Course 2’s temp objects material).

    TempDB allocation-page pileup(GAM / SGAM / PFS contention)BEFORE: one tempdb fileGAM / SGAM / PFSsingle hot pageT1T2T3T4the fixAFTER: 4 equal-size filesFile 1gets: T1, T5, T9…own GAM/PFS pageFile 2gets: T2, T6, T10…own GAM/PFS pageFile 3gets: T3, T7, T11…own GAM/PFS pageFile 4gets: T4, T8, T12…own GAM/PFS pageround-robin: each new temp object grabs the next file in rotationCommon mistake: adding extra tempdb fileswithout matching their SIZE. Proportional-fillfavors whichever file has the MOST free space —unequal sizes defeat round-robin completely. 📌

    What’s Actually Being Contended

    Every tempdb data file has special allocation-tracking pages: GAM (Global Allocation Map), SGAM (Shared GAM), and PFS (Page Free Space). Every session creating a temp table or table variable must touch these pages to claim space — under high concurrency, many sessions latch-wait on the same few physical pages.

    Diagnosing It

    -- High PAGELATCH waits specifically on tempdb pages is the signature
    SELECT wait_type, wait_time_ms, waiting_tasks_count
    FROM sys.dm_os_wait_stats
    WHERE wait_type LIKE 'PAGELATCH%'
    ORDER BY wait_time_ms DESC;
    
    -- Confirm it's tempdb specifically
    SELECT session_id, wait_type, resource_description
    FROM sys.dm_os_waiting_tasks
    WHERE resource_description LIKE '2:%'; -- database_id 2 = tempdb

    The Standard Fix: Multiple Equally-Sized Data Files

    One tempdb data file All sessions fight over the SAME GAM/SGAM/PFS pages Multiple equal-size files Round-robin allocation spreads contention across separate page sets

    -- Common starting guidance: one tempdb data file per CPU core, up to ~8, all EQUAL size
    ALTER DATABASE tempdb ADD FILE (NAME = tempdev2, FILENAME = 'D:tempdbtempdev2.ndf', SIZE = 1024MB, FILEGROWTH = 256MB);
    -- Repeat with matching sizes for tempdev3, tempdev4...

    Equal size matters: SQL Server’s proportional-fill allocation favors the file with the most free space, so unequal files defeat the round-robin benefit entirely — a genuinely common mistake when adding files without matching existing sizes.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • PIVOT, UNPIVOT, and CROSS APPLY in SQL Server: Solving Top-N-Per-Group

    PIVOT, UNPIVOT, and CROSS APPLY in SQL Server: Solving Top-N-Per-Group

    Two more tools that solve problems plain JOINs and GROUP BY genuinely can’t express cleanly — reshaping rows into columns, and joining a table to a per-row subquery in a way a normal JOIN’s ON clause structurally cannot do.

    Three Shapes, One Row Source(PIVOT, UNPIVOT, and CROSS APPLY)TALL rowsregion | person | amtWest | Dana | 500West | Rahul | 300East | Dana | 700…one row eachPIVOT →← UNPIVOTWIDE columnsregion | Dana | Rahul | ElenaWest | 500 | 300 | –East | 700 | – | 900column list hardcoded ⚠(different tool,same source)CROSS APPLYfor each region row,run TOP 1 …ORDER BYamount DESC subquerytop sale per regionGotcha: PIVOT’s [Dana],[Rahul],[Elena] list must be knownat query-write time — dynamic columns need dynamic SQL. 📌

    PIVOT: Rows Into Columns

    SELECT region, [Dana], [Rahul], [Elena]
    FROM (SELECT region, salesperson, amount FROM dbo.Sale) src
    PIVOT (SUM(amount) FOR salesperson IN ([Dana], [Rahul], [Elena])) AS pvt;

    The salesperson values become column headers — this is exactly the shape a spreadsheet-style report needs, and exactly the shape raw relational data never naturally has. The tradeoff: the column list [Dana], [Rahul], [Elena] must be known and hardcoded at query-write time — PIVOT can’t dynamically discover “whatever salespeople happen to exist.” A fully dynamic column list needs dynamic SQL (Chapter 1) to build the PIVOT statement’s IN list at runtime.

    UNPIVOT: The Reverse

    SELECT region, salesperson, amount
    FROM (SELECT region, [Dana], [Rahul], [Elena] FROM dbo.vw_RegionTotals_Wide) src
    UNPIVOT (amount FOR salesperson IN ([Dana], [Rahul], [Elena])) AS unpvt;

    Turns spreadsheet-shaped, wide data back into normalized, tall rows — genuinely useful when importing an Excel-style export where each salesperson got their own column.

    CROSS APPLY: The Top-N-Per-Group Solution

    SELECT s.region, top_sale.amount, top_sale.sale_date
    FROM (SELECT DISTINCT region FROM dbo.Sale) s
    CROSS APPLY (
        SELECT TOP 1 amount, sale_date FROM dbo.Sale WHERE region = s.region ORDER BY amount DESC
    ) AS top_sale;

    Why APPLY, Not JOIN

    A JOIN’s ON clause can’t contain a TOP/ORDER BY subquery APPLY lets the right side reference columns from the left, row by row

    CROSS APPLY behaves like an INNER JOIN, but the right side can reference the left row directly (as s.region is referenced inside the CROSS APPLY subquery above) — exactly what “top 1 sale per region” needs, and something a standard JOIN’s ON clause is syntactically incapable of expressing. OUTER APPLY is the LEFT JOIN equivalent, keeping left rows even when the applied subquery returns nothing (a region with zero sales still appears, with NULLs for the top-sale columns).

    The Window-Function Alternative, Compared

    -- The same "top sale per region" answer, using Chapter 6's window functions instead
    WITH Ranked AS (
        SELECT region, amount, sale_date,
            ROW_NUMBER() OVER (PARTITION BY region ORDER BY amount DESC) AS rn
        FROM dbo.Sale
    )
    SELECT region, amount, sale_date FROM Ranked WHERE rn = 1;

    Both approaches give the same result here. As a rule of thumb: reach for CROSS APPLY when the “top N” logic needs to pull in columns or computations that don’t fit neatly into a single window function (a call to a table-valued function per row, for instance); reach for a window function with ROW_NUMBER() = 1 when the whole thing is expressible as ordinary columns from one table, since it’s typically the more efficient, more idiomatic choice for that simpler case.

    Practice tip: Solve “top 2 sales per region” (not just top 1) both ways — CROSS APPLY with TOP 2, and the window-function version with WHERE rn <= 2. Confirm both return the same rows, then decide for yourself which reads more clearly to you; that judgment call is exactly what real T-SQL code review conversations are made of.

    Enjoyed this?

    Subscribe to get every new SQL Server lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes, projects, and 10+ exercises per chapter? Check out SQL Server for Developers & DBAs, coming soon on this site.

  • SQL Server Query Store: How It Works and How to Force a Better Plan

    SQL Server Query Store: How It Works and How to Force a Better Plan

    The plan cache (Module 1) resets on restart and only shows current state. Query Store persists query and plan performance history per database, across restarts — and lets you directly force a known-good plan.

    Catch the regression, force the fix(a plan’s history, sketched)Plan Aavg duration: 12msthe GOOD planrunning happily for weeks!Plan Bavg duration: 850msREGRESSED after recompilenew parameter, bad estimatePlan Anow FORCED (pinned)sp_query_store_force_planno code deploy neededrecompile — new paramforce_plan pins itQuery Store remembers every plan ever compiled for a query —you look up the good one and pin it, instead of guessing.Gotcha: forcing a plan isn’t forever. If it becomesinvalid (e.g. an index it needs gets dropped), SQL Serversilently falls back to a fresh compile — checklast_force_failure_reason, don’t assume it’s permanent. 📌

    Enabling It

    ALTER DATABASE YourDatabase SET QUERY_STORE = ON;
    ALTER DATABASE YourDatabase SET QUERY_STORE (OPERATION_MODE = READ_WRITE);

    Finding Regressed Queries

    SELECT q.query_id, qt.query_sql_text, rs.avg_duration, rs.last_execution_time
    FROM sys.query_store_query q
    JOIN sys.query_store_query_text qt ON q.query_text_id = qt.query_text_id
    JOIN sys.query_store_plan p ON q.query_id = p.query_id
    JOIN sys.query_store_runtime_stats rs ON p.plan_id = rs.plan_id
    ORDER BY rs.avg_duration DESC;

    The Feature That Directly Fixes Parameter Sniffing: Forcing a Plan

    Query Store remembers every plan a query has ever used You can pin the good one, permanently, without changing code

    EXEC sp_query_store_force_plan @query_id = 42, @plan_id = 137;
    -- Later, to release it:
    EXEC sp_query_store_unforce_plan @query_id = 42, @plan_id = 137;

    This is a genuinely production-safe response to the Module 3 parameter sniffing problem — no code deployment needed, immediately reversible, and the exact plan is verifiable (unlike a hint that only influences future compilation).


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • Building Reusable Extended Events Sessions in SQL Server for Ongoing Monitoring

    Building Reusable Extended Events Sessions in SQL Server for Ongoing Monitoring

    The one-off XEvents session from the 13-Hour Delete case study was diagnostic and temporary. A production monitoring session needs to run continuously with minimal overhead — different design goals entirely.

    Designing a session that survives(not just a diagnostic one-off)RAW ACTIVITYfires on EVERY querykeep signal, drop noiseFILTERWHERE duration > 5sBOUNDED TARGET50MB cap, 5 rollover filesREAD LATERfn_xe_file_target_read_file()ALLOW_SINGLE_EVENT_LOSSnever lets monitoring block your appSTARTUP_STATE = ONsurvives a SQL Server restartGotcha: MAX_DISPATCH_LATENCY = 5 SECONDS means anevent can sit buffered for up to 5s before hitting thetarget — this is NOT a real-time feed. Don’t build alertsthat assume instant visibility the moment it happens. 📌

    Designing for Low Overhead

    CREATE EVENT SESSION LongRunningQueries ON SERVER
    ADD EVENT sqlserver.sql_statement_completed (
        ACTION (sqlserver.sql_text, sqlserver.username, sqlserver.client_hostname)
        WHERE duration > 5000000  -- 5 seconds, in microseconds — filter aggressively
    )
    ADD TARGET package0.event_file (
        SET filename = N'LongRunningQueries', max_file_size = 50, max_rollover_files = 5
    )
    WITH (MAX_MEMORY = 4096 KB, EVENT_RETENTION_MODE = ALLOW_SINGLE_EVENT_LOSS, MAX_DISPATCH_LATENCY = 5 SECONDS);
    GO
    ALTER EVENT SESSION LongRunningQueries ON SERVER STATE = START;

    Three choices make this production-safe rather than a diagnostic one-off: an aggressive WHERE duration > filter (only capture what actually matters), a rollover file target with a size cap (bounded disk usage), and ALLOW_SINGLE_EVENT_LOSS (never let monitoring itself become a bottleneck).

    Auto-Starting on Server Restart

    ALTER EVENT SESSION LongRunningQueries ON SERVER WITH (STARTUP_STATE = ON);

    Reading the Results Later

    SELECT event_data.value('(event/@timestamp)[1]', 'DATETIME2') AS event_time,
        event_data.value('(event/data[@name="duration"]/value)[1]', 'BIGINT') / 1000000.0 AS duration_sec,
        event_data.value('(event/action[@name="sql_text"]/value)[1]', 'NVARCHAR(MAX)') AS sql_text
    FROM sys.fn_xe_file_target_read_file('LongRunningQueries*.xel', NULL, NULL, NULL)
    CROSS APPLY (SELECT CAST(event_data AS XML) AS event_data) ed
    ORDER BY event_time DESC;

    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • SQL Server PerfMon Counters That Actually Matter, Plus the Complete DMV Toolkit

    SQL Server PerfMon Counters That Actually Matter, Plus the Complete DMV Toolkit

    Windows Performance Monitor exposes hundreds of SQL Server counters. Most are noise. Here are the handful worth an actual dashboard tile, plus every DMV this course has used, in one place.

    Four tools, four different questions(reach for the right one)WHAT’SWRONG?(start here)PerfMonIs there a TRENDover time?DMVsWhat’s the stateRIGHT NOW?Query StoreHow has THIS QUERYevolved over time?ExtendedEventsCapture a SPECIFICevent as it happensCommon mistake: treating Batch Requests/sec ashaving a ‘good’ absolute number. There isn’t one —it’s workload-specific. Only the TREND vs YOUR OWNbaseline matters, not some blog’s benchmark. 📌

    The PerfMon Counters Worth Watching

    Counter What a bad value means
    Page Life Expectancy Low = buffer pool pressure, pages evicted quickly (Module 1)
    Batch Requests/sec Your baseline throughput metric — track trend, not absolute value
    Compilations/sec vs Batch Requests/sec High ratio = excessive recompiling, often ad-hoc query bloat (Module 1)
    Lock Waits/sec Rising trend = growing blocking problem (Module 5)
    Full Scans/sec Rising trend alongside stable workload = missing/degraded indexes (Module 2)

    The Complete DMV Reference From This Course

    sys.dm_os_buffer_descriptorsBuffer pool contents (M1) sys.dm_exec_cached_plansPlan cache contents (M1) sys.dm_db_missing_index_detailsIndex candidates (M2) sys.dm_db_index_usage_statsIndex read/write balance (M2) sys.dm_exec_query_statsHistorical query cost (M3/M4) sys.dm_os_waiting_tasksLive blocking/latch state (M5)

    The Right Habit: One Dashboard, Not Twenty Tools

    Query Store, Extended Events, DMVs, and PerfMon aren’t competing tools — they answer different question shapes. PerfMon: is there a trend problem over time? DMVs: what’s the current/historical state right now? Extended Events: capture specific events as they happen. Query Store: how has this specific query’s performance evolved? Combine them per the Evidence-First workflow rather than reaching for just one out of habit.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • In-Memory OLTP in SQL Server: When Memory-Optimized Tables Actually Help

    In-Memory OLTP in SQL Server: When Memory-Optimized Tables Actually Help

    This is the direct architectural answer to Module 5’s latch contention problem — tables that avoid locks and latches almost entirely, at the cost of real constraints on how you use them.

    Disk-based vs memory-optimized(same rows, different plumbing)DISK-BASED TABLErows live on 8KB pagesPAGELATCH to touch a pagerow/page LOCKS for isolationcost climbs under concurrencyMEMORY-OPTIMIZED TABLErows live in memory, alwayslock-free, latch-free accessoptimistic row-versioningbuilt for extreme concurrencyVSno PAGELATCHpileup, ever ✓Gotcha: not a default upgrade. Reach for thisonly once Module 5’s evidence points squarelyat latch contention — native procs alsorestrict the T-SQL surface you can use. 📌

    Creating a Memory-Optimized Table

    -- Requires a MEMORY_OPTIMIZED_DATA filegroup on the database first
    CREATE TABLE dbo.SessionState (
        session_id UNIQUEIDENTIFIER NOT NULL PRIMARY KEY NONCLUSTERED,
        user_id INT NOT NULL,
        last_activity DATETIME2 NOT NULL,
        INDEX IX_UserId NONCLUSTERED (user_id)
    ) WITH (MEMORY_OPTIMIZED = ON, DURABILITY = SCHEMA_AND_DATA);

    Why It Avoids the Contention From Module 5

    Disk-based table Locks for isolation Latches for page access Both cost under high concurrency Memory-optimized table Row-versioning, lock-free No page structure, no latches Designed for extreme concurrency

    Natively Compiled Procedures: The Other Half

    CREATE PROCEDURE dbo.usp_UpdateSessionActivity
        @session_id UNIQUEIDENTIFIER
    WITH NATIVE_COMPILATION, SCHEMABINDING
    AS
    BEGIN ATOMIC WITH (TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = 'us_english')
        UPDATE dbo.SessionState SET last_activity = SYSDATETIME() WHERE session_id = @session_id;
    END;

    Natively compiled procedures are compiled to actual machine code, not interpreted T-SQL — the performance ceiling is dramatically higher, but the T-SQL surface area supported inside them is deliberately restricted (no dynamic SQL, limited function support).

    When This Is (and Isn’t) the Right Tool

    In-Memory OLTP genuinely shines for extreme-throughput, high-contention scenarios like session state, real-time bidding, or IoT ingestion — exactly the ever-increasing-key latch contention pattern from Module 5. It’s a poor fit for general-purpose reporting tables or anything needing the full T-SQL surface (complex constraints, most trigger types). Reach for it only after confirming, with evidence, that lock/latch contention is the actual bottleneck — not as a default upgrade.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.

  • Resource Governor in SQL Server: Isolating Workloads on a Shared Instance

    Resource Governor in SQL Server: Isolating Workloads on a Shared Instance

    A classic problem: an analyst runs an ad-hoc report against the same instance serving the production OLTP app, and it eats all available CPU. Resource Governor caps this at the engine level, without a second server.

    One instance, two workloads, one gate(capping blast radius)OLTP APPwide-open pipeno CPU cap neededAD-HOC REPORTwants ALL the CPUreporting_svc login30% CPU / 20% MEM capSHARED SQL SERVER INSTANCEOLTP: runs exactlyas fast as before ✓Report: capped —can’t starve prod ✓Reminder: this caps blast radius — it doesn’tfix a bad query. An unindexed report is stillslow, just contained. 📌

    The Three Pieces

    -- 1. Resource pool: a slice of CPU/memory
    CREATE RESOURCE POOL ReportingPool WITH (MAX_CPU_PERCENT = 30, MAX_MEMORY_PERCENT = 20);
    GO
    
    -- 2. Workload group: sits inside a pool, can set query-level limits too
    CREATE WORKLOAD GROUP ReportingGroup
        WITH (REQUEST_MAX_CPU_TIME_SEC = 60)
        USING ReportingPool;
    GO
    
    -- 3. Classifier function: routes incoming connections to the right group
    CREATE FUNCTION dbo.fn_ClassifyLogin() RETURNS SYSNAME
    WITH SCHEMABINDING
    AS
    BEGIN
        IF SUSER_SNAME() = 'reporting_svc'
            RETURN 'ReportingGroup';
        RETURN 'default';
    END;
    GO
    ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = dbo.fn_ClassifyLogin);
    ALTER RESOURCE GOVERNOR RECONFIGURE;

    The Guarantee This Provides

    The reporting_svc login can NEVER consume more than 30% CPU or 20% memory, regardless of how badly its queries are written

    This isn’t a substitute for actually fixing a bad query (Modules 2-4 still apply) — it’s a blast-radius guarantee. Even an un-tuned, missing-index report query can no longer starve the production OLTP workload sharing the instance.


    Enjoyed this?

    Subscribe to get every new lesson as soon as it’s published, and share it with a developer who’d find it useful.

    📡 Subscribe via RSS  | 
    Share on X  | 
    Share on LinkedIn  | 
    Share on Facebook

    Want the full structured course with quizzes and hands-on labs? Check out SQL Server Performance Tuning, coming soon on this site.