clement13.net
Original photo: Flood damage
Description
Flood damage
Source
Unidentified National Park Service photographer · original page
Licence
PD Public domain
Original photo, unretouched. The background is a version of it stylised by the site.

Disaster cases: the SQL is clean, the assembly is wrong

Three workloads where no single query is bad, and yet they do not scale.

≈ 2 min readReading
In brief
  • A · Valuation: E1 E2 E3 E4 E5 E13.
  • B · Hierarchy: E1 E4 E5 E13.
  • C · Permissions: E1 E2 E5 E11.
  • Common remedy: prepare what the loop was recomputing, then run very few queries.
On this page

Three realistic workloads where every query, taken in isolation, is clean: set-based inside, indexed, typed, reviewed. They all fail the same way: an assembly error, often several at once. For each one: the complete procedural version, the reason for the disaster (with references to the design errors), the measurements, and the redesign using the set-based approach: one preparation step, then very few queries, with no iteration.

Environment and how to read the measurements

Measurements were taken on SQL Server 2019 (LocalDB), on a development workstation, clearing the plan cache before each run (DBCC FREEPROCCACHE, test instance only). The statements, function calls and logical reads columns are exactly reproducible; duration and CPU vary from one run to the next (up to a factor of 2 on this machine): they are orders of magnitude, and it is the growth that matters. In each case, the equivalence of the results was verified (EXCEPT in both directions: no rows).

The three cases#

CaseScenarioAccumulated errorsLoopSet-based
A · ValuationOne procedure per customer, one function per row, an audit triggerE1 E2 E3 E4 E5 E13188,002 statements, 180,000 calls (2,000 customers)4 statements, 0 calls
B · HierarchyOne recursive CTE per nodeE1 E4 E5 E1340,002 statements (20,000 nodes); quadratic without an index2 statements, then 1 per recalculation
C · PermissionsOne access function per user and per factE1 E2 E5 E11200,000 calls, 59M reads (40 users)3 statements, 0 calls, 14,000 reads

What the three have in common#

  1. Code review finds nothing. Each building block, looked at on its own, follows best practices.
  2. The unit test passes. One customer, one node, one user: a few milliseconds.
  3. The loop is somewhere other than in the query: in the orchestrator, in the function called per row, in the recursion launched per node.
  4. The cost is proportional to the number of iterations, and sometimes to their square, because each round rereads a structure that grows with the number of rounds.
  5. The fix is not an optimization. You do not make the loop faster: you remove it, by preparing what the loop was recomputing.

How the measurements were taken#

Four indicators, read after clearing the plan cache: the statements executed (sum of execution_count in sys.dm_exec_query_stats), the function calls (sys.dm_exec_function_stats), the logical reads and the CPU. The first three are reproducible; CPU and duration are only reproducible as orders of magnitude.

-- Measurement template (TEST instance only: DBCC FREEPROCCACHE clears the plan cache of the whole instance)
DBCC FREEPROCCACHE;
GO
DECLARE @t0 datetime2(7) = SYSDATETIME();

-- ... the workload to measure, in this same batch ...

SELECT DATEDIFF(MILLISECOND, @t0, SYSDATETIME())                                   AS duree_ms,
       (SELECT SUM(execution_count)          FROM sys.dm_exec_query_stats)          AS instructions,
       (SELECT ISNULL(SUM(execution_count),0) FROM sys.dm_exec_function_stats)      AS appels_fonctions,
       (SELECT SUM(total_logical_reads)      FROM sys.dm_exec_query_stats)          AS lectures_logiques,
       (SELECT SUM(total_worker_time) / 1000 FROM sys.dm_exec_query_stats)          AS cpu_ms;
Do not do this in production

DBCC FREEPROCCACHE clears the plan cache of the whole instance: only run it on a test instance that you control.