<?xml version="1.0" encoding="UTF-8"?><rss version="2.0"
	xmlns:content="http://purl.org/rss/1.0/modules/content/"
	xmlns:wfw="http://wellformedweb.org/CommentAPI/"
	xmlns:dc="http://purl.org/dc/elements/1.1/"
	xmlns:atom="http://www.w3.org/2005/Atom"
	xmlns:sy="http://purl.org/rss/1.0/modules/syndication/"
	xmlns:slash="http://purl.org/rss/1.0/modules/slash/"
	>

<channel>
	<title>Archives des SQL Server - dbi Blog</title>
	<atom:link href="https://www.dbi-services.com/blog/tag/sql-server/feed/" rel="self" type="application/rss+xml" />
	<link>https://www.dbi-services.com/blog/tag/sql-server/</link>
	<description></description>
	<lastBuildDate>Thu, 15 Oct 2026 12:11:31 +0000</lastBuildDate>
	<language>en-US</language>
	<sy:updatePeriod>
	hourly	</sy:updatePeriod>
	<sy:updateFrequency>
	1	</sy:updateFrequency>
	

<image>
	<url>https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2025/05/cropped-favicon_512x512px-min-32x32.png</url>
	<title>Archives des SQL Server - dbi Blog</title>
	<link>https://www.dbi-services.com/blog/tag/sql-server/</link>
	<width>32</width>
	<height>32</height>
</image> 
	<item>
		<title>SQL Server: Disabling FileTable/FileStream on an AlwaysOn high-availability instance</title>
		<link>https://www.dbi-services.com/blog/sql-server-disabling-filetable-filestream-on-an-alwayson-high-availability-instance/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-disabling-filetable-filestream-on-an-alwayson-high-availability-instance/#respond</comments>
		
		<dc:creator><![CDATA[Stéphane Haby]]></dc:creator>
		<pubDate>Thu, 15 Oct 2026 12:11:29 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[Database management]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[Microsoft]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47891</guid>

					<description><![CDATA[<p>Contexte A customer recently asked me to disable FileTable/FileStream on their instance because the databases no longer use it, and to ensure that this is indeed the case.The aim is to migrate to a new architecture without FileTable/FileStream in a second time. Link to FileTable and FileStream if you need to know a bit more [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-disabling-filetable-filestream-on-an-alwayson-high-availability-instance/">SQL Server: Disabling FileTable/FileStream on an AlwaysOn high-availability instance</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 id="h-contexte" class="wp-block-heading">Contexte</h2>



<p class="wp-block-paragraph">A customer recently asked me to <strong>disable FileTable/FileStream</strong> on their instance because the databases no longer use it, and to ensure that this is indeed the case.<br>The aim is to migrate to a new architecture without FileTable/FileStream in a second time.</p>



<p class="wp-block-paragraph">Link to<a href="https://learn.microsoft.com/en-us/sql/relational-databases/blob/filetables-sql-server?view=sql-server-ver17"> FileTable</a> and <a href="https://learn.microsoft.com/en-us/sql/relational-databases/blob/filestream-sql-server?view=sql-server-ver17">FileStream </a>if you need to know a bit more about these technologies</p>



<p class="wp-block-paragraph">The environment is in a high-availability (HA) configuration using AlwaysOn.</p>



<p class="wp-block-paragraph">As this isn’t something we do every day, here is a 9-step procedure to achieve this:</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Check that there are no more FileTables</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Disable non-transactional access</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Check that non-transactional access is disabled</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Check the columns in the tables</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Identify FILESTREAM filegroups &amp; check FILESTREAM files</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Delete FILESTREAM containers &amp; the FILESTREAM filegroup</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Disable FILESTREAM on the SQL Server instance</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Disable FILESTREAM on the server</p>



<p class="wp-block-paragraph">•&nbsp;&nbsp;&nbsp; Disable FILESTREAM on the other servers</p>



<p class="wp-block-paragraph">Let’s get started!</p>



<h2 id="h-step-1-checking-the-filetables" class="wp-block-heading">Step 1: Checking the FileTables</h2>



<p class="wp-block-paragraph">Let’s start with the first, fairly simple step, which is to check that there are no more FileTables </p>



<p class="wp-block-paragraph">Query used:</p>



<pre class="wp-block-code"><code>SELECT name,object_id,is_filetable FROM &lt;database&gt;.sys.tables WHERE is_filetable = 1;</code></pre>



<p class="wp-block-paragraph">Example:<br></p>



<figure class="wp-block-image size-full"><img fetchpriority="high" decoding="async" width="940" height="545" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-52.png" alt="" class="wp-image-47892" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-52.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-52-768x445.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-52-300x174.png 300w" sizes="(max-width: 940px) 100vw, 940px" /></figure>



<p class="wp-block-paragraph">In this example, it is clear that there is no longer a FileTable in the tables of the two databases checked here.</p>



<h2 id="h-step-2-disabling-non-transactional-access" class="wp-block-heading">Step 2: Disabling non-transactional access</h2>



<p class="wp-block-paragraph">To ensure that no further data is sent to FileTable/FileStream, we disable non-transactional access:</p>



<p class="wp-block-paragraph">Query used:</p>



<pre class="wp-block-code"><code>ALTER DATABASE &lt;database&gt; SET FILESTREAM ( NON_TRANSACTED_ACCESS = OFF );</code></pre>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img decoding="async" width="940" height="488" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-53.png" alt="" class="wp-image-47893" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-53.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-53-300x156.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-53-767x398.png 767w" sizes="(max-width: 940px) 100vw, 940px" /></figure>



<p class="wp-block-paragraph">The next step is, of course, to check carefully that it is disabled.</p>



<h2 id="h-step-3-check-that-non-transactional-access-is-disabled" class="wp-block-heading">Step 3: Check that non-transactional access is disabled</h2>



<p class="wp-block-paragraph">Query used:</p>



<pre class="wp-block-code"><code>SELECT db_name(database_id),* FROM sys.database_filestream_options;</code></pre>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img decoding="async" width="939" height="580" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-54.png" alt="" class="wp-image-47894" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-54.png 939w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-54-300x185.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-54-767x474.png 767w" sizes="(max-width: 939px) 100vw, 939px" /></figure>



<p class="wp-block-paragraph">As can be seen in the example, all databases are set to OFF<br></p>



<h2 id="h-step-4-checking-the-columns-in-the-tables" class="wp-block-heading">Step 4: Checking the columns in the tables</h2>



<p class="wp-block-paragraph">This is a very important step, as it is the one I missed in my example by the customer&#8230;</p>



<p class="wp-block-paragraph">In fact, in step 6, if there is still a column in a table that has a FileStream defined, it is not possible to delete the associated files and filegroups.</p>



<p class="wp-block-paragraph">Query used:</p>



<pre class="wp-block-code"><code>SELECT * FROM &lt;database&gt;.sys.columns WHERE is_filestream = 1;
select * from sys.objects where object_id= xxxx
</code></pre>



<p class="wp-block-paragraph">The first query returns the columns that contain FileStream data, and the second provides information about the table.</p>



<p class="wp-block-paragraph">You will then find a structure of the following type:<br>CREATE TABLE [schema].[table](<br> [ID] [uniqueidentifier] ROWGUIDCOL  NOT NULL,<br>&#8230;..<br>[FS] [varbinary](max) <strong>FILESTREAM  </strong>NULL<br>) ON [PRIMARY] TEXTIMAGE_ON [PRIMARY] <strong>FILESTREAM_ON</strong> [FileStreamFileGroup]</p>



<p class="wp-block-paragraph">We can therefore see that there is indeed a FileStream that needs to be removed at column level, but also at table level with the FILESTREAM_ON [FileStreamFileGroup].</p>



<p class="wp-block-paragraph">Initially, creating a new column with [FS2] [varbinary](max) NULL, copying the data and renaming it, seemed like a good idea but was not sufficient, as the table still had FILESTREAM_ON [FileStreamFileGroup].<br>To resolve this, you need to recreate a table, copy the data and add the keys, indexes and constraints.</p>



<p class="wp-block-paragraph">Then check everything again before proceeding to the next steps.</p>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="368" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-55.png" alt="" class="wp-image-47895" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-55.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-55-766x300.png 766w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-55-300x117.png 300w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<h2 id="h-step-5-identify-filestream-filegroups-amp-check-filestream-files" class="wp-block-heading">Step 5:  Identify FILESTREAM filegroups &amp; Check FILESTREAM files</h2>



<p class="wp-block-paragraph">In this step, the aim is to identify the FILESTREAM filegroups defined in the databases so that they can be deleted.<br><br></p>



<p class="wp-block-paragraph">Query used:</p>



<pre class="wp-block-code"><code>SELECT name, type_desc FROM &lt;database&gt;.sys.filegroups WHERE type = 'FD';
SELECT name,physical_name,type_desc FROM &lt;database&gt;.sys.database_files WHERE type_desc = 'FILESTREAM';
</code></pre>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="503" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-56.png" alt="" class="wp-image-47896" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-56.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-56-300x161.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-56-766x410.png 766w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<p class="wp-block-paragraph">As there are two databases in my example that have a FileStream, there are four lines.</p>



<h2 id="h-step-6-delete-the-filestream-containers-and-the-filestream-filegroup" class="wp-block-heading">Step 6: Delete the FILESTREAM containers and the FILESTREAM FILEGROUP</h2>



<p class="wp-block-paragraph">Once identified, delete them using the following command.<br>Query used:</p>



<pre class="wp-block-code"><code>ALTER DATABASE &#091;Database]  REMOVE FILE &#091;FileStreamFile]</code></pre>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="368" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-57.png" alt="" class="wp-image-47897" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-57.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-57-300x117.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-57-766x300.png 766w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<h2 id="h-step-7-disable-filestream-on-the-sql-server-instance" class="wp-block-heading">Step 7: Disable FILESTREAM on the SQL Server instance</h2>



<p class="wp-block-paragraph">At this point, we disable FILESTREAM at instance level.<br><br>Query used:</p>



<pre class="wp-block-code"><code>EXEC sp_configure 'filestream access level';
GO
EXEC sp_configure 'show advanced options', 1;
RECONFIGURE;
EXEC sp_configure 'filestream access level',0;
GO
RECONFIGURE;
GO
EXEC sp_configure 'show advanced options', 0;
RECONFIGURE;
GO
</code></pre>



<p class="wp-block-paragraph">Example:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="498" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-58.png" alt="" class="wp-image-47898" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-58.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-58-300x159.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-58-766x406.png 766w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<p class="wp-block-paragraph">As can be seen from the example, the `config_value` is indeed 0, but the `run_value` has changed to 1.</p>



<p class="wp-block-paragraph">In such cases, as always, we’ll check what the documentation says:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><tbody><tr><td><strong>Valeur</strong><strong></strong></td><td><strong>Signification</strong><strong></strong></td></tr><tr><td><strong>0</strong><strong></strong></td><td>FILESTREAM is disabled. No FILESTREAM access is permitted. FILESTREAM columns cannot be used.</td></tr><tr><td><strong>1</strong><strong></strong></td><td>T-SQL access only. SQL Server allows access to FILESTREAM data via Transact-SQL statements (INSERT, UPDATE, SELECT). Access via the Windows file system is disabled.</td></tr><tr><td><strong>2</strong><strong></strong></td><td>T-SQL access + Win32 streaming. FILESTREAM data can be accessed both via T-SQL and via the Windows APIs (direct file access using FILESTREAM sharing). This is generally the setting used to take advantage of FILESTREAM’s performance.</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">We are in T-SQL access only, it&#8217;s not really desactivated&#8230; Need a restart!</p>



<p class="wp-block-paragraph">At this stage, we could also carry this out on the secondary node(s) of our AlwaysOn high availability cluster, but it is preferable to do it first on a single node and verify that it works.</p>



<p class="wp-block-paragraph">Now it is time to disable Filestream on the SQL Server Engine service.<br><br></p>



<h2 id="h-step-8-disabling-filestream-on-the-server" class="wp-block-heading">Step 8: Disabling FileStream on the server</h2>



<p class="wp-block-paragraph">For this step, we have already performed a failover of the AAGs to a synchronous secondary node.</p>



<p class="wp-block-paragraph">We launch SQL Server Configuration Manager to disable FileStream in the Engine properties by unticking the box, as shown in the example above:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="536" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-60.png" alt="" class="wp-image-47901" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-60.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-60-766x437.png 766w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-60-300x171.png 300w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<p class="wp-block-paragraph">Then restart SQL Server and check that FileStream is disabled on the instance:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="940" height="536" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-59.png" alt="" class="wp-image-47900" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-59.png 940w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-59-300x171.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-59-766x437.png 766w" sizes="auto, (max-width: 940px) 100vw, 940px" /></figure>



<h2 id="h-step-9-disabling-filestream-on-the-other-servers" class="wp-block-heading">Step 9: Disabling FileStream on the other servers</h2>



<p class="wp-block-paragraph">At this point, you will just need to repeat steps 7 and 8 on the other nodes in your AlwaysOn high-availability cluster.</p>



<h2 id="h-conclusion" class="wp-block-heading">Conclusion</h2>



<p class="wp-block-paragraph">The steps are not complex in themselves, but where you can lose time is in the step 4 if you have to recreate a table without the FileStream and copy all data from this table. I was happy to have only one with not so much rows&#8230;</p>



<p class="wp-block-paragraph">All the steps up to the restart of the SQL Server Engine service in step 8 are carried out online without any service interruption or failover.</p>



<p class="wp-block-paragraph">I hope these steps will help anyone facing a similar issue! <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f609.png" alt="😉" class="wp-smiley" style="height: 1em; max-height: 1em;" /></p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-disabling-filetable-filestream-on-an-alwayson-high-availability-instance/">SQL Server: Disabling FileTable/FileStream on an AlwaysOn high-availability instance</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-disabling-filetable-filestream-on-an-alwayson-high-availability-instance/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Calling AI from SQL Server 2025 can lock everyone out of your on-prem instance</title>
		<link>https://www.dbi-services.com/blog/calling-ai-from-sql-server-2025-can-lock-everyone-out-of-your-on-prem-instance/</link>
					<comments>https://www.dbi-services.com/blog/calling-ai-from-sql-server-2025-can-lock-everyone-out-of-your-on-prem-instance/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Mon, 12 Oct 2026 07:22:20 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[AIDevelopment]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47798</guid>

					<description><![CDATA[<p>sp_invoke_external_rest_endpoint has no limit in SQL Server 2025: each waiting call holds a worker. Compared with Azure SQL.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/calling-ai-from-sql-server-2025-can-lock-everyone-out-of-your-on-prem-instance/">Calling AI from SQL Server 2025 can lock everyone out of your on-prem instance</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">With SQL Server 2025, Microsoft lets the database engine call an AI model by itself. <code><a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers">sp_invoke_external_rest_endpoint</a></code> sends an HTTPS request straight from T-SQL, to Azure OpenAI or to any other REST service, so an embedding or a completion no longer needs application code in between.</p>



<p class="wp-block-paragraph">But what happens under heavy pressure, when hundreds of sessions make that call at the same moment and the model takes its time to answer? I ran this scenario on SQL Server 2025 and on Azure SQL Database, with the same procedure and the same load script. The two platforms do not react the same way at all.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><tbody><tr><th>100 sessions calling a 30-second endpoint</th><th>SQL Server 2025</th><th>Azure SQL Database, 2 vCores</th><th>SQL Server 2025 with Resource Governor</th></tr><tr><td>Calls sent to the endpoint at once</td><td>100</td><td>22</td><td>22</td></tr><tr><td>What happens to the others</td><td>nothing holds them back</td><td>refused at once, error 10928</td><td>queued, all served after 150 s</td></tr><tr><td>Worker threads held</td><td>100</td><td>22</td><td>100</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The documentation of the procedure describes <a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers">a cap at 10% of the worker thread</a>s. That is not what I measured. Azure SQL Database does cap the calls in my tests, but at 15% of its workers: 22 calls at 2 vCores, 45 at 4 vCores, 30 on a Standard S3. SQL Server 2025 has no cap at all, and the rest of this post shows what happens without it.</p>



<h2 id="h-the-lab" class="wp-block-heading">The lab</h2>



<p class="wp-block-paragraph">The on-premises setup has three parts, all on one virtual machine (Windows Server 2022, 2 vCPUs): a load generator, a SQL Server 2025 Standard instance, and a small HTTPS endpoint that answers after the delay given in the URL.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="454" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-50-1024x454.png" alt="" class="wp-image-47808" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-50-1024x454.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-50-300x133.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-50-767x340.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-50.png 1344w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">The call is wrapped in a procedure, so that every session runs exactly the same thing:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE OR ALTER PROCEDURE dbo.CallSlowAi @delay int, @timeout smallint = 230
AS
BEGIN
    DECLARE @url nvarchar(400) = CONCAT(N&#039;https://&lt;YOUR_VM&gt;:8443/?delay=&#039;, @delay);
    DECLARE @ret int, @resp nvarchar(max);
    EXEC @ret = sp_invoke_external_rest_endpoint @url = @url, @method = &#039;POST&#039;,
         @payload = N&#039;{&quot;input&quot;:&quot;hello&quot;}&#039;, @timeout = @timeout, @response = @resp OUTPUT;
    RETURN @ret;
END
</pre></div>


<p class="wp-block-paragraph">The load generator is a PowerShell function that opens N connections without pooling, sends <code>EXEC dbo.CallSlowAi @delay = 30</code> on all of them at the same moment, and records for each session the elapsed time and the error number. One process, so the client itself does not slow the VM down.</p>



<p class="wp-block-paragraph">Before measuring SQL Server, I called the endpoint directly with 200 parallel requests: they all came back in 30.6 seconds. The endpoint is not the bottleneck.</p>



<h2 id="h-on-premises-no-limit-until-the-workers-run-out" class="wp-block-heading">On-premises: no limit, until the workers run out</h2>



<p class="wp-block-paragraph">A worker thread is what SQL Server uses to execute a request: one request running, at least one worker busy. The instance has a fixed pool of them, set by <code>max worker threads</code>; with the default setting on this 2-vCPU machine, that is 512. When they are all taken, new work waits with the <code>THREADPOOL</code> wait type, and that includes logging in, because a login needs a worker too (see <a href="https://www.dbi-services.com/blog/when-an-idle-transaction-starves-the-worker-pool-threadpool/" data-type="link" data-id="https://www.dbi-services.com/blog/when-an-idle-transaction-starves-the-worker-pool-threadpool/">my previous blog</a> about this wait type).</p>



<p class="wp-block-paragraph">Three words matter here, and their numbers should not be added together. A <strong>session </strong>is a <strong>connection</strong>. When it runs a <strong>request</strong>, the request is split into one or more <strong>tasks</strong>, and each task needs a <strong>worker </strong>thread to run. A simple call like ours is one task, so one worker; a parallel query would take several. The worker counts I quote also include SQL Server&#8217;s own background tasks: about 45 workers on this idle instance. A wait type, on the other hand, is counted per task, whoever owns it.</p>



<p class="wp-block-paragraph">A session waiting for the endpoint reports the HTTP_EXTERNAL_CONNECTION wait type and keeps its worker thread for the whole wait: every waiting call added one worker to that baseline.</p>



<p class="wp-block-paragraph">With 300 sessions, SQL Server sent everything at once. I counted 300 TCP connections from <code>sqlservr.exe</code> to port 8443, and all calls returned together:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: css; title: ; notranslate">
ErrorNumber : 0
Sessions    : 300
MinSec      : 30.2
MaxSec      : 34.5
</pre></div>


<p class="wp-block-paragraph">With 600 sessions, the instance has only 512 worker threads to give. 510 connections were open at the same time, and the calls finished in two groups:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: css; title: ; notranslate">
Finished after (s)   Sessions
30                   193
40                   316
50                     1
60                    90
</pre></div>


<p class="wp-block-paragraph">The first 509 calls left immediately, one per available worker. The last 91 did not wait for the endpoint: <strong>they waited for a worker</strong>. No error was raised at any point, and nothing held the calls back.</p>



<h3 id="h-what-the-rest-of-the-instance-sees" class="wp-block-heading">What the rest of the instance sees</h3>



<p class="wp-block-paragraph">I measured the saturation itself during an earlier run of the same 600 sessions, with HTTP/2 still enabled on the endpoint (see below). From the dedicated admin connection:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
active_workers  queued_tasks
556             154

-------------------------------------

wait_type                  n
HTTP_EXTERNAL_CONNECTION   513
THREADPOOL                 154
</pre></div>


<p class="wp-block-paragraph">At that moment an ordinary <code>sqlcmd</code> connection failed:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Sqlcmd: Error: Microsoft ODBC Driver 17 for SQL Server : Login timeout expired.
Sqlcmd: Error: Microsoft ODBC Driver 17 for SQL Server : Unable to complete login process due to delay in prelogin response.
</pre></div>


<p class="wp-block-paragraph">The admin connection kept answering, and the instance recovered by itself once the calls completed. <code>KILL</code> also works at once on a waiting session (error 596 on the client side).</p>



<h3 id="h-http-2-changes-the-shape-not-the-outcome" class="wp-block-heading">HTTP/2 changes the shape, not the outcome</h3>



<p class="wp-block-paragraph">HTTP.sys, which hosts my test endpoint, <a href="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/httpsys?view=aspnetcore-10.0" data-type="link" data-id="https://learn.microsoft.com/en-us/aspnet/core/fundamentals/servers/httpsys?view=aspnetcore-10.0">offers HTTP/2 by default</a>. In that mode SQL Server opened 6 TCP connections instead of one per call, and only 100 calls were in flight per host name. The same 300 sessions then finished in three waves:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Finished after (s)   Sessions
30                   100
60                   100
90                   100
</pre></div>


<p class="wp-block-paragraph">The queued sessions show the same <code>HTTP_EXTERNAL_CONNECTION</code> wait and hold a worker while they queue, so the pool empties just as well: the 556 workers above were measured in this mode. The queue has a second effect. <code>@timeout</code> keeps running while a call waits its turn. With a 30-second endpoint and <code>@timeout = 45</code>, the first 100 calls succeeded and the 100 queued ones failed after 45.5 seconds with error 31629, <em>The external rest endpoint execution exceeded the timeout value</em>, although the endpoint was answering normally.</p>



<p class="wp-block-paragraph">Turning HTTP/2 off on the endpoint removes that queue. I disabled it in HTTP.sys (<code>EnableHttp2Tls</code> and <code>EnableHttp2Cleartext</code> set to 0, then a reboot) and replayed the same load:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>300 sessions, 30-second endpoint</th><th>HTTP/2</th><th>HTTP/1.1</th></tr></thead><tbody><tr><td>TCP connections opened by SQL Server</td><td>6</td><td>300</td></tr><tr><td>Calls in flight at once</td><td>100</td><td>300</td></tr><tr><td>Last call returns after</td><td>90 s</td><td>34.5 s</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The waves are gone and so are the timeouts caused by the queue. It is not a fix for the instance, though. Each call still holds a worker, the 600-session run at the top of this section was made in HTTP/1.1, and the endpoint now receives every call at once. With a real AI provider you also do not choose the protocol: the server does.</p>



<p class="wp-block-paragraph">I did not capture the HTTP/2 negotiation, so I cannot say which side sets the figure of 100. What the two modes show together is that SQL Server applies no cap of its own.</p>



<h2 id="h-but-the-documentation-says-10" class="wp-block-heading">But the documentation says 10%?</h2>



<p class="wp-block-paragraph">The page of the procedure is marked as applying to SQL Server 2025, Azure SQL Database, Azure SQL Managed Instance and SQL database in Microsoft Fabric. Its <a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers#throttling" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers#throttling">throttling section</a> reads:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph"><em>The number of concurrent connections to external endpoints done via <code>sp_invoke_external_rest_endpoint</code> are capped to 10% of worker threads, with a maximum of 150 workers.</em></p>
</blockquote>



<p class="wp-block-paragraph">It adds that a call made beyond the limit raises error 10928. With 512 worker threads, I expected about 51 calls to pass and the others to fail. I got neither the cap nor the error, on RTM as on CU9.</p>



<p class="wp-block-paragraph">The next sentence of that section gives a hint: it speaks of a <em>single database</em> and of an <em>elastic pool</em>, two notions that only exist in Azure SQL Database. So I ran the same test there.</p>



<h2 id="h-azure-sql-database-22-calls-then-an-error" class="wp-block-heading">Azure SQL Database: 22 calls, then an error</h2>



<p class="wp-block-paragraph">Azure SQL Database only calls endpoints from a <a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers#allowed-endpoints" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/system-stored-procedures/sp-invoke-external-rest-endpoint-transact-sql?view=sql-server-ver17&amp;tabs=request-headers#allowed-endpoints">closed list</a> of Azure services, so the slow endpoint becomes an Azure Function. The database is a single Azure SQL Database in Switzerland North. I started on the vCore model, General Purpose serverless on standard-series (Gen5) hardware with 2 vCores maximum, then scaled the same database to 4 vCores, and finally to Standard S3 in the DTU model. The function runs on the Consumption plan, Node.js 22, in the same region. The procedure is the same, with the URL of the function, and the load script only changes its connection string.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="520" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-46-1024x520.png" alt="" class="wp-image-47800" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-46-1024x520.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-46-300x152.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-46-767x389.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-46.png 1344w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Before any load, the governance view of the database already shows a dedicated limit:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT primary_group_max_workers, primary_group_max_outbound_connection_workers
FROM sys.dm_user_db_resource_governance;
</pre></div>

<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
primary_group_max_workers  primary_group_max_outbound_connection_workers
150                        22
</pre></div>


<p class="wp-block-paragraph">I stated my bet before running the load: 22 successes, 78 errors. Result for 100 sessions calling a 30-second endpoint:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: css; title: ; notranslate">
ErrorNumber  Sessions  Finished after (s)
0            22        30
10928        78        0
</pre></div>

<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Resource ID : 4. The outbound connection worker limit for the database is 22 and has been reached.
</pre></div>


<p class="wp-block-paragraph">The 78 rejected sessions got their answer immediately, which leaves the database 128 of its 150 workers for everything else, whatever the endpoint does.</p>



<p class="wp-block-paragraph">Two remarks on this test. The ratio is 15%, not <a href="https://learn.microsoft.com/en-us/azure/azure-sql/database/resource-limits-logical-server?view=azuresql#external-connections" data-type="link" data-id="https://learn.microsoft.com/en-us/azure/azure-sql/database/resource-limits-logical-server?view=azuresql#external-connections">the documented 10%</a>. At 2 vCores the database has 150 workers and a limit of 22. I scaled it to 4 vCores: 300 workers, a limit of 45, and the same load gave 45 successes and 55 errors. In the <strong>DTU model</strong>, a Standard S3 shows 200 workers and a limit of 30. This is one database, in one region, read on three service objectives and load-tested on two. The ratio could differ on other tiers, hardware generations or Hyperscale, and Microsoft may change it: treat 15% as what I observed, not as a rule. On the endpoint side I needed to do a small adjustment as well: an Azure Function on the Consumption plan served only 100 concurrent requests per instance until I raised <code>maxConcurrentRequests</code> in <code>host.json</code>. A direct test of 200 parallel requests took 62.8 seconds before the change and 30.4 after.</p>



<h2 id="h-the-same-limit-on-premises-with-resource-governor" class="wp-block-heading">The same limit on-premises, with Resource Governor</h2>



<p class="wp-block-paragraph">SQL Server 2025 exposes nothing comparable. On CU9, <code>sys.dm_user_db_resource_governance</code> does not exist, no system column deals with outbound connections, and the only related configuration option is the on/off switch <code>external rest endpoint enabled</code>.</p>



<p class="wp-block-paragraph">What SQL Server does have is Resource Governor, and a workload group can limit its number of concurrent requests. I created a group with the Azure value and sent the sessions of the load generator into it through their application name:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
USE master;
GO
CREATE WORKLOAD GROUP AiCalls WITH (GROUP_MAX_REQUESTS = 22) USING &#x5B;default];
GO
CREATE FUNCTION dbo.RgClassifier() RETURNS sysname WITH SCHEMABINDING
AS
BEGIN
    RETURN CASE WHEN APP_NAME() = N&#039;AiLoad&#039; THEN N&#039;AiCalls&#039; ELSE N&#039;default&#039; END;
END
GO
ALTER RESOURCE GOVERNOR WITH (CLASSIFIER_FUNCTION = dbo.RgClassifier);
ALTER RESOURCE GOVERNOR RECONFIGURE;
</pre></div>


<p class="wp-block-paragraph">Same load as on Azure, 100 sessions calling a 30-second endpoint:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: css; title: ; notranslate">
ErrorNumber  Sessions
0            100

Finished after (s)   Sessions
30                   22
60                   22
90                   22
120                  22
150                  12
</pre></div>


<p class="wp-block-paragraph">The limit is applied to the unit: never more than 22 calls reach the endpoint. But there is no error. Every session ends up being served, and the last ones wait two minutes for it.</p>



<h2 id="h-where-resource-governor-stops" class="wp-block-heading">Where Resource Governor stops</h2>



<p class="wp-block-paragraph">During the second wave of that test, the group, the waits and the workers looked like this:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="660" height="817" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-48.png" alt="" class="wp-image-47802" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-48.png 660w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-48-242x300.png 242w" sizes="auto, (max-width: 660px) 100vw, 660px" /></figure>



<p class="wp-block-paragraph">The 56 queued sessions wait on <code>RESMGR_THROTTLED</code>, and each of them has a worker. The instance counts 123 active workers against 45 at rest: 78 more, exactly the 22 calls in flight plus the 56 sessions in the queue.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="443" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-47-1024x443.png" alt="" class="wp-image-47801" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-47-1024x443.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-47-767x332.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-47-300x130.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-47.png 1344w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<figure class="wp-block-table"><table class="has-fixed-layout"><tbody><tr><th>Beyond the 22nd call</th><th>Azure SQL Database</th><th>SQL Server 2025 with Resource Governor</th></tr><tr><td>What the session gets</td><td>error 10928, immediately</td><td>a place in a queue</td></tr><tr><td>Worker of that session</td><td>released</td><td>held until its turn comes and the call returns</td></tr><tr><td>Calls reaching the endpoint</td><td>22</td><td>22</td></tr><tr><td>Worker pool</td><td>protected</td><td>not protected</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Resource Governor protects the service being called. It does not protect the instance. With 600 sessions classified in that group, 22 calls would be in flight and the 578 others would still be queued, each holding a worker, against a pool of 512. I did not run that last case; it follows from the two measurements above.</p>



<h2 id="h-what-to-take-from-this" class="wp-block-heading">What to take from this</h2>



<p class="wp-block-paragraph">On SQL Server 2025, every session waiting for an external endpoint holds a worker thread, and the engine sets no limit on how many can wait at once. Azure SQL Database does have such a cap, at 15% of its workers <strong>in my tests</strong> rather than the documented 10%, and a call beyond it is refused immediately. On-premises, a slow AI service plus a concurrent application is enough to exhaust the worker pool and to keep new connections out, with no blocking chain to look for and an error message, when there is one, that points at the endpoint.</p>



<p class="wp-block-paragraph">The difference is also one of scope. In Azure SQL Database, the limit belongs to the <strong>database</strong>: a flood of slow calls is refused at the door of that database, and the 128 workers left keep it running. In SQL Server, the worker pool belongs to the instance. Every database on it <strong>shares the same 512 workers</strong>, so one application calling a slow AI service can lock out the users of all the others.</p>



<p class="wp-block-paragraph">Resource Governor can cap the number of calls sent to the service, in Standard Edition too, but it queues the surplus instead of refusing it, and a queued session costs a worker. If you want the Azure behavior, the refusal has to come from the code that makes the call, or from the application in front of it. Make sure you can reach the dedicated admin connection before you need it: it works locally out of the box (<code>sqlcmd -A</code>), but from another machine it requires the <code>remote admin connections</code> option. It was the only way in during the saturation, and <code>KILL</code> on the waiting sessions frees their workers at once.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/calling-ai-from-sql-server-2025-can-lock-everyone-out-of-your-on-prem-instance/">Calling AI from SQL Server 2025 can lock everyone out of your on-prem instance</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/calling-ai-from-sql-server-2025-can-lock-everyone-out-of-your-on-prem-instance/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Archiving 5 TB from Oracle into SQL Server: views, indexes and compression (part 2)</title>
		<link>https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-views-indexes-and-compression-part-2/</link>
					<comments>https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-views-indexes-and-compression-part-2/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Wed, 07 Oct 2026 06:20:08 +0000</pubDate>
				<category><![CDATA[Database management]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[migration]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47235</guid>

					<description><![CDATA[<p>Translating 4,000 Oracle views, rebuilding indexes offline on Standard Edition, and compressing clustered index to save 1T.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-views-indexes-and-compression-part-2/">Archiving 5 TB from Oracle into SQL Server: views, indexes and compression (part 2)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">At the end of&nbsp;<a href="https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-a-migration-logbook-part-1/">part 1</a>&nbsp;the data was across, at 42,170 rows per second into a database that could not answer a single question. Every nonclustered index disabled, the largest tables loaded as heaps with no primary key, every foreign key switched off, and 4,784 views that mostly did not compile.</p>



<p class="wp-block-paragraph">This part is about putting it back: <strong>translating the views</strong>, <strong>rebuilding the keys and indexes under a Standard Edition licence</strong> that makes every build offline, <strong>and compressing 3.2 TB of heaps</strong> that turned out to be mostly empty space.</p>



<p class="wp-block-paragraph">The logbook resumes at day 5.</p>



<h2 id="h-day-5-four-thousand-views-to-convert" class="wp-block-heading">Day 5. Four thousand views to convert</h2>



<p class="wp-block-paragraph">4,658 of the 4,784 views live in a single schema, and nobody needed all of them. We went through the list with the client first and kept the ones that actually matter to the business, which brought the scope down to about 710. SSMA converted around 600 of those on its own.</p>



<p class="wp-block-paragraph">SSMA fails mostly on <code>CONNECT BY</code>, <code>START WITH</code> and <code>SYS_CONNECT_BY_PATH</code>. For some easy cases, <code>DECODE</code> to <code>CASE</code> and <code>NVL</code> handles it well, for the record. For more complex use case, we needed to find an automatic solution process. So first, we discussed with the client to know which view were critical and needed to be migrated. Then, with this list, we leveraged the power of AI to help us converting the remaining views based on the following workflow:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="786" height="1024" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-45-786x1024.png" alt="" class="wp-image-47241" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-45-786x1024.png 786w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-45-230x300.png 230w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-45-768x1001.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-45.png 1120w" sizes="auto, (max-width: 786px) 100vw, 786px" /></figure>



<h3 id="h-the-workflow" class="wp-block-heading">The workflow</h3>



<p class="wp-block-paragraph">SSMA converts what it can. The failures get extracted from <code>dba_views </code>into files. Then, and this is the step that changes the economics, they get<strong> classified by pattern in the Oracle source</strong> rather than one at a time by error message.</p>



<p class="wp-block-paragraph">Translation itself was done in batches of one family at a time, outside production, on extracted files, <strong>with an AI assistant</strong> and a context file describing the type mapping decisions, the column types of the referenced tables and the target version. Then human review, tests against Oracle and finally, deployment.</p>



<p class="wp-block-paragraph">The catalogue of families is the real deliverable. Eight of them closed the great majority of the cases:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Family</th><th>Verdict</th></tr></thead><tbody><tr><td><code>+</code> concatenation on numerics (<code>col + '_' + col</code>)</td><td><code>CONCAT</code></td></tr><tr><td>Untyped NULL in a UNION, cast to <code>numeric(38,10)</code> against a <code>datetime2</code> sibling</td><td>retype the cast</td></tr><tr><td>Call to a package function that was never migrated</td><td>inline it if possible</td></tr><tr><td><code>ssma_oracle.*</code> helper functions</td><td>install the extension pack</td></tr><tr><td>XML extraction (<code>.extract().getStringVal()</code>)</td><td>rewrite part of the query</td></tr><tr><td><code>TO_TIMESTAMP_TZ</code></td><td>rewrite part of the query</td></tr><tr><td>Date arithmetic landing in float</td><td>redefine type mapping</td></tr><tr><td><code>CONNECT BY</code> used as a generator</td><td>materialise the level connectors</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong><span style="text-decoration: underline">And a word on the AI</span></strong>, since that is the part people ask about. <strong>No data ever left the environment</strong>: only the view definitions were processed, and every test against the live database was run by hand so we kept control of the process. We could have added MCP connectors and let a local model run the tests itself, but with the number of days allocated to the project and the small number of views left to fix, doing it manually was simpler. Around 85 percent of the views <strong>wanted by the client</strong> had already come through SSMA.</p>



<h2 id="h-day-6-putting-the-indexes-back-and-making-the-database-smaller" class="wp-block-heading">Day 6. Putting the indexes back and making the database smaller</h2>



<h3 id="h-recovering-what-i-had-thrown-away" class="wp-block-heading">Recovering what I had thrown away</h3>



<p class="wp-block-paragraph">The primary keys I dropped to load into heaps had left no trace on the SQL Server side. Nothing in <code>sys.indexes</code>, no disabled definition, nothing.</p>



<h3 id="h-what-standard-edition-costs" class="wp-block-heading">What Standard Edition costs</h3>



<p class="wp-block-paragraph">Index builds are offline and single-threaded. The table is locked for the duration, metadata reads queue behind it, but we managed to run 4 builds in parallel. <code>SORT_IN_TEMPDB = OFF</code> for the clustered builds. The sort covers the entire table, and 36 GB of tempdb was never going to hold a 900 GB sort.</p>



<p class="wp-block-paragraph">Meanwhile <code>percent_complete</code> sits at 0 for the whole build, with the serene confidence of something that has never been asked to account for itself. It works for <code>BACKUP DATABASE</code>. It does not work here. Real progress lives in <code><a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-objects/sys-dm-exec-query-profiles-transact-sql?view=sql-server-ver17">sys.dm_exec_query_profile</a>s</code>, operator by operator :</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET TRANSACTION ISOLATION LEVEL READ UNCOMMITTED;
SELECT physical_operator_name, row_count, estimate_row_count,
       CAST(row_count*100.0/NULLIF(estimate_row_count,0) AS decimal(5,1)) AS pct
FROM sys.dm_exec_query_profiles WITH (NOLOCK)
WHERE session_id = &lt;spid of the build&gt; 
ORDER BY node_id;

</pre></div>

<div class="wp-block-image">
<figure class="aligncenter size-full"><img loading="lazy" decoding="async" width="399" height="87" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-46.png" alt="" class="wp-image-47252" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-46.png 399w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-46-300x65.png 300w" sizes="auto, (max-width: 399px) 100vw, 399px" /></figure>
</div>


<p class="wp-block-paragraph">Those are not three parallel counters. They are three sequential phases of one pipeline, and only one of them moves at a time. The scan reads the source table, and while it runs its percentage is the only honest progress figure available. The sort is a blocking operator: it cannot emit a single row until it has consumed all 31.9 million, so it stays at zero through the whole scan and then through the whole sort. That is the blind spot. Once the scan reaches 100 percent you get a long stretch where all three counters report nothing while the server works flat out, and if the sort spills to disk that stretch can outlast the other two phases combined. Index Insert starts moving only when the sort begins handing rows over, and it is the first number that means the end is actually in sight.</p>



<h3 id="h-why-compress-and-what-it-returned" class="wp-block-heading">Why compress, and what it returned</h3>



<p class="wp-block-paragraph">On a read-only database, PAGE compression has no recurring write cost. You keep the read benefit and pay nothing back. The argument is rarely this clean.</p>



<p class="wp-block-paragraph">Compression trades CPU for I/O, and on an archive that trade is one-sided. These pages are read rarely, and when they are, nothing heavy is competing for the processor, so the decompression cost lands on a machine with nothing better to do. What you get back is density: more rows per page, which means fewer reads to find the one row somebody asked for and more of the table sitting in memory at once.</p>



<p class="wp-block-paragraph">The data was unusually well suited to it, for a reason worth spelling out. These tables carry roughly 200 numeric columns, mostly null, plus five <code>nvarchar(200)</code> aggregation labels that repeat endlessly. Here is the part that surprises people: <strong>a fixed-length numeric column that is NULL still occupies its full width</strong> in SQL Server. “Mostly null” saves nothing before compression. It is precisely why the heap was that large, and precisely what the page dictionary destroys.</p>



<p class="wp-block-paragraph">The allocated space per row tells the story:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Table</th><th>Rows</th><th>GB (heap)</th><th>bytes allocated per row</th></tr></thead><tbody><tr><td>TABLE1</td><td>251,777,518</td><td>960.5</td><td>~4,100</td></tr><tr><td>TABLE2</td><td>229,881,717</td><td>876.9</td><td>~4,100</td></tr><tr><td>TABLE3</td><td>343,997,550</td><td>874.8</td><td>~2,700</td></tr><tr><td>TABLE4</td><td>447,972,849</td><td>227.9</td><td>~550</td></tr><tr><td>TABLE5</td><td>519,727,931</td><td>120.2</td><td>~250</td></tr><tr><td>TABLE6</td><td>505,819,544</td><td>117.0</td><td>~250</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>Three tables hold 2.7 TB of the 3.2 TB total</strong>. At around 4 KB of allocated space per row against an 8 KB page, the top two fit around two rows per page and waste most of what is left.</p>



<p class="wp-block-paragraph">Then the first build, on 505,819,544 rows with a five-column primary key:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER TABLE &lt;TABLE6&gt;
ADD CONSTRAINT PK_TABLE6 PRIMARY KEY CLUSTERED
    (&lt;COLUMNS_NAME&gt;)
WITH (DATA_COMPRESSION = PAGE, SORT_IN_TEMPDB = OFF);

</pre></div>


<p class="wp-block-paragraph"><strong>117 GB to 30.7 GB. A ratio of 3.81.</strong></p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="721" height="121" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-47.png" alt="" class="wp-image-47258" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-47.png 721w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-47-300x50.png 300w" sizes="auto, (max-width: 721px) 100vw, 721px" /></figure>



<p class="wp-block-paragraph">Finally, as you can see above, more than 2.5 T have been reclaimed (datafiles were full with the heaps). Compression has made huge benefits and that means less data on disks, which means fewer pages to take in the buffer (less IO).</p>



<h3 id="h-the-script-that-puts-everything-back" class="wp-block-heading">The script that puts everything back</h3>



<p class="wp-block-paragraph">Across every schema that is two cursors, one rebuilding the disabled indexes with compression and one re-enabling the foreign keys. It is driven by the same&nbsp;<code>#Schemas</code>&nbsp;table as the disable script in part 1:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET NOCOUNT ON;
DECLARE @Execute bit = 0;   -- 0 = preview ; 1 = execute
  
DECLARE @cmd nvarchar(max);
DECLARE @n   int = 0;
  
DECLARE reb_cur CURSOR LOCAL FAST_FORWARD FOR
    SELECT N&#039;ALTER INDEX &#039; + QUOTENAME(i.name)
         + N&#039; ON &#039; + QUOTENAME(s.name) + N&#039;.&#039; + QUOTENAME(t.name)
         + N&#039; REBUILD WITH (DATA_COMPRESSION = PAGE);&#039;
    FROM   sys.indexes  i
    JOIN   sys.tables   t ON i.object_id = t.object_id
    JOIN   sys.schemas  s ON t.schema_id = s.schema_id
    WHERE  s.name IN (SELECT schema_name FROM #Schemas)
      AND  i.is_disabled = 1          
    ORDER BY s.name, t.name, i.name;
  
OPEN reb_cur;
FETCH NEXT FROM reb_cur INTO @cmd;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @n += 1;
    PRINT @cmd;
    IF @Execute = 1
    BEGIN
        BEGIN TRY EXEC sys.sp_executesql @cmd; END TRY
        BEGIN CATCH PRINT N&#039;   -- FAILED: &#039; + ERROR_MESSAGE(); END CATCH
    END
    FETCH NEXT FROM reb_cur INTO @cmd;
END
CLOSE reb_cur;
DEALLOCATE reb_cur;
  
PRINT N&#039;---&#039;;
PRINT CONVERT(nvarchar(10), @n) + N&#039; index &#039;
    + CASE WHEN @Execute = 1 THEN N&#039;rebuilt.&#039; ELSE N&#039;to be rebuilt (preview).&#039; END;
GO
  
  
SET NOCOUNT ON;
DECLARE @Execute bit = 1;   -- 0 = preview ; 1 = executer
  
DECLARE @cmd nvarchar(max);
DECLARE @n   int = 0;
  
DECLARE fkc_cur CURSOR LOCAL FAST_FORWARD FOR
    SELECT N&#039;ALTER TABLE &#039; + QUOTENAME(s.name) + N&#039;.&#039; + QUOTENAME(t.name)
         + N&#039; WITH NOCHECK CHECK CONSTRAINT &#039; + QUOTENAME(fk.name) + N&#039;;&#039;
    FROM   sys.foreign_keys fk
    JOIN   sys.tables  t ON fk.parent_object_id = t.object_id
    JOIN   sys.schemas s ON t.schema_id = s.schema_id
    WHERE  s.name IN (SELECT schema_name FROM #Schemas);
  
OPEN fkc_cur;
FETCH NEXT FROM fkc_cur INTO @cmd;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @n += 1;
    PRINT @cmd;
    IF @Execute = 1
    BEGIN
        BEGIN TRY EXEC sys.sp_executesql @cmd; END TRY
        BEGIN CATCH PRINT N&#039;   -- FAILED : &#039; + ERROR_MESSAGE(); END CATCH
    END
    FETCH NEXT FROM fkc_cur INTO @cmd;
END
CLOSE fkc_cur;
DEALLOCATE fkc_cur;
  
PRINT N&#039;---&#039;;
PRINT CONVERT(nvarchar(10), @n) + N&#039; FK &#039;
    + CASE WHEN @Execute = 1 THEN N&#039;activated and validated.&#039; ELSE N&#039;to be activated (preview).&#039; END;
GO

</pre></div>


<h3 id="h-the-foreign-keys-which-turned-out-to-be-free" class="wp-block-heading">The foreign keys, which turned out to be free</h3>



<p class="wp-block-paragraph">All 6,072 foreign keys were already back, active and marked not trusted, because the loading script re-enabled them with <code>NOCHECK</code>. Revalidating them with <code>WITH CHECK</code> would mean full joins against the largest tables in the database.</p>



<p class="wp-block-paragraph">On a read-only archive whose source integrity <strong>was enforced by Oracle</strong> for fifteen years, that buys one thing: the optimizer regains the right to eliminate redundant joins. Worth having on a schema of deeply nested views, not worth days of scanning here. They stayed not trusted.</p>



<h2 id="h-day-7-testing-600-views" class="wp-block-heading">Day 7. Testing 600 views</h2>



<p class="wp-block-paragraph">The principle is simple: run every view, record what happened, and never lose the record. Each one gets a <code>SELECT TOP (100) *</code> inside a <code>TRY/CATCH</code>, so a view that fails is logged rather than fatal, and the outcome lands in a table with its duration in milliseconds. The cursor only picks up views that are not already recorded, which means an interrupted run resumes where it stopped instead of starting over, and the duration column is what you sort on once it is done:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET NOCOUNT ON;
 
IF OBJECT_ID(&#039;dbo.ViewTestResults&#039;) IS NULL
    CREATE TABLE dbo.ViewTestResults (
        schema_name  sysname,
        view_name    sysname,
        statut       varchar(20),
        duree_ms     int,
        message      nvarchar(2048),
        tested_at    datetime2 DEFAULT SYSDATETIME(),
        CONSTRAINT PK_ViewTestResults PRIMARY KEY (schema_name, view_name)
    );
 
DECLARE @sch sysname, @vue sysname, @sql nvarchar(max), @t0 datetime2, @ms int, @i int = 0, @n int;
 
SELECT @n = COUNT(*)
FROM   sys.views v JOIN sys.schemas s ON v.schema_id = s.schema_id
WHERE  s.name = &#039;SCDAT&#039;
  AND  NOT EXISTS (SELECT 1 FROM dbo.ViewTestResults r
                   WHERE r.schema_name = s.name AND r.view_name = v.name);
 
RAISERROR(&#039;=== %d view remaining ===&#039;, 0, 1, @n) WITH NOWAIT;
 
DECLARE c CURSOR FAST_FORWARD FOR
  SELECT s.name, v.name
  FROM   sys.views v JOIN sys.schemas s ON v.schema_id = s.schema_id
  WHERE  s.name = &#039;SCDAT&#039;
    AND  NOT EXISTS (SELECT 1 FROM dbo.ViewTestResults r
                     WHERE r.schema_name = s.name AND r.view_name = v.name)
  ORDER  BY v.name;
 
OPEN c;
FETCH NEXT FROM c INTO @sch, @vue;
WHILE @@FETCH_STATUS = 0
BEGIN
    SET @i += 1;
    RAISERROR(&#039;&#x5B;%d/%d] %s.%s&#039;, 0, 1, @i, @n, @sch, @vue) WITH NOWAIT;
 
    SET @t0 = SYSDATETIME();
    BEGIN TRY
        SET @sql = N&#039;SELECT TOP (100) * FROM &#039; + QUOTENAME(@sch) + N&#039;.&#039; + QUOTENAME(@vue) + N&#039;;&#039;;
        EXEC sp_executesql @sql;
        SET @ms = DATEDIFF(MILLISECOND, @t0, SYSDATETIME());
        INSERT dbo.ViewTestResults (schema_name, view_name, statut, duree_ms, message)
        VALUES (@sch, @vue, &#039;OK&#039;, @ms, NULL);
    END TRY
    BEGIN CATCH
        SET @ms = DATEDIFF(MILLISECOND, @t0, SYSDATETIME());
        INSERT dbo.ViewTestResults (schema_name, view_name, statut, duree_ms, message)
        VALUES (@sch, @vue, &#039;FAILED&#039;, @ms, ERROR_MESSAGE());
        RAISERROR(&#039;    -&gt; FAILED: %s&#039;, 0, 1, @vue) WITH NOWAIT;
    END CATCH
 
    FETCH NEXT FROM c INTO @sch, @vue;
END
CLOSE c; DEALLOCATE c;
 
RAISERROR(&#039;=== Done ===&#039;, 0, 1) WITH NOWAIT;
 
-- summary
SELECT statut, COUNT(*) AS nb, SUM(duree_ms) AS duree_totale_ms
FROM dbo.ViewTestResults GROUP BY statut;
 
-- failed
SELECT schema_name, view_name, duree_ms, message
FROM dbo.ViewTestResults WHERE statut=&#039;FAILED&#039; ORDER BY view_name;
 
-- all
SELECT schema_name, view_name, statut, duree_ms
FROM dbo.ViewTestResults ORDER BY duree_ms DESC;

</pre></div>


<p class="wp-block-paragraph">The result looked excellent: <strong>604 views OK, zero errors</strong>, against real data. Six skipped because they are not implemented to be run without an explicit filter added. <code>SELECT TOP 100 *</code> without a filter <strong>is not a representative test</strong> but still confirms that definition and joins are working fine. So: test with the filter the users will actually write. A view that only misbehaves when queried in a way nobody queries it is not a broken view, but you cannot know that from a green tick.</p>



<h2 id="h-day-8-closing-the-hatch" class="wp-block-heading">Day 8. Closing the hatch</h2>



<p class="wp-block-paragraph">Database set to read-only, final backup taken and user&#8217;s specific queries tested with success, the story now comes to a happy end.</p>



<h2 id="h-what-i-would-tell-the-next-person" class="wp-block-heading">What I would tell the next person</h2>



<p class="wp-block-paragraph">An archive migration is not a cheap application migration. It is a different decision set, and read-only is the input that drives most of it. 25,000 triggers are pointless, PAGE compression free, and untrusted foreign keys perfectly acceptable. It even tells you how to index: broadly and in advance, because nobody can tell you which row an auditor will want in three years. Before anything else, ask what the target will be used for. The answer rewrites the plan before the first byte moves.</p>



<p class="wp-block-paragraph">Measure, and distrust anything that presents itself as progress. The SSMA counter refreshes per batch and sits still for minutes at a time. <code>percent_complete </code>stays at zero for an entire index build. A green tick on<code> SELECT TOP 100</code> confirms that the joins resolve and nothing beyond that. In every one of those cases the real number was sitting in a DMV.</p>



<p class="wp-block-paragraph">On views, the conversion rate is the least useful number in the report. What made four thousand views a finite piece of work was not the translation but the two steps around it: agreeing the real scope with the client first, then classifying what remained by pattern in the Oracle source rather than one error message at a time. <strong>An AI assistant</strong> translates a family of twenty views very well. It validates nothing, and the test against the source stays yours.</p>



<p class="wp-block-paragraph">Compression on an archive is the rare decision with nothing to weigh against it. 117 GB down to 30.7 GB on the first table, <strong>more than 2.5 TB</strong> reclaimed across the set, and not a single write to pay it back with. If your heap is mostly null numeric columns, it is bigger than you think, and it will compress better than you expect.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-views-indexes-and-compression-part-2/">Archiving 5 TB from Oracle into SQL Server: views, indexes and compression (part 2)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/archiving-5-tb-from-oracle-into-sql-server-views-indexes-and-compression-part-2/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server 2025 CU5: max lock manager cache memory (%): what I could reproduce and what I could not</title>
		<link>https://www.dbi-services.com/blog/sql-server-2025-cu5-max-lock-manager-cache-memory-what-i-could-reproduce-and-what-i-could-not/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-2025-cu5-max-lock-manager-cache-memory-what-i-could-reproduce-and-what-i-could-not/#respond</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Mon, 05 Oct 2026 22:07:01 +0000</pubDate>
				<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[SQL Server]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=47676</guid>

					<description><![CDATA[<p>Cumulative Update 5 for SQL Server 2025 was released on May 20, 2026 (build 17.0.4045.5). Seven entries in the KB, mostly fixes. One of them is not a fix: it is a new server configuration option, max lock manager cache memory (%). Reference: https://support.microsoft.com/en-us/servicing/sql/sql-server-2025/cumulative-update/kb5084896-cu5 I built a lab on CU5 and on later builds up [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-2025-cu5-max-lock-manager-cache-memory-what-i-could-reproduce-and-what-i-could-not/">SQL Server 2025 CU5: max lock manager cache memory (%): what I could reproduce and what I could not</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">Cumulative Update 5 for SQL Server 2025 was released on May 20, 2026 (build 17.0.4045.5). Seven entries in the KB, mostly fixes. One of them is not a fix: it is a new server configuration option, max lock manager cache memory (%).</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="136" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-31-1024x136.png" alt="" class="wp-image-47678" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-31-1024x136.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-31-766x102.png 766w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-31-300x40.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-31.png 1224w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Reference: <a href="https://support.microsoft.com/en-us/servicing/sql/sql-server-2025/cumulative-update/kb5084896-cu5">https://support.microsoft.com/en-us/servicing/sql/sql-server-2025/cumulative-update/kb5084896-cu5</a></p>



<p class="wp-block-paragraph">I built a lab on CU5 and on later builds up to CU9 and I spent some time measuring. Most of the documentation is confirmed. The sentence that says what the option actually changes, is not: on my lab, under the conditions described below I could not reproduce it. This post is what I measured with the protocol so that you can run it yourself and tell me if you get something different.</p>



<h2 id="h-why-would-released-locks-still-cost-memory" class="wp-block-heading">Why would released locks still cost memory?</h2>



<p class="wp-block-paragraph">A lock is not one structure in memory, it is two. The lock block describes the locked resource: this row, this page, this table. The lock owner block describes who holds it and in which mode. Three sessions reading the same row give one lock block and three lock owner blocks. sys.dm_tran_locks returns one row per owner block: the resource_* columns describe the lock block, the request_* columns the owner block and lock_owner_address is literally its address in memory.</p>



<p class="wp-block-paragraph">The documentation gives 96 bytes per lock: 64 bytes for the lock block, plus 32 bytes per owner block. On my lab, a row lock costs about 195 bytes all included, the clerk’s pages divided by the number of locks held: twice the documented figure. Part of the gap is known. The 64 bytes are the 32-bit size of the lock block: on 64-bit, Kalen Delaney gives 128 bytes. That leaves about 67 bytes for the owner block, the share of the intent lock on the page and the overhead of the object store. Keep 200 bytes in mind. Ten million row locks are 2 GB.</p>



<p class="wp-block-paragraph">These structures are not database pages. They are stolen memory taken from a pool owned by the lock manager. This pool is visible as the OBJECTSTORE_LOCK_MANAGER memory clerk in sys.dm_os_memory_clerks (one clerk per NUMA node, named Lock Manager : Node 0…) and as the Lock Memory (KB) performance counter. The locks documentation says 2500 lock structures. On my lab, Lock Blocks Allocated reports 3050 right after a restart, on SQL Server 2022 as on 2025.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="847" height="114" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-32.png" alt="" class="wp-image-47679" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-32.png 847w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-32-300x40.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-32-765x103.png 765w" sizes="auto, (max-width: 847px) 100vw, 847px" /></figure>



<p class="wp-block-paragraph">Reference : <a href="https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-locks-server-configuration-option?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/configure-the-locks-server-configuration-option?view=sql-server-ver17</a></p>



<p class="wp-block-paragraph">Here is what I have :</p>



<pre class="wp-block-code"><code>SELECT
    cntr_value AS lock_blocks_allocated
FROM sys.dm_os_performance_counters
WHERE object_name LIKE N'%:Memory Manager%'
  AND counter_name = N'Lock Blocks Allocated';</code></pre>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="164" height="46" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-33.png" alt="" class="wp-image-47680" /></figure>



<p class="wp-block-paragraph">Now the important part. When a transaction commits or rolls back, its locks are released. The lock structures are not returned to SQLOS. They stay in the pool, ready to be reused by the next lock request. This is a cache and the reason is simple: allocating memory for every lock acquisition would be expensive and lock acquisition is on the hot path of every query.</p>



<h2 id="h-the-numbers-to-remember" class="wp-block-heading">The numbers to remember</h2>



<p class="wp-block-paragraph">Four numbers. The documentation expresses the percentages against the &#8220;SQLOS committed memory&#8221;. On my lab, they behave as percentages of committed_target_kb in sys.dm_os_sys_info, the Target Server Memory (KB) counter: what SQLOS is allowed to use, not what it has used so far. Test 1 below shows why I say so.</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Threshold</strong></td><td><strong>Value</strong></td><td><strong>What happens</strong></td></tr><tr><td>Lock escalation per statement</td><td>5000 locks on one table or partition</td><td>The row and page locks of the statement are replaced by one table (or partition) lock</td></tr><tr><td>Lock escalation, memory</td><td>24 % of the engine memory (40 % of the lock memory limit)</td><td>The engine picks active statements and escalates their locks, every 1,250 new locks as long as lock memory stays above the threshold</td></tr><tr><td>Lock memory limit</td><td>60 % of the target</td><td>No more lock structures can be allocated: error 1204, the statement fails and the transaction is rolled back</td></tr><tr><td>Lock manager cache limit (new in CU5)</td><td>20 % of the target by default, 20 to 60 allowed</td><td><strong>The cached structures should fit under this size. Whether and when that is enforced is the subject of this post</strong></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Two remarks. Lock escalation exists precisely to keep lock memory small: 5000 row locks cost about 1 MB, one table lock costs 200 bytes. And the 60 % limit is not a cache limit: it is the maximum memory the lock manager may use, cache included.</p>



<h2 id="h-what-was-the-problem-before-cu5" class="wp-block-heading">What was the problem before CU5?</h2>



<p class="wp-block-paragraph">The cache could grow up to the 60 % limit. It was given back slowly, by a background task and that task could be slow enough for the cache to look permanent.</p>



<p class="wp-block-paragraph">The typical trigger is a workload that acquires a very large number of locks at once. The documentation mentions large concurrent query workloads with lock escalation disabled at the instance level (trace flag 1211). But you do not need a trace flag for that. ALTER TABLE &#8230; SET (LOCK_ESCALATION = DISABLE) on a big table, a reporting query in REPEATABLE READ or SERIALIZABLE scanning millions of rows, a purge that could not escalate because another session held an incompatible lock on the table: any of these can push lock memory to several GB.</p>



<p class="wp-block-paragraph">After the transaction ends, the lock structures stay in the cache. The buffer pool, the plan cache and every other cache have to live with what is left. Page life expectancy drops, physical reads increase, and nothing in the current workload explains it, because the workload that caused it is over.</p>



<p class="wp-block-paragraph">The symptom is easy to recognize once you know it. An idle instance, sys.dm_tran_locks almost empty and OBJECTSTORE_LOCK_MANAGER reporting GB. On an instance with a 128 GB target, the cache can hold up to about 77 GB.</p>



<h2 id="h-what-the-documentation-says-cu5-changes" class="wp-block-heading">What the documentation says CU5 changes</h2>



<p class="wp-block-paragraph">The lock manager cache now has a ceiling and you can configure it:</p>



<pre class="wp-block-code"><code>EXECUTE sp_configure 'show advanced options', 1;
RECONFIGURE;

EXECUTE sp_configure 'max lock manager cache memory (%)', 20;
RECONFIGURE;</code></pre>



<p class="wp-block-paragraph">The server must be restarted before the setting can take effect. The Server configuration options page lists it as A, RR (advanced, restart required): <a href="https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/server-configuration-options-sql-server?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/server-configuration-options-sql-server?view=sql-server-ver17</a></p>



<p class="wp-block-paragraph">What the option limits is the cache, not the locks.</p>



<p class="wp-block-paragraph">As stated in the documentation:</p>



<p class="wp-block-paragraph">Lock manager memory can still grow up to 60 percent of the SQLOS committed memory if required by the workload. However, when locks are released, memory is freed rather than cached if the lock manager cache already reached its configured limit.</p>



<p class="wp-block-paragraph">Reference : <a href="https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/max-lock-manager-cache-memory-configuration-option?view=sql-server-ver17">https://learn.microsoft.com/en-us/sql/database-engine/configure-windows/max-lock-manager-cache-memory-configuration-option?view=sql-server-ver17</a></p>



<p class="wp-block-paragraph">It says two things. A transaction may still take up to 60 % of the memory in locks: true (test 1). And when locks are released while the cache is already at its limit, the memory is freed instead of cached: this is the sentence I could not reproduce (tests 2 and 3).</p>



<h2 id="h-the-probe" class="wp-block-heading">The probe</h2>



<pre class="wp-block-code"><code>;WITH lm AS
(
    SELECT SUM(pages_kb) AS lock_manager_kb
    FROM sys.dm_os_memory_clerks
    WHERE type = N'OBJECTSTORE_LOCK_MANAGER'
),
si AS
(
    SELECT committed_kb, committed_target_kb
    FROM sys.dm_os_sys_info
),
pc AS
(
    SELECT
        MAX(CASE WHEN counter_name = N'Lock Blocks'                THEN cntr_value END) AS lock_blocks_in_use,
        MAX(CASE WHEN counter_name = N'Lock Blocks Allocated'      THEN cntr_value END) AS lock_blocks_allocated,
        MAX(CASE WHEN counter_name = N'Lock Memory (KB)'           THEN cntr_value END) AS lock_memory_kb,
        MAX(CASE WHEN counter_name = N'Database Cache Memory (KB)' THEN cntr_value END) AS database_cache_kb,
        MAX(CASE WHEN counter_name = N'Free Memory (KB)'           THEN cntr_value END) AS free_memory_kb
    FROM sys.dm_os_performance_counters
    WHERE object_name LIKE N'%Memory Manager%'
)
SELECT
    SYSDATETIME()  AS measured_at,
    lm.lock_manager_kb / 1024  AS lock_manager_mb,
    si.committed_kb / 1024  AS committed_mb,
    si.committed_target_kb / 1024 AS target_mb,
    CAST(100.0 * lm.lock_manager_kb / si.committed_target_kb AS DECIMAL(5, 1)) AS lock_manager_pct,
    pc.lock_blocks_in_use,
    pc.lock_blocks_allocated,
    pc.lock_blocks_allocated - pc.lock_blocks_in_use AS lock_blocks_cached,
    pc.database_cache_kb / 1024 AS buffer_pool_mb,
    pc.free_memory_kb / 1024 AS free_mb
FROM lm
CROSS JOIN si
CROSS JOIN pc;</code></pre>



<p class="wp-block-paragraph"><strong>How to read it:</strong></p>



<ul class="wp-block-list">
<li>lock_manager_mb is the whole lock manager memory: locks in use plus cached structures. On an idle instance, it is the size of the cache. This is the measure that matters.</li>



<li>committed_mb is what SQLOS has taken from the operating system so far. target_mb is what it is allowed to take. On an instance that has been running for a while, the two are equal. On a freshly restarted lab, they are far apart and nothing in the percentages makes sense until they meet.</li>



<li><strong>lock_manager_pct</strong> is the clerk against the target. The 20 % and the 60 % are read on this column.</li>



<li>buffer_pool_mb and free_mb show who pays for the cache, and who gets the memory back.</li>



<li>lock_blocks_in_use, lock_blocks_allocated and their difference lock_blocks_cached are the performance counters on lock blocks. They are coherent on a freshly started instance (3050 allocated, 0 in use) and stop refreshing after a lock storm on every 2025 build I tested: 6 624 047 allocated blocks reported while the clerk held 1 MB. They are here so that you can see it. Do not use them as evidence, sys.dm_tran_locks is the truth for locks held, the clerk for memory.</li>
</ul>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="841" height="37" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-34.png" alt="" class="wp-image-47681" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-34.png 841w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-34-300x13.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-34-750x33.png 750w" sizes="auto, (max-width: 841px) 100vw, 841px" /></figure>



<h2 id="h-the-lab" class="wp-block-heading">The lab</h2>



<p class="wp-block-paragraph">SQL Server 2025 Developer Edition, CU5 (17.0.4045.5) first, then CU9 (17.0.5005.3), on virtual machines of a Proxmox lab and on a Nutanix environment. One SQL Server 2022 instance for comparison, in test 5. The SQLOS target is 4096 MB unless stated otherwise (2048 MB in test 4), with min server memory and max server memory pinned to the same value.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="447" height="126" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-35.png" alt="" class="wp-image-47682" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-35.png 447w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-35-300x85.png 300w" sizes="auto, (max-width: 447px) 100vw, 447px" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="379" height="280" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-36.png" alt="" class="wp-image-47683" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-36.png 379w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-36-300x222.png 300w" sizes="auto, (max-width: 379px) 100vw, 379px" /></figure>



<p class="wp-block-paragraph">A database with one narrow table. Lock escalation is disabled on the table: we want millions of row locks, not one table lock. This is the scenario the documentation describes at table level instead of trace flag 1211:</p>



<pre class="wp-block-code"><code>CREATE DATABASE LockCacheDemo
GO
ALTER DATABASE LockCacheDemo SET RECOVERY SIMPLE
GO
USE LockCacheDemo
GO
CREATE TABLE dbo.T_Locks
(
    id     INT      NOT NULL,
    filler CHAR(20) NOT NULL CONSTRAINT DF_T_Locks_filler DEFAULT ('x'),
    CONSTRAINT PK_T_Locks PRIMARY KEY CLUSTERED (id)
)
GO
INSERT INTO dbo.T_Locks WITH (TABLOCK) (id)
SELECT value
FROM GENERATE_SERIES(1, 30000000)
GO
ALTER TABLE dbo.T_Locks SET (LOCK_ESCALATION = DISABLE)
GO</code></pre>



<p class="wp-block-paragraph">Thirty million rows, about 1 GB. Nothing in this block touches the lock manager: the TABLOCK on the insert takes one table lock to load fast. This block only provides lockable rows and removes the mechanism that would stop us from locking them one by one.</p>



<p class="wp-block-paragraph">The workload (session A):</p>



<pre class="wp-block-code"><code>USE LockCacheDemo
GO
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ
GO
DECLARE @rows INT = 1000000;

BEGIN TRANSACTION

SELECT COUNT(*) AS rows_locked
FROM (
    SELECT TOP (@rows) id
    FROM dbo.T_Locks WITH (ROWLOCK)
    ORDER BY id
) AS x

-- Leave the transaction open. Measure from session B. Then:
-- ROLLBACK TRANSACTION</code></pre>



<p class="wp-block-paragraph"><strong>Three different settings:</strong></p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Setting</strong></td><td><strong>Why</strong></td></tr><tr><td>LOCK_ESCALATION = DISABLE on the table</td><td>Keeps SQL Server from swapping the row locks for one table lock at 5000 locks.</td></tr><tr><td>WITH (ROWLOCK)</td><td>One lock per row. Otherwise SQL Server may lock pages: one lock for 245 rows.</td></tr><tr><td>REPEATABLE READ</td><td>Every shared lock is kept until the end of the transaction. In READ COMMITTED it is released as soon as the row is read.</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">From a second session (session B): did we get row locks and did escalation stay out?</p>



<pre class="wp-block-code"><code>SELECT resource_type, request_mode, COUNT(*) AS lock_count
FROM sys.dm_tran_locks
WHERE request_session_id = 65   -- The session holding the locks
GROUP BY resource_type, request_mode
ORDER BY lock_count DESC;
</code></pre>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>resource_type</strong></td><td><strong>request_mode</strong></td><td><strong>lock_count</strong></td></tr><tr><td>KEY</td><td>S</td><td>1 000 000</td></tr><tr><td>PAGE</td><td>IS</td><td>4 082</td></tr><tr><td>OBJECT</td><td>IS</td><td>1</td></tr><tr><td>DATABASE</td><td>S</td><td>1</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">One shared lock per row (KEY, the row of a clustered index). One intent lock per page, about 245 rows per page. One intent lock on the table and no OBJECT S: no escalation.</p>



<p class="wp-block-paragraph">Second check, the cost of a lock. Measured on a restarted instance each time so that nothing was cached before the run:</p>



<pre class="wp-block-code"><code>DECLARE @spid    INT    = 65;  -- session A
DECLARE @idle_kb BIGINT = 0;   -- clerk before the transaction

SELECT
    l.lock_count,
    l.key_locks,
    c.lock_manager_kb / 1024                                  AS lock_manager_mb,
    (c.lock_manager_kb - @idle_kb) * 1024.0 / l.lock_count    AS bytes_per_structure,
    (c.lock_manager_kb - @idle_kb) * 1024.0 / l.key_locks     AS bytes_per_row_lock
FROM (
    SELECT COUNT_BIG(*)                                            AS lock_count,
           SUM(CASE WHEN resource_type = N'KEY' THEN 1 ELSE 0 END) AS key_locks
    FROM sys.dm_tran_locks
    WHERE request_session_id = @spid
) AS l
CROSS JOIN (
    SELECT SUM(pages_kb) AS lock_manager_kb
    FROM sys.dm_os_memory_clerks
    WHERE type = N'OBJECTSTORE_LOCK_MANAGER'
) AS c;</code></pre>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Rows locked</strong></td><td><strong>lock_manager_mb</strong></td><td><strong>Bytes per row lock</strong></td></tr><tr><td>1 000 000</td><td>187</td><td>196</td></tr><tr><td>5 000 000</td><td>932</td><td>195</td></tr><tr><td>6 000 000</td><td>1 117</td><td>195</td></tr><tr><td>9 000 000</td><td>1 677</td><td>195</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">For example :</p>



<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="476" height="45" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-38.png" alt="" class="wp-image-47685" style="aspect-ratio:10.577777777777778;width:476px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-38.png 476w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-38-300x28.png 300w" sizes="auto, (max-width: 476px) 100vw, 476px" /></figure>



<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="473" height="44" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-39.png" alt="" class="wp-image-47686" style="aspect-ratio:10.75;width:473px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-39.png 473w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-39-300x28.png 300w" sizes="auto, (max-width: 473px) 100vw, 473px" /></figure>



<p class="wp-block-paragraph">Four runs, the same figure: about 195 bytes per row lock, everything included. Twice the 96 bytes of the documentation (64 for the lock block, 32 per owner block).</p>



<h2 id="h-test-1-the-60-wall-has-not-moved" class="wp-block-heading">Test 1: The 60 % wall has not moved</h2>



<p class="wp-block-paragraph">The name of the option invites a misunderstanding: it is not a limit on locks. Option at its default of 20 and @rows = 70 000 000 far beyond what 60 % of the target can hold (Target at 4968 MB for this run). The lock manager climbs.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="845" height="38" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-40.png" alt="" class="wp-image-47687" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-40.png 845w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-40-300x13.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-40-756x34.png 756w" sizes="auto, (max-width: 845px) 100vw, 845px" /></figure>



<p class="wp-block-paragraph">The probe shows 20 %, then 24 % without any escalation (the table option prevents it), then 40 %, 50 % and the statement dies:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="767" height="399" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-41.png" alt="" class="wp-image-47688" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-41.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-41-300x156.png 300w" sizes="auto, (max-width: 767px) 100vw, 767px" /></figure>



<p class="wp-block-paragraph">Severity 19: the statement is terminated and the transaction is rolled back. The same message lands in the SQL Server error log and in the Windows application log.</p>



<p class="wp-block-paragraph">Option at its default of 20 and @rows = 70 000 000, more than the 30 million rows of the table and far beyond what 60 % of the target can hold (Target at 4968 MB for this run): the wall is at about 16 million row locks.</p>



<p class="wp-block-paragraph">This test also settled which memory figure the percentages refer to. On the last probe before the error, the clerk was at 2979 MB: 60 % of the 4968 MB target, and 70 % of the 4233 MB SQLOS had committed at that moment. A 60 % limit on the committed memory would have fired about 440 MB earlier, at 2540 MB. The 60 % limit, and with it the 20 % one, are percentages of the target, not of the memory committed so far. This is why the probe divides by committed_target_kb.</p>



<h2 id="h-test-2-releasing-the-locks-frees-nothing" class="wp-block-heading">Test 2: Releasing the locks frees nothing</h2>



<p class="wp-block-paragraph">This is the test I wrote this post for. Option at 20. Target at 4096 MB for this run, so the cache ceiling is 819 MB. Nine million locks:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Step</strong></td><td><strong>lock_manager_mb</strong></td><td><strong>lock_manager_pct</strong></td><td><strong>committed_mb</strong></td><td><strong>buffer_pool_mb</strong></td></tr><tr><td>Idle, after restart</td><td>0</td><td>0.0</td><td>285</td><td>27</td></tr><tr><td>9 000 000 locks held</td><td>1 677</td><td>40.9</td><td>2 296</td><td>349</td></tr><tr><td>ROLLBACK, a few seconds later</td><td>1 677</td><td>40.9</td><td>2 296</td><td>349</td></tr><tr><td>ROLLBACK, minutes later</td><td>1 677</td><td>40.9</td><td>2 296</td><td>349</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">buffer_pool_mb is the Database Cache Memory (KB) counter: the data and index pages in memory. The 349 MB are the pages of the nine million rows the SELECT had to read to lock them, nothing else touched the buffer pool. sys.dm_tran_locks is empty, the session holds nothing and the clerk has not moved. 1677 MB of lock structures are sitting in the cache, twice the configured ceiling. The ROLLBACK did not free anything and neither did the minutes after it.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="840" height="39" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-42.png" alt="" class="wp-image-47689" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-42.png 840w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-42-300x14.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-42-754x35.png 754w" sizes="auto, (max-width: 840px) 100vw, 840px" /></figure>



<p class="wp-block-paragraph">I ran this on CU5 and on CU9, with 5, 6, 9 and 12 million locks with targets of 2 048 and 4 096 MB. Same result every time. The last run, 12 million locks on CU9 left 2193 MB in the cache, 54 % of the target and they were still there hours later.</p>



<h2 id="h-test-3-later-transactions-free-nothing-either" class="wp-block-heading">Test 3: Later transactions free nothing either</h2>



<p class="wp-block-paragraph">There is a second way to read the sentence of the documentation. Not the release of the storm but the releases that follow: once the cache is above its limit, every structure released by a later transaction should be freed instead of cached and the cache would erode with the lock activity of the workload, down to 20 %. &#8220;Large concurrent query workloads&#8221; as the documentation puts it.</p>



<p class="wp-block-paragraph">A first attempt with UPDATE TOP (1000000) &#8230; SET filler = &#8216;y&#8217; and a COMMIT, repeated, lowered the clerk by a few MB per pass. Not conclusive: without a ROWLOCK hint, the engine is free to take page locks for an update of this size and then only a few thousand structures travel through the cache at each pass.</p>



<p class="wp-block-paragraph">So the proper version, with row locks guaranteed and verified. Cache at 40.9 % of the target after a storm, option at 20, and short transactions in a loop:</p>



<pre class="wp-block-code"><code>USE LockCacheDemo
GO

SET NOCOUNT ON;
SET TRANSACTION ISOLATION LEVEL REPEATABLE READ;

DECLARE @i INT = 0, @n INT;

WHILE @i &lt; 100
BEGIN
    BEGIN TRANSACTION;
    SELECT @n = COUNT(*)
    FROM (SELECT TOP (200000) id
          FROM dbo.T_Locks WITH (ROWLOCK)
          WHERE id &gt; (@i % 50) * 200000
          ORDER BY id) AS x;
    COMMIT;            -- 200 000 structures released
    SET @i += 1;
END</code></pre>



<p class="wp-block-paragraph">One hundred transactions, 200,000 row locks each, verified in sys.dm_tran_locks while the loop ran: twenty million lock releases, more than four times the 4.6 million structures sitting above the ceiling (858 MB).</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Step</strong></td><td><strong>lock_manager_mb</strong></td><td><strong>lock_manager_pct</strong></td></tr><tr><td>Before the loop</td><td>1 677</td><td>40.9</td></tr><tr><td>After 20 000 000 releases</td><td>1 677</td><td>40.9</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The clerk did not move. Twenty million releases while the cache was more than twice its limit and the memory stayed. Whatever &#8220;when locks are released, memory is freed rather than cached&#8221; describes, it is not something I can observe with row locks, on CU5 or CU9, with the option at its default.</p>



<h2 id="h-test-4-what-does-shrink-the-cache" class="wp-block-heading">Test 4: What does shrink the cache</h2>



<p class="wp-block-paragraph">Two things did, during the week. None of them is the sentence of the documentation. Memory demand, once. Target at 2 048 MB, option at 20, six million locks, ROLLBACK: 1 117 MB in the cache, 54.6 %. Then a NOLOCK scan of a 17 GB table which takes one schema stability lock and nothing else but needs every page in the buffer pool:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Step</strong></td><td><strong>lock_manager_mb</strong></td><td><strong>lock_manager_pct</strong></td><td><strong>committed_mb</strong></td><td><strong>target_mb</strong></td><td><strong>buffer_pool_mb</strong></td></tr><tr><td>6 000 000 locks held</td><td>1 117</td><td>54.6</td><td>1 549</td><td>2 048</td><td>203</td></tr><tr><td>After ROLLBACK</td><td>1 117</td><td>54.6</td><td>1 549</td><td>2 048</td><td>203</td></tr><tr><td>After the NOLOCK scan</td><td>332</td><td>16.2</td><td>2 048</td><td>2 048</td><td>1 408</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The scan pushed committed_mb up to the target at that moment the buffer pool still wanted pages, SQLOS had nothing left to take from the operating system and it took from the lock manager: 785 MB given back, the clerk under the ceiling. Not one lock released in between. That looked like the mechanism. It was not reproducible either: on CU9 with the cache at 2 193 MB and the instance already at its 4 096 MB target, the same 17 GB scan left the cache at 2 190 MB. sys.dm_os_ring_buffers tells why: the memory broker that governs the lock manager cache (MEMORYBROKER_FOR_CACHE, which is where the object store lives, not MEMORYBROKER_FOR_STEAL) kept sending it a target of 2 809 MB, 68 % of the target with SHRINK notifications of a few MB. There is no 20 % anywhere in the broker&#8217;s arithmetic.</p>



<p class="wp-block-paragraph">DBCC FREESYSTEMCACHE, instantly<strong>.</strong> The documentation only shows &#8216;ALL&#8217;, which releases the unused entries of every cache, plan cache included, at the price of recompiling every plan afterwards. It also says that most caches can be freed individually. The lock manager cache is one of them:</p>



<pre class="wp-block-code"><code>SELECT name, pages_kb / 1024 AS mb
FROM sys.dm_os_memory_clerks
WHERE name LIKE N'Lock Manager%'; 

DBCC FREESYSTEMCACHE('Lock Manager : Node 0');</code></pre>



<p class="wp-block-paragraph">Before :</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="187" height="58" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-43.png" alt="" class="wp-image-47690" /></figure>



<p class="wp-block-paragraph">After :</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="175" height="60" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/10/image-44.png" alt="" class="wp-image-47691" /></figure>



<p class="wp-block-paragraph">Probe right after, on the 1677 MB cache left by test 3: 8 MB in the clerk, and the memory back in free_mb. The cache is releasable at any time in one instruction. What is missing on these builds is not the ability to release it, it is the trigger.</p>



<h2 id="h-test-5-sql-server-2022-does-the-same-without-the-option" class="wp-block-heading">Test 5: SQL Server 2022 does the same without the option</h2>



<p class="wp-block-paragraph">If the option changed something, the comparison with a version that does not have it should show it. Same table, same workload, @rows = 70000000 for the wall and a value under the wall for the clean ROLLBACK, on SQL Server 2022 and on SQL Server 2025 CU5, CU9 with the option at 20:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><tbody><tr><td><strong>Scenario</strong></td><td><strong>SQL Server 2022</strong></td><td><strong>SQL Server 2025 CU5, CU9, option at 20</strong></td></tr><tr><td>Locks acquired below the wall then ROLLBACK</td><td>Memory given back progressively, down to 17.6 %</td><td>Memory given back very progressively</td></tr><tr><td>60 % wall, error 1204</td><td>Memory given back very progressively</td><td>Descent very, very slow, then stagnation</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">Two things in this table. The background release exists on 2022, without any option, and it brings the cache down to 17.6 %, which is where the 20 % ceiling of 2025 would put it. And 2025 is not faster than 2022 at giving the memory back.</p>



<h2 id="h-what-the-tests-say" class="wp-block-heading">What the tests say</h2>



<p class="wp-block-paragraph">Put together, on SQL Server 2025 CU5 and CU9 with one big transaction and then normal lock activity:</p>



<ul class="wp-block-list">
<li>Lock memory, cache included, can grow up to 60 % of the target. Beyond that, error 1204. Confirmed and unchanged by the option.</li>



<li>When the storm is released, nothing is freed. The structures go to the cache above the configured ceiling and stay there for hours.</li>



<li>The lock activity that follows does not free them either: twenty million row-lock releases above the ceiling, no measurable change.</li>



<li>The cache does come down, slowly, through the generic SQLOS mechanisms (background release, memory broker rebalancing when the instance is at its target), the same ones SQL Server 2022 has. On 2025, they are not faster, and the 20 % figure does not appear in the broker&#8217;s targets.</li>



<li>DBCC FREESYSTEMCACHE(&#8216;Lock Manager : Node N&#8217;) empties the cache immediately on any version I tried.</li>
</ul>



<p class="wp-block-paragraph">So the documented behavior, &#8220;when locks are released, memory is freed rather than cached if the lock manager cache already reached its configured limit&#8221;, is not something I could reproduce. Not on CU5, not on CU9, not with the release of the big transaction, not with twenty million releases afterwards. The option is there, it accepts 20 to 60, value_in_use says 20 and the cache sits around 40% for as long as I care to watch.</p>



<p class="wp-block-paragraph">Two caveats, because this is a lab and not a support case. One access pattern: a single transaction holding millions of row locks then released. Virtual machines with 2 and 4 GB targets. Developer Edition. It is possible that the mechanism needs a condition I did not create and that &#8220;large concurrent query workloads&#8221; means something more specific than what I ran. If you reproduce the protocol above and see the cache come back under 20 % after a release, I want to hear about it. If you do not, you have the same question I have.</p>



<h2 id="h-a-summary" class="wp-block-heading">A summary</h2>



<ul class="wp-block-list">
<li>Released locks are not freed, they are cached by the lock manager. A row lock costs about 195 bytes all included.</li>



<li>The thresholds (24 % escalation, 60 % hard limit, 20 % cache ceiling) are read against committed_target_kb, not committed_kb. On a lab, set min and max server memory before measuring anything.</li>



<li>CU5 adds max lock manager cache memory (%): 20 by default, 20 to 60 allowed, a restart to take effect (is_dynamic = 0).</li>



<li>The 60 % wall and error 1204 are unchanged by the option.</li>



<li>On CU5 and CU9, releasing the locks does not bring the cache back under the ceiling and neither do twenty million later releases. The documented behavior was not reproducible on my lab.</li>



<li>The cache does shrink through the generic SQLOS mechanisms slowly as on SQL Server 2022 and the memory broker targets it well above 20 %.</li>



<li>DBCC FREESYSTEMCACHE(&#8216;Lock Manager : Node N&#8217;) empties it instantly (on any version I tried). It is the remedy that works.</li>



<li>The lock block performance counters stop refreshing on these builds. Use the clerk and sys.dm_tran_locks.</li>
</ul>



<p class="wp-block-paragraph">Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-2025-cu5-max-lock-manager-cache-memory-what-i-could-reproduce-and-what-i-could-not/">SQL Server 2025 CU5: max lock manager cache memory (%): what I could reproduce and what I could not</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-2025-cu5-max-lock-manager-cache-memory-what-i-could-reproduce-and-what-i-could-not/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server: why can&#8217;t I read the full table in parallel???</title>
		<link>https://www.dbi-services.com/blog/sql-server-why-cant-i-read-the-full-table-in-parallel/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-why-cant-i-read-the-full-table-in-parallel/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Mon, 21 Sep 2026 16:56:45 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[clustered index]]></category>
		<category><![CDATA[Parallel Execution]]></category>
		<category><![CDATA[tuning]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46987</guid>

					<description><![CDATA[<p>Why SQL Server keeps a full SELECT * serial: I/O cost is never divided across threads. Forcing parallelism burned 3x the CPU.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-why-cant-i-read-the-full-table-in-parallel/">SQL Server: why can&#8217;t I read the full table in parallel???</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">&#8220;<strong>Why won&#8217;t my table read in parallel???&#8221; </strong></p>



<p class="wp-block-paragraph">That was more or less the question <a href="https://www.linkedin.com/in/roger-schoenmann/" data-type="link" data-id="https://www.linkedin.com/in/roger-schoenmann/">Roger Schönmann</a> and I were asking ourselves while staring at two execution plans on a SQL Server 2025 lab instance. The first query scanned the whole <code>Users</code> table of the <code>StackOverflow2010</code> database on a single thread and took five seconds (elapsed time). The second one scanned exactly the same pages, with a <code>WHERE</code> clause on <code>Location</code>, got a parallel plan and finished in under three hundred milliseconds.</p>



<p class="wp-block-paragraph">The obvious reading is that parallelism made the difference, and that the optimizer made a poor choice by keeping the first query serial. The numbers ended up saying the opposite, including once I had forced the parallel plan by hand.</p>



<h3 id="h-the-two-queries" class="wp-block-heading">The two queries</h3>



<p class="wp-block-paragraph">Everything below ran on SQL Server 2025, <code>cost threshold for parallelism</code> left at its default of 5 (for this lab only <img src="https://s.w.org/images/core/emoji/17.0.2/72x72/1f609.png" alt="😉" class="wp-smiley" style="height: 1em; max-height: 1em;" />). The <code>Users</code> table holds 299’398 rows on 7’401 pages. Each query ran on a cold buffer pool, so both paid the same disk reads.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CHECKPOINT;

DBCC FREEPROCCACHE; -- on a lab only
DBCC DROPCLEANBUFFERS;  -- on a lab only

SET STATISTICS IO, TIME ON;

SELECT * FROM dbo.Users;

SELECT * FROM dbo.Users
WHERE Location LIKE &#039;%Denmark%&#039;;
</pre></div>


<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>Scan count</th><th>Read-ahead reads</th><th>CPU</th></tr></thead><tbody><tr><td>No <code>WHERE</code></td><td>1</td><td>7’401</td><td>1.016 s</td></tr><tr><td><code>LIKE '%Denmark%'</code></td><td>5</td><td>7’401</td><td>0.266 s</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The read-ahead counts match to the page. The leading wildcard makes the predicate <strong>non-sargable</strong>, so the second query cannot seek and has to read every row to test it, exactly like the first one. The filter only reduces the rows returned.</p>



<p class="wp-block-paragraph">The serial query needs about one second of CPU in total, so even a perfect parallel plan could only shave a fraction off it.</p>



<h3 id="h-how-the-optimizer-decides" class="wp-block-heading">How the optimizer decides</h3>



<p class="wp-block-paragraph">The <code>cost threshold for parallelism</code> is only an entry point when the query planner is building the tree of possible execution plans. <strong><span style="text-decoration: underline">When the serial plan costs more than the threshold</span></strong>, the optimizer also builds a parallel alternative, then keeps whichever of the two is cheaper. </p>



<p class="wp-block-paragraph">The costing of the parallel alternative is explained in detail by <a href="http://dataeducation.com/next-level-parallel-plan-forcing-an-alternative-to-8649/">Adam Machanic</a>: <strong>CPU costs are divided by the degree of parallelism, <span style="text-decoration: underline">I/O costs are not divided at all</span></strong>, and the exchange operators added to the plan <span style="text-decoration: underline">come with a cost of their own</span>.</p>



<p class="wp-block-paragraph">The plan XML of the serial query gives everything needed to do the arithmetic.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
&lt;OptimizerHardwareDependentProperties ... EstimatedAvailableDegreeOfParallelism=&quot;2&quot; ... /&gt;
&lt;RelOp AvgRowSize=&quot;4468&quot; EstimateCPU=&quot;0.329495&quot; EstimateIO=&quot;5.46609&quot;
       EstimateRows=&quot;299398&quot; PhysicalOp=&quot;Clustered Index Scan&quot;
       EstimatedTotalSubtreeCost=&quot;5.79558&quot; ... &gt;
</pre></div>


<p class="wp-block-paragraph">Around <strong>90%</strong> of the scan cost is I/O, which the parallel plan keeps as is. The part that can shrink is 0.33, divided by the two threads the optimizer assumes for costing on this instance, so the best a parallel scan can hope to save is roughly 0.16. In exchange, the plan needs a<code> Gather Streams </code>operator to bring every row back to a single thread, and here that means 299’398 rows the optimizer believes to be 4’468 bytes wide on average, mostly because <code>AboutMe</code> is an <code>nvarchar(max)</code>.</p>



<p class="wp-block-paragraph"><strong>There was no <code>NonParallelPlanReason</code> in the XML, the optimization level was <code>FULL</code>, and nothing in the plan pointed to an inhibitor. The optimizer looked at the parallel alternative and turned it down on cost.</strong></p>



<h3 id="h-forcing-the-parallel-plan-anyway" class="wp-block-heading">Forcing the parallel plan anyway</h3>



<p class="wp-block-paragraph">Understanding the costing did not stop me from wanting to see the parallel plan run. Three attempts failed.</p>



<p class="wp-block-paragraph"><code>OPTION(QUERYTRACEON 8649, RECOMPILE)</code> compiled with trace flag 8649 listed under <code>IsCompileTime="true"</code>, and the plan stayed serial with no <code>NonParallelPlanReason</code>. I cannot say with certainty why the flag did not win here. <code>USE HINT('ENABLE_PARALLEL_PLAN_PREFERENCE')</code> gave the same result.</p>



<p class="wp-block-paragraph"><a href="https://www.linkedin.com/in/adammachanic/" data-type="link" data-id="https://www.linkedin.com/in/adammachanic/">Adam Machani</a><a href="https://www.linkedin.com/in/adammachanic/">c</a>&#8216;s <code><a href="http://dataeducation.com/next-level-parallel-plan-forcing-an-alternative-to-8649/" data-type="link" data-id="http://dataeducation.com/next-level-parallel-plan-forcing-an-alternative-to-8649/">make_parallel()</a></code> function, cross applied to the query as his article describes, produced a spectacular plan with a <code>Gather Streams</code> estimated at 549’756’000’000 rows. The optimizer parallelised the fake branch of the function, closed the parallel zone before its aggregate, and left the scan of <code>Users</code> on the outer serial Nested Loops. </p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT x.*
FROM dbo.make_parallel() AS mp
CROSS APPLY
(
    SELECT * FROM dbo.Users
) AS x;
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="260" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-22-1024x260.png" alt="" class="wp-image-47008" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-22-1024x260.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-22-300x76.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-22-765x194.png 765w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-22.png 1159w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">What finally worked was going after the estimate instead of the plan. If the <code>Gather Streams</code> is what the optimizer refuses to pay for, it only has to believe that almost no rows will cross it. So I gave it a filter it judges extremely selective, and that removes nothing at runtime:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
declare @impossiblevalue int = 0;

SELECT * FROM dbo.Users WHERE reputation &gt;= @impossiblevalue
OPTION (OPTIMIZE FOR (@impossiblevalue = 1000000));
</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="909" height="199" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-21.png" alt="" class="wp-image-46990" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-21.png 909w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-21-300x66.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-21-767x168.png 767w" sizes="auto, (max-width: 909px) 100vw, 909px" /></figure>



<p class="wp-block-paragraph"><strong>Finally, some parallelism has been introduced!</strong></p>



<p class="wp-block-paragraph">The statistics show nothing anywhere near a million reputation points, so the plan is compiled for an estimate of 45 rows (based on the density vector). At runtime <code>@impossiblevalue</code> is 0 and every one of the 299’398 users comes back. The optimizer, fooled into seeing a nearly free <code>Gather Streams</code>, finally picks the parallel plan.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>Scan count</th><th>Read-ahead reads</th><th>CPU</th></tr></thead><tbody><tr><td>Serial (optimizer choice)</td><td>1</td><td>7&#8217;401</td><td>1.016 s</td></tr><tr><td>Parallel (forced)</td><td>5</td><td>7&#8217;401</td><td>2.844 s</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">And let&#8217;s see the main wait statistics of both executions :</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Wait</th><th>Serial</th><th>Forced parallel</th></tr></thead><tbody><tr><td>PAGEIOLATCH_SH</td><td>41 ms</td><td>2&#8217;237 ms</td></tr><tr><td>CXPACKET</td><td>none</td><td>20’935 ms (60’181 waits)</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><code>CXPACKET</code> is the price of the streams we created by introducing parallelism. 4 producer threads fill packets for a single consumer, which can only hand rows to SSMS as fast as SSMS accepts them. When the exchange buffers are full, the producers wait. The 21 seconds are summed across threads and the wide rows mean few rows per packet, hence the 60&#8217;000 waits.</p>



<p class="wp-block-paragraph"><code>PAGEIOLATCH_SH</code> also grew, from 41 ms to 2&#8217;237 ms. Several threads competing for pages of the same table is a plausible explanation, but I have no real proof of it.</p>



<h3 id="h-what-to-take-from-this" class="wp-block-heading">What to take from this</h3>



<p class="wp-block-paragraph">For a query that reads the whole table and returns the whole table, most of the estimated cost is I/O, and the optimizer <strong>never divides I/O between threads</strong>. On <code>Users</code>, that left ~90 percent of the scan cost untouched by parallelism, and only the remaining CPU share, about 0.33, available to be split. Against that small saving, the parallel plan has to pay for funnelling every row through a single exchange, and on this table the exchange cost more than the plan could save. Row count does not change the outcome, since the I/O, the CPU and the exchange all grow with it. </p>



<p class="wp-block-paragraph">What makes the optimizer choose a parallel plan is the estimated number of rows crossing the exchange: the forced plan got there with an integer comparison that costs nothing, simply because the estimate dropped to 45 rows. What makes a parallel plan actually faster is something else, real CPU work per row to spread across threads, and few rows coming out at the end. The <code>LIKE</code> query has both, which is why it wins. The forced plan had neither, and it burned almost three times the CPU.</p>



<p class="wp-block-paragraph"></p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-why-cant-i-read-the-full-table-in-parallel/">SQL Server: why can&#8217;t I read the full table in parallel???</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-why-cant-i-read-the-full-table-in-parallel/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server: Stop ordering index columns by selectivity</title>
		<link>https://www.dbi-services.com/blog/sql-server-stop-ordering-index-columns-by-selectivity/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-stop-ordering-index-columns-by-selectivity/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Tue, 08 Sep 2026 11:32:57 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[index]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46730</guid>

					<description><![CDATA[<p>Column order does not affect equality seeks in SQL Server. Measured on 5M rows, then proven inside the B-tree pages.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-stop-ordering-index-columns-by-selectivity/">SQL Server: Stop ordering index columns by selectivity</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">In September 2026 <a href="https://www.linkedin.com/in/brentozar/">Brent Ozar</a> published <a href="https://lnkd.in/p/eNQKx_tV">a post</a> arguing that the classic interview question about composite index column order has an answer everybody gets wrong, and that for a query filtering two columns <strong>with equality the order makes no difference whatsoever. </strong>I wanted to test that claim in logical reads on my own instance and dig into the B-tree architecture to see what actually happens.</p>



<h2 id="h-building-the-test" class="wp-block-heading">Building the test</h2>



<p class="wp-block-paragraph">One table, five million rows, and two indexes holding the same two columns in opposite order. The distribution is deliberately <strong>skewed</strong>, the way real order tables always are.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE TABLE dbo.Orders (
    OrderID     INT IDENTITY(1,1) NOT NULL PRIMARY KEY CLUSTERED,
    Status      VARCHAR(12)   NOT NULL,
    CustomerID  INT           NOT NULL,
    OrderDate   DATETIME2(0)  NOT NULL,
    Amount      DECIMAL(10,2) NOT NULL
);
GO

INSERT INTO dbo.Orders (Status, CustomerID, OrderDate, Amount)
SELECT TOP 3000000
    &#039;Shipped&#039;,
    ABS(CHECKSUM(NEWID())) % 100000 + 1,
    DATEADD(MINUTE, -ABS(CHECKSUM(NEWID())) % 1000000, SYSDATETIME()),
    ABS(CHECKSUM(NEWID())) % 50000 / 100.0
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

INSERT INTO dbo.Orders (Status, CustomerID, OrderDate, Amount)
SELECT TOP 1500000
    &#039;Pending&#039;,
    ABS(CHECKSUM(NEWID())) % 100000 + 1,
    DATEADD(MINUTE, -ABS(CHECKSUM(NEWID())) % 1000000, SYSDATETIME()),
    ABS(CHECKSUM(NEWID())) % 50000 / 100.0
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

INSERT INTO dbo.Orders (Status, CustomerID, OrderDate, Amount)
SELECT TOP 500000
    &#039;Cancelled&#039;,
    ABS(CHECKSUM(NEWID())) % 100000 + 1,
    DATEADD(MINUTE, -ABS(CHECKSUM(NEWID())) % 1000000, SYSDATETIME()),
    ABS(CHECKSUM(NEWID())) % 50000 / 100.0
FROM sys.all_columns a CROSS JOIN sys.all_columns b;

CREATE NONCLUSTERED INDEX IX_Status_Customer ON dbo.Orders (Status, CustomerID);
CREATE NONCLUSTERED INDEX IX_Customer_Status ON dbo.Orders (CustomerID, Status);
GO

UPDATE STATISTICS dbo.Orders WITH FULLSCAN;
GO
</pre></div>


<p class="wp-block-paragraph"><code>Status</code> holds three distinct values across five million rows and <code>CustomerID</code> holds a hundred thousand. Anyone applying the textbook rule puts <code>CustomerID</code> first, since it slices the table thirty-three thousand times more finely, and writes off <code>IX_Status_Customer</code> as a beginner&#8217;s mistake.</p>



<p class="wp-block-paragraph">Both indexes cover a query that selects only <code>OrderID</code>, <a href="https://learn.microsoft.com/en-us/sql/relational-databases/indexes/clustered-and-nonclustered-indexes-described?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/indexes/clustered-and-nonclustered-indexes-described?view=sql-server-ver17">because the clustering key sits in every nonclustered leaf</a>. That keeps key lookups out of the read counts and leaves nothing but index access to measure.</p>



<h2 id="h-run-the-test-both-columns-in-the-where-clause" class="wp-block-heading">Run the test: both columns in the WHERE clause</h2>



<p class="wp-block-paragraph">Let&#8217;s run the same query against both indexes, with equality predicates on <code>Status</code> and <code>CustomerID</code>, and compare what comes back.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SET STATISTICS IO ON;

SELECT OrderID
FROM dbo.Orders WITH (INDEX(IX_Status_Customer))
WHERE Status = &#039;Cancelled&#039; AND CustomerID = 42731;
</pre></div>

<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Table &#039;Orders&#039;. Scan count 1, logical reads 3, physical reads 0
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="195" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-6-1024x195.png" alt="" class="wp-image-46734" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-6-1024x195.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-6-300x57.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-6-767x146.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-6.png 1372w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT OrderID
FROM dbo.Orders WITH (INDEX(IX_Customer_Status))
WHERE Status = &#039;Cancelled&#039; AND CustomerID = 42731;
</pre></div>

<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
Table &#039;Orders&#039;. Scan count 1, logical reads 3, physical reads 0

</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="190" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-7-1024x190.png" alt="" class="wp-image-46735" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-7-1024x190.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-7-300x56.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-7-766x142.png 766w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-7.png 1370w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Three logical reads each. Same plan shape, same cost, same everything, from two indexes whose leading columns differ in cardinality by a factor of thirty-three thousand.</p>



<p class="wp-block-paragraph">The seek predicates settle it: both say <strong>Prefix</strong>, and neither plan carries a residual <code>Predicate</code> section, which means both indexes reach the rows with a single descent on the full composite key rather than filtering one column after the other :</p>



<div class="wp-block-columns are-vertically-aligned-center is-layout-flex wp-container-core-columns-is-layout-8f761849 wp-block-columns-is-layout-flex">
<div class="wp-block-column is-layout-flow wp-block-column-is-layout-flow">
<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="524" height="101" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-10.png" alt="" class="wp-image-46738" style="aspect-ratio:5.188766788766789;width:369px;height:auto" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-10.png 524w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-10-300x58.png 300w" sizes="auto, (max-width: 524px) 100vw, 524px" /></figure>
</div>



<div class="wp-block-column is-layout-flow wp-block-column-is-layout-flow">
<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="557" height="95" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-11.png" alt="" class="wp-image-46739" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-11.png 557w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-11-300x51.png 300w" sizes="auto, (max-width: 557px) 100vw, 557px" /></figure>
</div>
</div>



<p class="wp-block-paragraph">The word that matters is <strong>Prefix</strong>, and it appears on both sides. SQL Server uses that label when the seek keys form a leading portion of the index key and can be resolved in a single descent through the tree.</p>



<p class="wp-block-paragraph">This is the heart of the whole thing. A composite index does not store two sorted columns side by side, waiting to be consulted in sequence. It stores one sorted key made of both columns, and <code>Cancelled | 42731</code> occupies exactly one position inside it. The engine descends once and arrives, and the picture most of us carry, in which the engine narrows the search using the first column, hands a smaller set of rows over to the second and narrows it again, describes something SQL Server never does.</p>



<p class="wp-block-paragraph">That mental picture is precisely what makes the selectivity rule feel so obviously right. Sort by the column that eliminates the most rows first, the reasoning goes, and the second column has less work left to do. It sounds like common sense, and the only trouble with it is that it describes a two-stage filter which the seek predicates above show to be nowhere in either plan, because the filtering it imagines was already finished the moment the descent landed.</p>



<h2 id="h-deep-dive-into-the-nonclustered-b-trees" class="wp-block-heading">Deep-dive into the nonclustered B-trees</h2>



<p class="wp-block-paragraph">The plans agree. Now let&#8217;s see whether the physical structures agree too.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT
    index_level,
    page_count,
    record_count,
    avg_page_space_used_in_percent
FROM sys.dm_db_index_physical_stats(
        DB_ID(),
        OBJECT_ID(&#039;dbo.Orders&#039;),
        INDEXPROPERTY(OBJECT_ID(&#039;dbo.Orders&#039;), &#039;IX_Status_Customer&#039;, &#039;IndexID&#039;),
        NULL,
        &#039;DETAILED&#039;)
ORDER BY index_level DESC;

SELECT
    index_level,
    page_count,
    record_count,
    avg_page_space_used_in_percent
FROM sys.dm_db_index_physical_stats(
        DB_ID(),
        OBJECT_ID(&#039;dbo.Orders&#039;),
        INDEXPROPERTY(OBJECT_ID(&#039;dbo.Orders&#039;), &#039;IX_Customer_Status&#039;, &#039;IndexID&#039;),
        NULL,
        &#039;DETAILED&#039;)
ORDER BY index_level DESC;
</pre></div>


<p class="wp-block-paragraph">IX_Status_Customer:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="632" height="114" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-15.png" alt="" class="wp-image-46745" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-15.png 632w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-15-300x54.png 300w" sizes="auto, (max-width: 632px) 100vw, 632px" /></figure>



<p class="wp-block-paragraph">IX_Customer_Status:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="635" height="111" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-16.png" alt="" class="wp-image-46746" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-16.png 635w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-16-300x52.png 300w" sizes="auto, (max-width: 635px) 100vw, 635px" /></figure>



<p class="wp-block-paragraph">Start with the number that ties back to test 1. Both indexes have <strong>three levels</strong>, and both queries reported three logical reads. Root page, intermediate page, leaf page. The execution plan and the physical structure confirm each other through two completely independent measurements, and neither leaves room for a second lookup step hiding somewhere. Five million rows on both sides, same depth, same cost, and the number of distinct values in the leading column changes neither one.</p>



<p class="wp-block-paragraph">Now read the tables diagonally. The <code>record_count</code> of any level equals the <code>page_count</code> of the level beneath it: 57 records at level 2 against 57 pages at level 1, then 15&#8217;605 records at level 1 against 15&#8217;605 pages at level 0. The second index does the same with its own figures, 58 and 58, then 15&#8217;589 and 15&#8217;589. An upper level therefore holds one entry per child page. One per page, whatever that page happens to contain, which is why the size of these levels tracks the number of pages underneath them and stays completely indifferent to how many distinct values live in the column you chose to lead with.</p>



<p class="wp-block-paragraph">And that brings us to the comparison the whole article was built for:</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>IX_Status_Customer</th><th>IX_Customer_Status</th></tr></thead><tbody><tr><td>Leading column</td><td>Status</td><td>CustomerID</td></tr><tr><td>Distinct values in the leading column</td><td>3</td><td>100&#8217;000</td></tr><tr><td>Levels</td><td>3</td><td>3</td></tr><tr><td>Root page entries</td><td>57</td><td>58</td></tr><tr><td>Level 1 pages</td><td>57</td><td>58</td></tr><tr><td>Leaf pages</td><td>15&#8217;605</td><td>15&#8217;589</td></tr><tr><td>Rows</td><td>5&#8217;000&#8217;000</td><td>5&#8217;000&#8217;000</td></tr><tr><td>Logical reads, equality seek</td><td>3</td><td>3</td></tr></tbody></table></figure>



<h2 id="h-inside-the-root-pages" class="wp-block-heading">Inside the root pages</h2>



<p class="wp-block-paragraph">The DMV tells us each root holds fifty-seven and fifty-eight entries. Let&#8217;s look at what those entries actually are.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT
    allocated_page_file_id,
    allocated_page_page_id,
    page_type_desc,
    page_level
FROM sys.dm_db_database_page_allocations(
        DB_ID(),
        OBJECT_ID(&#039;dbo.Orders&#039;),
        INDEXPROPERTY(OBJECT_ID(&#039;dbo.Orders&#039;), &#039;IX_Status_Customer&#039;, &#039;IndexID&#039;),
        NULL,
        &#039;DETAILED&#039;)
WHERE page_type_desc = &#039;INDEX_PAGE&#039;
ORDER BY page_level DESC;

SELECT
    allocated_page_file_id,
    allocated_page_page_id,
    page_type_desc,
    page_level
FROM sys.dm_db_database_page_allocations(
        DB_ID(),
        OBJECT_ID(&#039;dbo.Orders&#039;),
        INDEXPROPERTY(OBJECT_ID(&#039;dbo.Orders&#039;), &#039;IX_Customer_Status&#039;, &#039;IndexID&#039;),
        NULL,
        &#039;DETAILED&#039;)
WHERE page_type_desc = &#039;INDEX_PAGE&#039;
ORDER BY page_level DESC;

--Run once per index, using the page_level 2 page id returned above
DBCC TRACEON(3604);
DBCC PAGE(&#039;IndexOrderDemo&#039;, 1, &lt;root_page_id_for_both_index&gt;, 3);
</pre></div>


<p class="wp-block-paragraph">IX_Status_Customer:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="447" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-17-1024x447.png" alt="" class="wp-image-46749" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-17-1024x447.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-17-300x131.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-17-767x335.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-17.png 1047w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">IX_Customer_Status:</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="443" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-18-1024x443.png" alt="" class="wp-image-46750" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-18-1024x443.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-18-767x332.png 767w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-18-300x130.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-18.png 1049w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Several things show up in these dumps, and each of them says the same thing about how the key is treated.</p>



<p class="wp-block-paragraph">Row 0 carries <code>NULL</code> in every key column, which means an index record holding no key whatsoever, pointing at the <strong>leftmost child page</strong> and covering every row that sorts below the first boundary anywhere in the page.</p>



<p class="wp-block-paragraph">Every other entry carries all three key columns, <code>OrderID</code> included, because the clustering key forms part of every nonclustered key. No entry ever names a single column. Each one identifies one specific row out of five million, which is exactly what a boundary between two pages has to do.</p>



<p class="wp-block-paragraph">In <code>IX_Status_Customer</code>, the same status value appears in entry after entry. Half a million <code>Cancelled</code> rows span roughly 1&#8217;560 leaf pages and therefore about six intermediate pages, so the value contributes six separate boundaries.</p>



<h2 id="h-what-about-statistics" class="wp-block-heading">What about statistics?</h2>



<p class="wp-block-paragraph"><strong>Navigation </strong>is one thing, <strong>description </strong>is another. Run <code>DBCC SHOW_STATISTICS</code> on both indexes and the density vectors tell the story of this whole article in five numbers:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
DBCC SHOW_STATISTICS(&#039;dbo.Orders&#039;, &#039;IX_Status_Customer&#039;);
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="291" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-19-1024x291.png" alt="" class="wp-image-46768" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-19-1024x291.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-19-768x218.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-19-300x85.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-19.png 1433w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
DBCC SHOW_STATISTICS(&#039;dbo.Orders&#039;, &#039;IX_Customer_Status&#039;);
</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="330" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-20-1024x330.png" alt="" class="wp-image-46769" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-20-1024x330.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-20-766x247.png 766w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-20-300x97.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-20.png 1408w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">The single-column entries are worlds apart, 0.3333333 for <code>Status</code> against 1E-05 for <code>CustomerID</code>. The two-column entries are identical to the last digit, 3.340538E-06 on both, <strong>because<a href="https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-show-statistics-transact-sql?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-show-statistics-transact-sql?view=sql-server-ver17"> the density</a> of the full prefix counts the same combinations</strong> whichever way round you write them. Furthermore, both previous execution plans above show 7 of 17, seven rows actually returned against seventeen expected, and seventeen is not a coincidence: 5&#8217;000&#8217;000 multiplied by the two-column density of 3.340538E-06 gives 16.7. Since that density is identical in both indexes, so is the estimate.</p>



<p class="wp-block-paragraph">The histograms for the first column have nothing in common. <code>IX_Status_Customer</code> gets three steps with exact row counts, 500&#8217;000 Cancelled, 1&#8217;500&#8217;000 Pending, 3&#8217;000&#8217;000 Shipped. <code>IX_Customer_Status</code> gets 21 steps across a hundred thousand customer numbers, with around 50 rows per step. <a href="https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-show-statistics-transact-sql?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/t-sql/database-console-commands/dbcc-show-statistics-transact-sql?view=sql-server-ver17">A histogram only ever describes the leading column</a>, so each index is blind to the other&#8217;s.</p>



<p class="wp-block-paragraph">That matters the moment a query supplies one column instead of both, and it is worth knowing before you assume the two indexes are interchangeable for every purpose. It changes nothing for equality on the full key, which is what this article measured.</p>



<h2 id="h-what-to-take-from-this" class="wp-block-heading">What to take from this</h2>



<p class="wp-block-paragraph"><strong>For equality predicates covering the full key</strong>, the two indexes are interchangeable. Three logical reads each, three levels each, and root pages holding 57 entries against 58, from two indexes whose leading columns sit thirty-three thousand apart in cardinality. Brent was right, and the plan, the DMV and the pages all say so independently.</p>



<p class="wp-block-paragraph">The reason sits in the seek predicates. <code>Prefix</code> on both sides, no residual predicate on either, one descent to one composite key. SQL Server treats the columns as a single sorted value, so there is no first column doing the heavy lifting and no second column mopping up.</p>



<p class="wp-block-paragraph">None of which makes column order free. Drop the leading column and the two part company immediately: <code>WHERE CustomerID = 42731</code> alone seeks on one index and scans all 15&#8217;605 leaf pages on the other. <strong>Add an inequality and only the leading range predicate stays seekable</strong>, since a range cannot be resolved by a single descent. The seek finds the boundary and reads forward, everything after that column becomes a residual predicate evaluated row by row, and you can watch it appear as a <code>Predicate</code> section under the seek that was absent from both plans above.</p>



<p class="wp-block-paragraph">So order your key columns by what your queries supply. <strong><span style="text-decoration: underline">Equality columns first, then the single range predicate that eliminates the most rows, because only the first of them gets a real seek.</span></strong> That is the one place selectivity earns its keep.</p>



<p class="wp-block-paragraph">One last thing worth saying plainly: this article compared two indexes <strong><span style="text-decoration: underline">on a single query,</span></strong> and no index ever exists to serve a single query. The right order for <code>IX_Status_Customer</code> or <code>IX_Customer_Status</code> depends on everything else your application runs against that table. Which columns appear alone, which appear together, which carry ranges. That is the analysis worth doing, and counting distinct values will never be a substitute for it.</p>



<p class="wp-block-paragraph">Next time you are ordering the columns of a composite index, write down the two or three query shapes that actually matter in your workload, check which of them the key prefix can serve, and let that decide the order. If you find yourself counting distinct values instead, stop, because you are answering a question nobody asked.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-stop-ordering-index-columns-by-selectivity/">SQL Server: Stop ordering index columns by selectivity</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-stop-ordering-index-columns-by-selectivity/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Why UUIDv7 does not reduce fragmentation in SQL Server</title>
		<link>https://www.dbi-services.com/blog/why-uuidv7-does-not-reduce-fragmentation-in-sql-server/</link>
					<comments>https://www.dbi-services.com/blog/why-uuidv7-does-not-reduce-fragmentation-in-sql-server/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Fri, 04 Sep 2026 11:30:42 +0000</pubDate>
				<category><![CDATA[Development & Performance]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[development]]></category>
		<category><![CDATA[primary key]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46589</guid>

					<description><![CDATA[<p>UUIDv7 places its timestamp first, but SQL Server compares uniqueidentifier<br />
values starting from the last bytes. Measured on 25,000 inserts: no gain over NEWID().</p>
<p>L’article <a href="https://www.dbi-services.com/blog/why-uuidv7-does-not-reduce-fragmentation-in-sql-server/">Why UUIDv7 does not reduce fragmentation in SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph">This post came out of the <a href="https://eightkb.online/">8kb conference</a>. <a href="https://www.linkedin.com/in/ACoAAABn0aoBfhQG_7FzQSGQew1NKQe1l6E95Ao?miniProfileUrn=urn%3Ali%3Afs_miniProfile%3AACoAAABn0aoBfhQG_7FzQSGQew1NKQe1l6E95Ao&amp;lipi=urn%3Ali%3Apage%3Ad_flagship3_curation_hub_pf_followers%3B2410dnnZRhCNii8L7k1EQw%3D%3D">Andy Yun&#8217;s</a> session pointed to <a href="https://www.youtube.com/watch?v=JYYHalwjBwA">Jeff Moden&#8217;s <em>Black Arts Index Maintenance</em></a>, a talk that argues fragmentation percentages are the wrong thing to watch and that page density is where the real cost sits. Watching it left me with a question it does not answer: if density is what matters, what happens when the clustering key is a GUID, and does UUIDv7 improve the picture the way it does elsewhere?</p>



<p class="wp-block-paragraph">The answer turns on how SQL Server compares <code>uniqueidentifier</code> values, which is not explicitly documented. So it has to be measured.</p>



<h2 id="h-context" class="wp-block-heading">Context</h2>



<p class="wp-block-paragraph">To showcase it, run this on any instance. No database required.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT g
FROM (VALUES
    (CAST(&#039;ffffffff-ffff-ffff-ffff-000000000000&#039; AS uniqueidentifier)),
    (CAST(&#039;00000000-0000-0000-0000-000000000001&#039; AS uniqueidentifier))
) v(g)
ORDER BY g;
</pre></div>


<p class="wp-block-paragraph">The value starting with&nbsp;<code>ffffffff</code>&nbsp;comes back first, ahead of the one that is almost entirely zeros.</p>



<p class="wp-block-paragraph">That behaviour is intentional, and Microsoft covers it <a href="https://learn.microsoft.com/en-us/sql/t-sql/data-types/uniqueidentifier-transact-sql">in a sentence</a> that is easy to overlook: for&nbsp;<code>uniqueidentifier</code>, <span style="text-decoration: underline">ordering is not implemented by comparing the bit patterns of the two values</span>. The documentation stops there. It tells you the comparison is something other than a plain byte scan, then leaves the actual sequence unstated.</p>



<p class="wp-block-paragraph">The omission has become expensive. Developers are now bringing UUIDv7, the time-ordered identifier standardised in <a href="https://www.rfc-editor.org/rfc/rfc9562.html">RFC 9562</a>, on the reasonable assumption that a leading timestamp turns scattered inserts into an append pattern. That holds where a database compares UUIDs lexicographically over the RFC byte sequence. SQL Server uses a different order, and nothing about the identifier warns you.</p>



<p class="wp-block-paragraph">Since the order is undocumented, the only way to establish it is to measure it.</p>



<h2 id="h-how-the-bytes-are-stored" class="wp-block-heading">How the bytes are stored</h2>



<p class="wp-block-paragraph"><a href="https://learn.microsoft.com/en-us/sql/t-sql/data-types/uniqueidentifier-transact-sql">A&nbsp;<code>uniqueidentifier</code>&nbsp;is a 16-byte value</a>. The five hyphen-separated groups you see on screen are a display convention. Storage follows the Windows GUID structure, which is mixed-endian: the first three groups are byte-reversed on disk, while the last two keep the order you read them in.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT CAST(CAST(&#039;01020304-0506-0708-090a-0b0c0d0e0f10&#039; AS uniqueidentifier) AS binary(16));
</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="265" height="43" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/09/image-5.png" alt="" class="wp-image-46717" /></figure>



<p class="wp-block-paragraph">Every byte here is distinct, so both halves of the rule are visible at once. The first group <code>01020304</code> is stored <code>04 03 02 01</code>, the second <code>0506</code> becomes <code>06 05</code>, the third <code>0708</code> becomes <code>08 07</code>. The fourth and fifth groups appear exactly as written.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>String group</th><th>Stored bytes</th><th>Reversed</th></tr></thead><tbody><tr><td>1st (8 hex)</td><td>0 to 3</td><td>yes</td></tr><tr><td>2nd (4 hex)</td><td>4 to 5</td><td>yes</td></tr><tr><td>3rd (4 hex)</td><td>6 to 7</td><td>yes</td></tr><tr><td>4th (4 hex)</td><td>8 to 9</td><td>no</td></tr><tr><td>5th (12 hex)</td><td>10 to 15</td><td>no</td></tr></tbody></table></figure>



<h2 id="h-the-comparison-order" class="wp-block-heading">The comparison order</h2>



<p class="wp-block-paragraph">The&nbsp;ADO.NET&nbsp;<a href="https://learn.microsoft.com/en-us/dotnet/framework/data/adonet/sql/comparing-guid-and-uniqueidentifier-values">documentation </a>supplies part of the answer. <code>SqlGuid</code>&nbsp;implements&nbsp;<code>CompareTo</code>&nbsp;to match SQL Server behaviour, treating the last six bytes as the most significant, whereas&nbsp;<code>System.Guid</code>&nbsp;evaluates all sixteen.</p>



<p class="wp-block-paragraph">Testing the rest against a live instance produces a sequence that held for every value I tried:</p>



<blockquote class="wp-block-quote is-layout-flow wp-block-quote-is-layout-flow">
<p class="wp-block-paragraph">SQL Server reorders the sixteen stored bytes as&nbsp;<code>10-15, 8-9, 6-7, 4-5, 0-3</code>, then compares them left to right.</p>
</blockquote>



<p class="wp-block-paragraph">A single permutation followed by an ordinary binary comparison, with no special handling per group.</p>



<p class="wp-block-paragraph">Applied to the two values above:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
A = ffffffff-ffff-0010-0000-aaaaaaaaaaaa
B = ffffffff-ffff-2000-0000-aaaaaaaaaaaa

stored     A : FFFFFFFF FFFF 1000 0000 AAAAAAAAAAAA
           B : FFFFFFFF FFFF 0020 0000 AAAAAAAAAAAA

reordered  A : AAAAAAAAAAAA 0000 1000 FFFF FFFFFFFF
           B : AAAAAAAAAAAA 0000 0020 FFFF FFFFFFFF
</pre></div>


<p class="wp-block-paragraph">The first eight bytes are identical in both keys, the ninth settles the comparison, and the remaining seven are never examined.</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="860" height="440" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/guid-read-order.gif" alt="" class="wp-image-46598" /></figure>



<p class="wp-block-paragraph">The six bytes read first are stored positions 10 to 15. In an RFC 4122 UUIDv1 those hold the node field, historically <a href="https://www.rfc-editor.org/rfc/rfc9562.html">derived from the MAC address,</a> though implementations are free to substitute a random value there.</p>



<h2 id="h-testing-it-on-10-000-values" class="wp-block-heading">Testing it on 10,000 values</h2>



<p class="wp-block-paragraph">Hand-picked examples illustrate a rule without showing that it generalises. To check that, rebuild the proposed key in T-SQL and compare the order it predicts against the engine&#8217;s own.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
WITH G AS (
    SELECT TOP (10000) g = NEWID() FROM sys.all_columns a CROSS JOIN sys.all_columns b
), B AS (
    SELECT g, b = CAST(g AS binary(16)) FROM G
), P AS (
    SELECT g, Cle = SUBSTRING(b,11,6) + SUBSTRING(b,9,2) + SUBSTRING(b,7,2)
                  + SUBSTRING(b,5,2)  + SUBSTRING(b,1,4)
    FROM B
), R AS (
    SELECT R1 = ROW_NUMBER() OVER (ORDER BY g),
           R2 = ROW_NUMBER() OVER (ORDER BY Cle)
    FROM P
)
SELECT Mismatches = SUM(CASE WHEN R1 &lt;&gt; R2 THEN 1 ELSE 0 END) FROM R;
</pre></div>


<p class="wp-block-paragraph">Zero mismatches on 10,000 values. Substituting the naive key, sixteen bytes read straight through from left to right, returns 10,000 mismatches on the same set, which at least confirms the test discriminates between the two models.</p>



<p class="wp-block-paragraph">That is strong evidence, but it stops short of proof. Repeated runs, with fresh values each time, returned zero mismatches every time. The implementation itself is undocumented, and what I ran covers one version of SQL Server (2022).</p>



<h2 id="h-where-uuidv7-puts-its-timestamp" class="wp-block-heading">Where UUIDv7 puts its timestamp</h2>



<p class="wp-block-paragraph">RFC 9562 builds UUIDv7 around a 48-bit big-endian Unix timestamp in milliseconds, placed in the leading bytes. The remaining fields carry version and variant bits along with random data, optionally including a sub-millisecond fraction or a counter to <a href="https://www.rfc-editor.org/rfc/rfc9562.html">improve monotonicity inside a single millisecond</a>.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
TTTTTTTT - TTTT - 7RRR - VRRR - RRRRRRRRRRRR
|_____________|                              48-bit timestamp
</pre></div>


<p class="wp-block-paragraph">That timestamp occupies the first two textual groups, so in storage its bytes land in positions 0 to 3 and 4 to 5, both byte-reversed. Positions 4 to 5 are read only after 10 to 15, 8 to 9 and 6 to 7, and positions 0 to 3 come last of all.</p>



<p class="wp-block-paragraph">Which means the fields SQL Server examines first contain version bits, variant bits and random data. Independently generated values separate on those long before the timestamp is reached, so for a clustered index taking single-row inserts the append behaviour UUIDv7 is known for simply does not materialise.</p>



<h2 id="h-measured-on-25-000-inserts" class="wp-block-heading">Measured on 25,000 inserts</h2>



<p class="wp-block-paragraph">Three tables, identical apart from the clustered key,&nbsp;<code>FILLFACTOR 100</code>, 25,000 rows inserted one row per statement. Bulk loading can hide the effect entirely. When SQL Server sets <code>DMLRequestSort</code> on a clustered index insert, it feeds the rows in key order, which <a href="https://sqlperformance.com/2014/10/t-sql-queries/performance-tuning-whole-plan" data-type="link" data-id="https://sqlperformance.com/2014/10/t-sql-queries/performance-tuning-whole-plan">promotes sequential writes and avoids page splitting</a>. Whether it does so depends on the estimated row count, the locking hint and a cost decision, so the safest way to reproduce an OLTP pattern is to insert one row per statement.</p>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th>Clustered key</th><th class="has-text-align-right" data-align="right">Pages</th><th class="has-text-align-right" data-align="right">Page fullness</th><th class="has-text-align-right" data-align="right">Fragmentation</th></tr></thead><tbody><tr><td><code>NEWID()</code></td><td class="has-text-align-right" data-align="right">560</td><td class="has-text-align-right" data-align="right">68.9 %</td><td class="has-text-align-right" data-align="right">99.1 %</td></tr><tr><td>UUIDv7 (RFC 9562)</td><td class="has-text-align-right" data-align="right">568</td><td class="has-text-align-right" data-align="right">67.9 %</td><td class="has-text-align-right" data-align="right">99.1 %</td></tr><tr><td><code>NEWSEQUENTIALID()</code></td><td class="has-text-align-right" data-align="right">391</td><td class="has-text-align-right" data-align="right">98.7 %</td><td class="has-text-align-right" data-align="right">0.5 %</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">About one percentage point separates&nbsp;<code>NEWID()</code>&nbsp;from UUIDv7, and the sign changes between runs, so under this workload UUIDv7 buys you nothing at all.</p>



<p class="wp-block-paragraph">The sequential key holds the same rows in 391 pages rather than 560, roughly 30 % fewer. Since pages are the unit SQL Server reads and caches, a sparse index needs more buffer pool to hold the same rows and more reads to scan them, which puts the cost on memory and I/O well before it shows up as disk space.</p>



<p class="wp-block-paragraph">A few things are worth knowing if you reproduce this. The measurements come from the <code><a href="https://learn.microsoft.com/en-us/sql/relational-databases/system-dynamic-management-views/sys-dm-db-index-physical-stats-transact-sql">sys.dm_db_index_physical_stats</a></code> function, called in <code>DETAILED</code> mode: <code>avg_page_space_used_in_percent</code> comes back <code>NULL</code> under <code>LIMITED</code>, and passing an <code>index_id</code> while <code>object_id</code> is <code>NULL</code> raises an error rather than returning every index. Read page fullness in preference to fragmentation, since a mid-page split leaves both halves partly empty and fullness is the figure that registers it.</p>



<h2 id="h-practical-options" class="wp-block-heading">Practical options</h2>



<p class="wp-block-paragraph">The costly combination here is quite specific: a GUID that is random under SQL Server&#8217;s comparison order, used as the clustering key, fed by sustained single-row inserts. Change any one of those three and the physical consequences shift. GUIDs still earn their place whenever identity has to be generated away from the database, by clients, by distributed services, or by merge replication, which relies on&nbsp;<code>uniqueidentifier</code>&nbsp;<a href="https://learn.microsoft.com/en-us/sql/t-sql/data-types/uniqueidentifier-transact-sql">to keep rows distinct across copies</a>.</p>



<p class="wp-block-paragraph">A sequential clustered key sends every insert to the same last page, and under concurrency those threads all queue on the same <code>PAGELATCH_EX</code>. <a href="https://techcommunity.microsoft.com/blog/sqlserver/behind-the-scenes-on-optimize-for-sequential-key/806888">Microsoft describes what happens next</a>: the insert that triggers a new page holds the latch longer than usual, the queue builds up behind it, and throughput falls off a cliff. <a href="https://techcommunity.microsoft.com/blog/sqlserver/behind-the-scenes-on-optimize-for-sequential-key/806888"><code>OPTIMIZE_FOR_SEQUENTIAL_KEY</code> </a>caps how many threads may queue for the latch, which keeps throughput steadier without removing the contention. </p>



<p class="wp-block-paragraph">The useful question, then, is where the GUID sits in the physical design and which of those two costs your workload actually pays.</p>



<p class="wp-block-paragraph">You can move it off the clustering key while leaving it as the primary key. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/tables/create-primary-keys?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/tables/create-primary-keys?view=sql-server-ver17">SQL Server lets you declare</a> <code>PRIMARY KEY NONCLUSTERED</code> on the GUID and cluster on a narrow <code>IDENTITY</code> column instead. The physical result matches a clustered <code>IDENTITY</code> with a unique constraint on the GUID, but the declaration matches the logical model: the GUID identifies the row, the <code>IDENTITY</code> only orders it on disk.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
CREATE TABLE dbo.T (
    RowId bigint           IDENTITY NOT NULL,
    Id    uniqueidentifier NOT NULL DEFAULT NEWID(),
    CONSTRAINT PK_T PRIMARY KEY NONCLUSTERED (Id),
    INDEX CX_T CLUSTERED (RowId)
);
</pre></div>


<p class="wp-block-paragraph">Either way the table stays dense and the 16-byte cost is confined to one index. The question is also narrower than it looks, since it applies to OLTP tables taking single-row inserts. On the analytical side a clustered columnstore index changes the storage model entirely, page density in the rowstore sense stops being the right measure, and this is a typical solution for some Data Warehouse use-cases (thanks to <a href="https://www.linkedin.com/in/uwericken/" data-type="link" data-id="https://www.linkedin.com/in/uwericken/">Uwe Ricken</a> for the reminder 😉).</p>



<p class="wp-block-paragraph">You can make it sequential instead.&nbsp;<code><a href="https://learn.microsoft.com/en-us/sql/t-sql/functions/newsequentialid-transact-sql">NEWSEQUENTIALID()</a></code>&nbsp;works because it puts the increasing part where SQL Server reads first. Its documented limits are worth reading before you commit: the values are guessable and unsuitable where privacy matters, it only functions as a column&nbsp;<code>DEFAULT</code>, and the sequence can restart from a lower range after a Windows restart.</p>



<p class="wp-block-paragraph">Or accept the cost. That is easy on a small table with rare inserts, where a single rebuild holds and the wasted space is trivial anyway, and harder on a busy random-key table that drifts back to the same density (or higher) after every rebuild.</p>



<p class="wp-block-paragraph">Another solution could be a <a href="https://learn.microsoft.com/en-us/sql/relational-databases/indexes/heaps-tables-without-clustered-indexes?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/indexes/heaps-tables-without-clustered-indexes?view=sql-server-ver17">heap</a>, because it removes the ordering question from the table itself: rows land wherever free space allows, and a nonclustered index on the GUID locates them by RID rather than by the clustering key. It suits tables with little DML. An update that grows a row past its page leaves a forwarding record behind, and the RID then costs an extra read on every lookup that follows it. Deletes have a different drawback: the rows are marked as ghosted, but the emptied pages stay allocated to the table unless the delete takes a table lock or the heap is rebuilt.</p>



<p class="wp-block-paragraph">Whichever way you go, audit what is already in production. A large&nbsp;<code>uniqueidentifier</code>-keyed index sitting near 69 % fullness deserves a look, since it may be taking values whose insertion order is effectively random for SQL Server, whether they come from&nbsp;<code>NEWID()</code>, UUIDv7 or something else. Density only points somewhere. Check fill factor, insert pattern and rebuild history before drawing a conclusion.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT
    &#x5B;Schema]      = OBJECT_SCHEMA_NAME(ps.object_id),
    &#x5B;Table]       = OBJECT_NAME(ps.object_id),
    &#x5B;Index]       = i.name,
    &#x5B;FillFactor]  = NULLIF(i.fill_factor, 0),
    Pages         = ps.page_count,
    Fullness      = CAST(ps.avg_page_space_used_in_percent AS decimal(5,1)),
    Fragmentation = CAST(ps.avg_fragmentation_in_percent  AS decimal(5,1))
FROM sys.dm_db_index_physical_stats(DB_ID(), NULL, NULL, NULL, &#039;SAMPLED&#039;) ps
JOIN sys.indexes i ON i.object_id = ps.object_id AND i.index_id = ps.index_id
JOIN sys.index_columns ic ON ic.object_id = i.object_id
                         AND ic.index_id  = i.index_id
                         AND ic.key_ordinal = 1
JOIN sys.columns c ON c.object_id = ic.object_id AND c.column_id = ic.column_id
WHERE ps.index_level = 0
  AND ps.alloc_unit_type_desc = &#039;IN_ROW_DATA&#039;
  AND ps.page_count &gt; 500
  AND i.type IN (1, 2)
  AND TYPE_NAME(c.system_type_id) = &#039;uniqueidentifier&#039;
ORDER BY ps.avg_page_space_used_in_percent;
</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="538" height="114" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-79.png" alt="" class="wp-image-46603" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-79.png 538w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-79-300x64.png 300w" sizes="auto, (max-width: 538px) 100vw, 538px" /></figure>



<h2 id="h-where-that-leaves-us" class="wp-block-heading">Where that leaves us</h2>



<p class="wp-block-paragraph">An RFC specifies what the bits mean, and nothing in it governs how a storage engine chooses to order them.</p>



<p class="wp-block-paragraph">UUIDv7 delivers what it promises, a sortable timestamp at the start of the RFC byte sequence, and SQL Server applies the comparison order it has always applied, in which that timestamp carries almost no weight. Both behaviours are internally consistent. Put together, under the workload measured here, they produce no clustered-index locality whatsoever, and the version number in the identifier gives you no hint that the temporal ordering has been lost along the way.</p>



<p class="wp-block-paragraph">So when the next identifier scheme arrives described as sequential, the version number and the printed form will settle very little. What counts is where the increasing portion ends up once the engine has applied its storage layout and its comparison order.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/why-uuidv7-does-not-reduce-fragmentation-in-sql-server/">Why UUIDv7 does not reduce fragmentation in SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/why-uuidv7-does-not-reduce-fragmentation-in-sql-server/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>Fixing ORA-00904 on Oracle hidden columns during SSMA data migration</title>
		<link>https://www.dbi-services.com/blog/fixing-ora-00904-on-oracle-hidden-columns-during-ssma-data-migration/</link>
					<comments>https://www.dbi-services.com/blog/fixing-ora-00904-on-oracle-hidden-columns-during-ssma-data-migration/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Wed, 26 Aug 2026 08:54:51 +0000</pubDate>
				<category><![CDATA[Database management]]></category>
		<category><![CDATA[Oracle]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[ssma]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46636</guid>

					<description><![CDATA[<p>SSMA fails with ORA-00904 on Oracle SET UNUSED columns. Fix it with a custom select aliasing NULL, no source DDL required.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/fixing-ora-00904-on-oracle-hidden-columns-during-ssma-data-migration/">Fixing ORA-00904 on Oracle hidden columns during SSMA data migration</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="wp-block-paragraph"><a href="https://learn.microsoft.com/en-us/sql/ssma/sql-server-migration-assistant?view=sql-server-ver17">SSMA </a>(SQL Server Migration Assistant) handles the whole Oracle-to-SQL Server move: it reads the source data dictionary, converts the schema, then generates a SELECT per table to pull the rows across. That last step is where this story goes wrong.</p>



<h2 id="h-the-problem" class="wp-block-heading">The problem</h2>



<p class="wp-block-paragraph">Migrating ~5,600 Oracle tables to SQL Server with SSMA. Most load fine; ~100 tables fail <strong>Migrate Data</strong> with the same error:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ERROR &#x5B;42S22] &#x5B;Oracle]&#x5B;ODBC]&#x5B;Ora]ORA-00904:
&quot;SYS_C00004_21081414:28:22$&quot;: invalid identifier
</pre></div>


<p class="wp-block-paragraph">The named column exists in no DDL anyone wrote. The <code>[Ora]</code> prefix says Oracle itself is rejecting the query: SSMA built an extraction SELECT naming a column Oracle refuses to resolve. Tellingly, <code>SELECT *</code> and <code>COUNT(*)</code> run fine against the same table, whatever this column is, Oracle is happy to ignore it, but not to be asked for it by name.</p>



<h2 id="h-where-the-column-actually-comes-from" class="wp-block-heading">Where the column actually comes from</h2>



<p class="wp-block-paragraph">The name is the giveaway: <code>SYS_C00004_21081414:28:22$</code> is what Oracle calls a column after <code>ALTER TABLE … SET UNUSED COLUMN</code>.</p>



<p class="wp-block-paragraph">Oracle offers two ways to get rid of a column: a logical delete and a physical one. The physical delete (<code>ALTER TABLE … DROP COLUMN</code>) is the honest one, but on a large table it is very time- and resource-consuming. That&#8217;s why people reach for the logical delete instead:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER TABLE table_name SET UNUSED (column_name);
</pre></div>


<p class="wp-block-paragraph">That statement is metadata-only and instant. The column immediately stops being visible to users, and the physical removal is deferred to whenever there is time for it (see <a href="https://oracle-base.com/articles/8i/dropping-columns" data-type="link" data-id="https://oracle-base.com/articles/8i/dropping-columns">Oracle Documentation</a>):</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
ALTER TABLE table_name DROP UNUSED COLUMNS;
-- on large tables, cap undo growth by checkpointing every N rows:
ALTER TABLE table_name DROP UNUSED COLUMNS CHECKPOINT 250;
</pre></div>


<p class="wp-block-paragraph">To free the original name for reuse, Oracle renames the column to <code>SYS_C&lt;internal column number&gt;_&lt;YYMMDDHH24:MI:SS&gt;$</code>, sets <code>USER_GENERATED</code> to NO, <code>HIDDEN_COLUMN</code> to YES and releases its <code>COLUMN_ID</code>. So the timestamp is not when the column was added, it is the second someone ran <code>SET UNUSED</code>. Ours says 14 August 2021, 14:28:22.</p>



<p class="wp-block-paragraph">That also explains the error pattern. The operation is one-way and the column is unreadable by design, so naming it gets you ORA-00904 «invalid identifier». <code>SELECT *</code> and <code>COUNT(*)</code> keep working because the column no longer has a <code>COLUMN_ID</code> and is simply excluded from the star. SSMA, however, lists it and builds an explicit column list Oracle then refuses.</p>



<p class="wp-block-paragraph"><strong>Not to be confused with <code>SYS_NC…$</code>.</strong> Those are a different animal: virtual columns backing a function-based index or extended statistics. </p>



<p class="wp-block-paragraph"><strong>Bottom line:</strong> returning NULL costs you nothing. This is a column its owner already decided to delete, holding data Oracle itself will no longer hand out. There is no information left to lose.</p>



<h2 id="h-what-doesn-t-work-for-the-migration" class="wp-block-heading">What doesn&#8217;t work for the migration</h2>



<ul class="wp-block-list">
<li><strong>SSMA setting </strong><code>Ignore hidden system columns = Yes</code> was not making any effect on this use case</li>



<li><strong>Dropping the column on SQL Server</strong> resolves nothing because the error is on the source SELECT, unaffected.</li>



<li><strong>Dropping it on Oracle</strong> could not be done in our scenario because the source is frozen; DDL not allowed.</li>



<li><strong>Custom select, column removed or bare <code>NULL</code></strong>: SSMA still expects the name in its mapping and fails with &#8220;key not present&#8221; or &#8220;does not match up&#8221; before the query ever reaches Oracle.</li>
</ul>



<h2 id="h-find-them-all-first" class="wp-block-heading">Find them all first</h2>



<p class="wp-block-paragraph">Before editing anything, get the full list. Discovering the affected tables one failed migration at a time is a waste of an afternoon because the data dictionary already knows.</p>



<p class="wp-block-paragraph">The reason the columns are findable at all is an asymmetry between two views: an unused column is gone from <code>ALL_TAB_COLUMNS</code>, but still listed in <code>ALL_TAB_COLS</code> with <code>HIDDEN_COLUMN = 'YES'</code>. That second view is what you query:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT owner,
table_name,
column_name,
data_type,
internal_column_id,
TO_DATE(REGEXP_SUBSTR(column_name, &#039;\d{8}:\d{2}:\d{2}&#039;),
&#039;YYMMDDHH24:MI:SS&#039;) AS set_unused_at
FROM dba_tab_cols
WHERE hidden_column = &#039;YES&#039;
AND user_generated = &#039;NO&#039;
AND REGEXP_LIKE(column_name, &#039;^SYS_C\d+_\d{8}:\d{2}:\d{2}\$$&#039;)
-- AND owner = &#039;&lt;SCHEMA_NAME&gt;&#039;
ORDER BY owner, table_name, internal_column_id;
</pre></div>


<p class="wp-block-paragraph">The regex is deliberately strict: it matches only the <code>SET UNUSED</code> naming pattern, so virtual columns and other system-generated names stay out of the result. </p>



<p class="wp-block-paragraph">One more view is worth a look, as a cross-check:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT owner, table_name, count AS unused_columns
FROM   dba_unused_col_tabs
--WHERE owner = &#039;&lt;SCHEMA_NAME&gt;&#039;
ORDER  BY count DESC, table_name;
</pre></div>


<p class="wp-block-paragraph"><code>DBA_UNUSED_COL_TABS</code> gives the number of unused columns per table. Sorting by that number puts the dangerous tables first: those with two or three hidden columns are the ones where you&#8217;ll forget a line in the custom select and be back at square one.</p>



<h2 id="h-the-fix" class="wp-block-heading">The fix</h2>



<p class="wp-block-paragraph">Keep the hidden column&#8217;s name as an <strong>alias</strong>, but return a literal NULL instead of reading it. SSMA&#8217;s mapping finds the name (no &#8220;key not present&#8221;); Oracle never resolves the real column (no ORA-00904).</p>



<ul class="wp-block-list">
<li><strong>Tools → Project Settings → General → Migration</strong> → enable <strong>Extended data migration options</strong>.</li>
</ul>



<ul class="wp-block-list">
<li><strong>Data Migration Settings</strong> tab → tick <strong>Use custom select</strong> → replace each hidden-column line with:</li>
</ul>



<ol class="wp-block-list"></ol>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
 SELECT ...   
    TO_CHAR(&quot;&lt;COLUMN_NAME&gt;&quot;, &#039;TM&#039;, &#039;NLS_NUMERIC_CHARACTERS = &#039;&#039;.,&#039;&#039;&#039;) as &quot;&lt;COLUMN_NAME&gt;&quot;,
    NULL as &quot;SYS_C00004_21081414:28:22$&quot;
from &lt;OWNER&gt;.&lt;TABLE_NAME&gt; t
</pre></div>


<ul class="wp-block-list">
<li><strong>Migrate Data</strong> → 100%. Drop the NULL-filled column(s) on SQL Server in post-migration cleanup.</li>
</ul>



<ol class="wp-block-list"></ol>



<h2 id="h-takeaway" class="wp-block-heading">Takeaway</h2>



<p class="wp-block-paragraph"><code>SYS_C…$</code> is not an exotic Oracle feature, it&#8217;s an ordinary column someone deleted years ago, logically. Oracle keeps the name on file; SSMA finds it, insists on naming it, and Oracle refuses to hand it over. Aliasing a NULL satisfies both, then you drop the column on the target. No source DDL, no external tooling, everything inside SSMA, behind a project setting that&#8217;s hidden by default.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/fixing-ora-00904-on-oracle-hidden-columns-during-ssma-data-migration/">Fixing ORA-00904 on Oracle hidden columns during SSMA data migration</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/fixing-ora-00904-on-oracle-hidden-columns-during-ssma-data-migration/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
		<item>
		<title>When tempdb write latency points below SQL Server</title>
		<link>https://www.dbi-services.com/blog/when-tempdb-write-latency-points-below-sql-server/</link>
					<comments>https://www.dbi-services.com/blog/when-tempdb-write-latency-points-below-sql-server/#comments</comments>
		
		<dc:creator><![CDATA[Amine Haloui]]></dc:creator>
		<pubDate>Sat, 15 Aug 2026 18:55:35 +0000</pubDate>
				<category><![CDATA[Hardware & Storage]]></category>
		<category><![CDATA[Operating systems]]></category>
		<category><![CDATA[SQL Server]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46474</guid>

					<description><![CDATA[<p>During a recent performance review on a SQL Server 2019 instance (AlwaysOn Failover Cluster Instance, bare-metal), one number stood out. This post follows the investigation: from a latency figure in a DMV, down the I/O path to a RAID controller setting nobody had ever chosen. The starting point: one number from a health check The [&#8230;]</p>
<p>L’article <a href="https://www.dbi-services.com/blog/when-tempdb-write-latency-points-below-sql-server/">When tempdb write latency points below SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<p class="has-text-align-left wp-block-paragraph">During a recent performance review on a SQL Server 2019 instance (AlwaysOn Failover Cluster Instance, bare-metal), one number stood out. This post follows the investigation: from a latency figure in a DMV, down the I/O path to a RAID controller setting nobody had ever chosen.</p>



<h2 id="h-the-starting-point-one-number-from-a-health-check" class="wp-block-heading">The starting point: one number from a health check</h2>



<p class="wp-block-paragraph">The I/O statistics of the instance (sys.dm_io_virtual_file_stats) reported the following for tempdb hosted on a local volume D:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-center" data-align="center"><strong>Files</strong></th><th class="has-text-align-center" data-align="center"><strong>Type</strong></th><th class="has-text-align-center" data-align="center"><strong>Avg write latency</strong></th><th class="has-text-align-center" data-align="center"><strong>Writes</strong></th><th class="has-text-align-center" data-align="center"><strong>Acceptable </strong>t<strong>hreshold</strong></th></tr></thead><tbody><tr><td class="has-text-align-center" data-align="center">8 data files</td><td class="has-text-align-center" data-align="center">ROWS</td><td class="has-text-align-center" data-align="center">about 380 ms</td><td class="has-text-align-center" data-align="center">About 15.8 M each</td><td class="has-text-align-center" data-align="center">20 ms</td></tr><tr><td class="has-text-align-center" data-align="center">Log file</td><td class="has-text-align-center" data-align="center">LOG</td><td class="has-text-align-center" data-align="center">90.6 ms</td><td class="has-text-align-center" data-align="center">2.2 M</td><td class="has-text-align-center" data-align="center">20 ms</td></tr></tbody></table></figure>



<p class="wp-block-paragraph">And the global view of the volume:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-center" data-align="center"><strong>Metric</strong></th><th class="has-text-align-center" data-align="center"><strong>Value</strong></th></tr></thead><tbody><tr><td class="has-text-align-center" data-align="center">Avg read latency</td><td class="has-text-align-center" data-align="center">1.76 ms</td></tr><tr><td class="has-text-align-center" data-align="center">Avg write latency</td><td class="has-text-align-center" data-align="center">375.91 ms</td></tr></tbody></table></figure>



<p class="wp-block-paragraph"><strong>Two details frame the whole investigation:</strong></p>



<ul class="wp-block-list">
<li>Reads are excellent. Writes are 19 times over the threshold. The read path is healthy, the write path is not.</li>



<li>io_stall_write_ms measures the time between I/O submission and completion, queue time included. A high average does not tell us whether each write is slow or whether writes are waiting behind each other.</li>
</ul>



<p class="wp-block-paragraph">When SQL Server performs a write operation, the request goes through the following (simplified) path:</p>


<div class="wp-block-image">
<figure class="aligncenter size-full is-resized"><img loading="lazy" decoding="async" width="220" height="240" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-77.png" alt="" class="wp-image-46488" style="width:317px;height:auto" /></figure>
</div>


<h2 class="wp-block-heading">What it is not</h2>



<p class="wp-block-paragraph">The 8 data files show nearly identical latencies and write counts (about 15.8 M each). The tempdb round-robin allocation works perfectly. This is not a hotspot, not a single bad file. The whole volume is affected.</p>



<p class="wp-block-paragraph">The volume D is local to each cluster node. It is not a shared disk (not on the storage array).</p>



<h2 class="wp-block-heading">Slow media or queuing?</h2>



<p class="wp-block-paragraph">The DMV cannot separate service time from queue time. So we measured the service time directly at idle with WinSAT:</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-center" data-align="center">Measure</th><th class="has-text-align-center" data-align="center">I/O profile</th><th class="has-text-align-center" data-align="center">Result</th><th class="has-text-align-center" data-align="center">IOPS</th></tr></thead><tbody><tr><td class="has-text-align-center" data-align="center">Random read</td><td class="has-text-align-center" data-align="center">16 KB</td><td class="has-text-align-center" data-align="center">451 MB/s</td><td class="has-text-align-center" data-align="center">About 28 900</td></tr><tr><td class="has-text-align-center" data-align="center">Sequential read</td><td class="has-text-align-center" data-align="center">64 KB</td><td class="has-text-align-center" data-align="center">1 965 MB/s</td><td class="has-text-align-center" data-align="center"></td></tr><tr><td class="has-text-align-center" data-align="center">Sequential write</td><td class="has-text-align-center" data-align="center">64 KB</td><td class="has-text-align-center" data-align="center">964 MB/s</td><td class="has-text-align-center" data-align="center"></td></tr><tr><td class="has-text-align-center" data-align="center">Random write</td><td class="has-text-align-center" data-align="center">8 KB (SQL Server page profile)</td><td class="has-text-align-center" data-align="center">394 MB/s</td><td class="has-text-align-center" data-align="center">About 50 500</td></tr><tr><td class="has-text-align-center" data-align="center">Read latency, maximum</td><td class="has-text-align-center" data-align="center"></td><td class="has-text-align-center" data-align="center">3.1 ms</td><td class="has-text-align-center" data-align="center"></td></tr></tbody></table></figure>



<p class="wp-block-paragraph">The media is excellent on all four access profiles. The verdict is simple: the 380 ms are queue time, not service time.</p>



<p class="wp-block-paragraph">A quick calculation confirms it. The DMV counted about 128 M writes over 104 hours of uptime: about 340 writes per second on average. The volume can absorb 50,000. Average utilization: 0.7%. A volume used at 0.7% that shows 376 ms of average latency means one thing: the load is not smooth. It arrives in bursts. During a burst, thousands of I/Os pile up in the queue, each one waits behind the others and since most of the write volume is concentrated in those bursts they dominate the average.</p>



<h2 class="wp-block-heading">Where the bursts come from</h2>



<p class="wp-block-paragraph">On this instance, the bursts are produced by sort and hash operations that do not fit in their memory grant and spill to tempdb, mainly during data loads and some heavy analytical queries. The workload side of this story (memory grants, parallelism, NUMA topology) is covered in this blog : <a href="https://www.dbi-services.com/blog/wait-stats-and-sub-numa-clustering/">https://www.dbi-services.com/blog/wait-stats-and-sub-numa-clustering/</a></p>



<p class="wp-block-paragraph">In this post, we follow the storage path only: whatever the workload does, a burst of writes should not cost 380 ms per I/O on a volume this fast.</p>



<h2 class="wp-block-heading">The invisible layer</h2>



<p class="wp-block-paragraph">When SQL Server writes a page to tempdb, the write goes through this chain:</p>



<p class="wp-block-paragraph">SQL Server &gt; Windows/NTFS &gt; driver (SmartPqi.sys) &gt; Smart Array controller &gt; physical SSDs</p>



<p class="wp-block-paragraph">Windows never talks to the SSDs. It talks to the RAID controller (an HPE Smart Array P408i-a) which assembles two SAS SSDs into a RAID 1 mirror and presents the result as volume D.</p>



<figure class="wp-block-image size-full is-resized"><img loading="lazy" decoding="async" width="258" height="105" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-72.png" alt="" class="wp-image-46477" style="width:286px;height:auto" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="286" height="109" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-73.png" alt="" class="wp-image-46478" /></figure>



<p class="wp-block-paragraph">Reference: <a href="https://support.hpe.com/connect/s/softwaredetails?language=en_US&amp;collectionId=MTX-3d51e7d6b8674f16&amp;tab=releaseNotes">https://support.hpe.com/connect/s/softwaredetails?language=en_US&amp;collectionId=MTX-3d51e7d6b8674f16&amp;tab=releaseNotes</a></p>



<p class="wp-block-paragraph">Here is the key point: every instrument used so far measures through that controller without seeing it. The DMVs measure above it. WinSAT measures above it. Only one question remains open: how is that card configured? And only one tool answers it: the Smart Storage Administrator CLI (ssacli).</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="394" height="109" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-74.png" alt="" class="wp-image-46479" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-74.png 394w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-74-300x83.png 300w" sizes="auto, (max-width: 394px) 100vw, 394px" /></figure>



<h2 id="h-factory-settings" class="wp-block-heading">Factory settings</h2>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ctrl slot=0 show detail
   
   Cache Board Present: True
   Total Cache Size: 2.0
   Cache Status: Not Configured
   Battery/Capacitor Status: OK
   No-Battery Write Cache: Disabled

ctrl slot=0 ld all show detail
   
   Logical Drive: 2          (volume D)
      Fault Tolerance: 1     (RAID 1)
      Caching: Disabled
      LD Acceleration Method: Smart Path
</pre></div>


<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-center" data-align="center">Output</th><th class="has-text-align-center" data-align="center">Value</th><th class="has-text-align-center" data-align="center">Details</th></tr></thead><tbody><tr><td class="has-text-align-center" data-align="center">Cache Board Present / Total Cache Size</td><td class="has-text-align-center" data-align="center">True / 2 GB</td><td class="has-text-align-center" data-align="center">The controller has a write cache module (1.8 GB usable)</td></tr><tr><td class="has-text-align-center" data-align="center">Battery/Capacitor Status</td><td class="has-text-align-center" data-align="center">OK</td><td class="has-text-align-center" data-align="center">Its power-loss protection is healthy</td></tr><tr><td class="has-text-align-center" data-align="center">Cache Status</td><td class="has-text-align-center" data-align="center">Not Configured</td><td class="has-text-align-center" data-align="center">The cache serves no volume: it is idle</td></tr><tr><td class="has-text-align-center" data-align="center">LD Acceleration Method (on D:)</td><td class="has-text-align-center" data-align="center">Smart Path</td><td class="has-text-align-center" data-align="center">The volume uses an I/O path that bypasses the cache</td></tr><tr><td class="has-text-align-center" data-align="center">Caching (on D:)</td><td class="has-text-align-center" data-align="center">Disabled</td><td class="has-text-align-center" data-align="center">Confirmation at the logical drive level</td></tr></tbody></table></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="780" height="552" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-75.png" alt="" class="wp-image-46481" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-75.png 780w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-75-300x212.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-75-768x544.png 768w" sizes="auto, (max-width: 780px) 100vw, 780px" /></figure>



<p class="wp-block-paragraph">HPE SSD Smart Path is a direct I/O path: requests skip the RAID firmware stack and go straight to the SSDs. It saves a few dozen microseconds per I/O which benefits reads. But it is a per-volume switch and it is mutually exclusive with the controller cache. Smart Path is enabled by default on every SSD array (factory default). It&#8217;s reasonable for a read-oriented volume but it was never revisited for a volume hosting tempdb (one of the most write-intensive profiles there is).</p>



<p class="wp-block-paragraph">The consequence: every write must be applied to both SSDs of the mirror and confirmed before it is acknowledged. There is no absorber anywhere in the chain. When a burst arrives the queue explodes.</p>



<figure class="wp-block-table is-style-stripes"><table class="has-fixed-layout"><thead><tr><th class="has-text-align-center" data-align="center"></th><th class="has-text-align-center" data-align="center">Today (Smart Path)</th><th class="has-text-align-center" data-align="center">After (cache enabled)</th></tr></thead><tbody><tr><td class="has-text-align-center" data-align="center">Reads</td><td class="has-text-align-center" data-align="center">Direct path to the SSDs</td><td class="has-text-align-center" data-align="center">Classic path (+ a few dozen microseconds) + read cache</td></tr><tr><td class="has-text-align-center" data-align="center">Writes</td><td class="has-text-align-center" data-align="center">Wait for both SSDs to confirm</td><td class="has-text-align-center" data-align="center">Posted to DRAM: acknowledged in microseconds, mirror written in the background</td></tr></tbody></table></figure>



<h2 class="wp-block-heading">Is enabling the write cache safe?</h2>



<p class="wp-block-paragraph">The old advice &#8220;do not enable write caching&#8221; targets a different cache: the volatile DRAM inside the disks themselves which loses acknowledged writes on power failure. That one stays disabled (Drive Write Cache Policy: Disable).</p>



<p class="wp-block-paragraph">The controller cache is a different story. Microsoft&#8217;s requirement is stable media: an acknowledged write must survive a power failure. This controller qualifies through the flash-backed write cache mechanism:</p>



<ul class="wp-block-list">
<li>On power loss, the battery does not store any data. It powers the cache module for a few seconds just long enough for the controller to copy the DRAM content to the flash NAND chip on the module itself. Flash is non-volatile: the data survives without any power (indefinitely).</li>



<li>At reboot the controller restores that data and writes it to the SSDs of the volume before accepting any new I/O.</li>



<li>If the battery ever fails the controller detects it and automatically falls back to write-through.</li>
</ul>



<p class="wp-block-paragraph">There is a second safety belt specific to this volume: tempdb is recreated at every instance startup. Even in the worst theoretical scenario, there is no data anyone would come back for.</p>



<h2 id="h-the-possible-fix" class="wp-block-heading">The possible fix</h2>



<p class="wp-block-paragraph">Three online reversible commands:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
ssacli ctrl slot=0 array B modify ssdsmartpath=disable
ssacli ctrl slot=0 ld 2 modify caching=enable
ssacli ctrl slot=0 modify cacheratio=10/90
</pre></div>


<p class="wp-block-paragraph"><strong>Why cacheratio=10/90?</strong></p>



<p class="wp-block-paragraph">This setting splits the controller cache: 10% for reads, 90% for writes. It is not an exotic choice, it is the HPE factory default for a configured cache documented as the best ratio for most workloads.</p>



<p class="wp-block-paragraph">Reference: <a href="https://support.hpe.com/hpesc/public/docDisplay?docId=a00019059en_us&amp;page=GUID-EE28F5A4-ADF5-4E27-81AA-8377A267FFA7.html">https://support.hpe.com/hpesc/public/docDisplay?docId=a00019059en_us&amp;page=GUID-EE28F5A4-ADF5-4E27-81AA-8377A267FFA7.html</a></p>



<p class="wp-block-paragraph"></p>



<p class="wp-block-paragraph">Expected result: LD Acceleration Method: Controller Cache on the logical drive, Cache Status: OK on the controller.</p>



<p class="wp-block-paragraph">To validate we should not rely on the cumulative DMV averages they will stay polluted by history. We should measure deltas:</p>



<ul class="wp-block-list">
<li>WinSAT after the change: service time should stay excellent (nothing was broken).</li>



<li>sys.dm_io_virtual_file_stats deltas over a defined window covering the load phases.</li>



<li>PerfMon during the load window: Avg. Disk sec/Write should stay in single digits at the peak of a burst and the queue should drain between bursts.</li>
</ul>



<p class="wp-block-paragraph">One expectation to set correctly: the cache absorbs bursts but it does not add throughput. All the bytes still land on the same two SSDs.</p>



<p class="wp-block-paragraph"><strong>Note:</strong> these commands have not been implemented. They are proposals only. The change must be reviewed, validated and scheduled by the customer before any implementation.</p>



<h2 class="wp-block-heading">Local tempdb volumes on FCI nodes: a good idea</h2>



<p class="wp-block-paragraph">Placing tempdb on a local volume in a Failover Cluster Instance is supported since SQL Server 2012 and it is a good design: tempdb is recreated at startup so there is nothing to fail over. It offloads the shared storage and local SSDs deliver excellent performance for one of the hottest write profiles of the instance (our measurements above prove it).</p>



<p class="wp-block-paragraph">But this choice turns storage health into a per-node responsibility:</p>



<ul class="wp-block-list">
<li>Check the RAID controller configuration on every node. The passive node most likely carries the same factory default. After a failover the problem would silently come back.</li>



<li>Monitor Battery/Capacitor Status. A dead battery silently disables the write cache and brings the symptom back.</li>
</ul>



<p class="wp-block-paragraph">The architecture is right. It just makes your RAID controller part of your database health check.</p>



<p class="wp-block-paragraph">Thank you. <a href="https://www.linkedin.com/in/amine-haloui-76968056/">Amine Haloui</a></p>
<p>L’article <a href="https://www.dbi-services.com/blog/when-tempdb-write-latency-points-below-sql-server/">When tempdb write latency points below SQL Server</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/when-tempdb-write-latency-points-below-sql-server/feed/</wfw:commentRss>
			<slash:comments>1</slash:comments>
		
		
			</item>
		<item>
		<title>SQL Server vs MongoDB: When the cloud is your adversary (Always Encrypted vs Queryable Encryption)</title>
		<link>https://www.dbi-services.com/blog/sql-server-vs-mongodb-when-the-cloud-is-your-adversary-always-encrypted-vs-queryable-encryption/</link>
					<comments>https://www.dbi-services.com/blog/sql-server-vs-mongodb-when-the-cloud-is-your-adversary-always-encrypted-vs-queryable-encryption/#respond</comments>
		
		<dc:creator><![CDATA[Louis Tochon]]></dc:creator>
		<pubDate>Mon, 10 Aug 2026 13:47:55 +0000</pubDate>
				<category><![CDATA[Cloud]]></category>
		<category><![CDATA[Database Administration & Monitoring]]></category>
		<category><![CDATA[MongoDB]]></category>
		<category><![CDATA[NoSQL]]></category>
		<category><![CDATA[SQL Server]]></category>
		<category><![CDATA[NoSQL MongoDB]]></category>
		<guid isPermaLink="false">https://www.dbi-services.com/blog/?p=46282</guid>

					<description><![CDATA[<p>When the cloud admin is the threat: how SQL Server and MongoDB let you query encrypted data, and what each approach costs.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-vs-mongodb-when-the-cloud-is-your-adversary-always-encrypted-vs-queryable-encryption/">SQL Server vs MongoDB: When the cloud is your adversary (Always Encrypted vs Queryable Encryption)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></description>
										<content:encoded><![CDATA[
<h2 id="h-the-real-barrier-to-the-cloud" class="wp-block-heading">The real barrier to the cloud</h2>



<p class="wp-block-paragraph">When a company still refuses to put its sensitive data in the cloud, the reason usually isn&#8217;t cost or performance: it&#8217;s <strong>data sovereignty</strong>. In the cloud, someone else is the administrator of the machine; therefore the provider can, in theory, read the data files, see the data being used in memory, or read the backups. The threat model is no longer the external attacker but the privileged insider hosting the database.</p>



<p class="wp-block-paragraph">As covered previously on my blog <em><a href="https://www.dbi-services.com/blog/tde-tls-data-security-governance-gap-in-lower-environments/">Beyond TDE and TLS: Bridging the Data Security Governance Gap in Lower Environments</a></em>, various encryption methods can protect you. For example, TLS protects data <em>in transit</em> and TDE protects it <em>at rest</em>, but as soon as the engine runs a query, it handles plaintext in memory. So there are three states to protect: <em>at-rest</em>, <em>in-transit</em>, <em>in-use</em>. The gap this article focuses on is the last one.</p>



<p class="wp-block-paragraph">That is exactly what <a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-database-engine?view=sql-server-ver17">Always Encrypted</a> (SQL Server) and <a href="https://www.mongodb.com/docs/manual/core/queryable-encryption/?msockid=17bb9dfe67e76329082e8b4866e962fe">Queryable Encryption</a> (MongoDB) target. The common principle: encryption and decryption happen client-side, in the driver; the keys never reach the engine. The data stays encrypted at rest, in transit, <strong>and</strong> during processing. The DBA, the cloud operator, the hypervisor admin: all of them see only cyphertext.</p>



<p class="wp-block-paragraph">That leaves one question: if the engine sees only cyphertext, how does it answer a <code>WHERE</code> condition? Both database engines do answer it, but through different technical means.</p>



<h2 id="h-sql-server-queryability-lives-in-the-cyphertext" class="wp-block-heading">SQL Server: queryability lives in the cyphertext</h2>



<p class="wp-block-paragraph">To demonstrate all this, let&#8217;s start by creating a table with two columns, Salary and Department, encrypted deterministically on one side and randomized on the other:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
DROP TABLE IF EXISTS dbo.Employees;
CREATE TABLE dbo.Employees (
  Id         INT IDENTITY(1,1) PRIMARY KEY,
  LastName   NVARCHAR(50) COLLATE Latin1_General_BIN2 NOT NULL,
  FirstName  NVARCHAR(50) COLLATE Latin1_General_BIN2 NOT NULL,
  DeptDet    NVARCHAR(30) COLLATE Latin1_General_BIN2 NOT NULL, -- will be DETERMINISTIC
  DeptRand   NVARCHAR(30) COLLATE Latin1_General_BIN2 NOT NULL, -- will be RANDOMIZED
  SalaryDet  INT NOT NULL, -- will be DETERMINISTIC
  SalaryRand INT NOT NULL  -- will be RANDOMIZED
);
GO

INSERT INTO dbo.Employees (LastName, FirstName, DeptDet, DeptRand, SalaryDet, SalaryRand) VALUES
(&#039;Martin&#039;,  &#039;Alice&#039;, &#039;Sales&#039;, &#039;Sales&#039;, 55000, 55000),
(&#039;Dubois&#039;,  &#039;Bob&#039;,   &#039;Sales&#039;, &#039;Sales&#039;, 48000, 48000),
(&#039;Bernard&#039;, &#039;Chloe&#039;, &#039;Sales&#039;, &#039;Sales&#039;, 52000, 52000),
(&#039;Petit&#039;,   &#039;David&#039;, &#039;IT&#039;,    &#039;IT&#039;,    72000, 72000),
(&#039;Durand&#039;,  &#039;Emma&#039;,  &#039;IT&#039;,    &#039;IT&#039;,    68000, 68000);

</pre></div>


<p class="wp-block-paragraph">Five rows: three <code>Sales</code>, two <code>IT</code>. The <code>BIN2</code> collation is required by Always Encrypted (<a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-database-engine?view=sql-server-ver17">link to documentation</a>), and the master key lives outside the database (Key Vault, certificate store, or HSM).</p>



<p class="wp-block-paragraph">Column encryption isn&#8217;t done in T-SQL, because the engine doesn&#8217;t have the keys. It&#8217;s driven from the client (here in PowerShell), declaring for each value a deterministic column and its randomized twin:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: powershell; title: ; notranslate">
Import-Module SqlServer -MinimumVersion 22.0.59
$sqlConnectionString = &quot;Data Source=.\LAB2025;Initial Catalog=AEDEMO;Integrated Security=True;Encrypt=False;Trust Server Certificate=False&quot;
$smoDatabase = Get-SqlDatabase -ConnectionString $sqlConnectionString

$encryptionChanges  = @()
$encryptionChanges += New-SqlColumnEncryptionSettings -ColumnName dbo.Employees.DeptDet    -EncryptionType Deterministic -EncryptionKey &quot;CEK1&quot;
$encryptionChanges += New-SqlColumnEncryptionSettings -ColumnName dbo.Employees.DeptRand   -EncryptionType Randomized    -EncryptionKey &quot;CEK1&quot;
$encryptionChanges += New-SqlColumnEncryptionSettings -ColumnName dbo.Employees.SalaryDet  -EncryptionType Deterministic -EncryptionKey &quot;CEK1&quot;
$encryptionChanges += New-SqlColumnEncryptionSettings -ColumnName dbo.Employees.SalaryRand -EncryptionType Randomized    -EncryptionKey &quot;CEK1&quot;

Set-SqlColumnEncryption -ColumnEncryptionSettings $encryptionChanges -InputObject $smoDatabase

</pre></div>


<p class="wp-block-paragraph">In this example, I&#8217;m working on my 2025 SQL Server instance, on the AEDEMO database, using the column encryption key <code>CEK1</code> I created beforehand (itself protected by a column master key stored outside the database).</p>



<p class="wp-block-paragraph">We check that the engine sees the right type per column:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: sql; title: ; notranslate">
SELECT c.name, c.encryption_type_desc
FROM sys.columns c
WHERE c.object_id = OBJECT_ID(&#039;dbo.Employees&#039;);

</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="350" height="251" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-32.png" alt="" class="wp-image-46293" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-32.png 350w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-32-300x215.png 300w" sizes="auto, (max-width: 350px) 100vw, 350px" /></figure>



<p class="wp-block-paragraph">The deterministic mechanism works like this: <strong>same plaintext, same cyphertext</strong> (an injective function). On an Always Encrypted-enabled connection with <em>Parameterization for Always Encrypted</em> and parameters for the predicates (never literals), equality works:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
DECLARE @d NVARCHAR(30) = &#039;Sales&#039;;
SELECT DeptDet AS Enc, COUNT(*) FROM dbo.Employees WHERE DeptDet = @d
GROUP BY DeptDet;

</pre></div>


<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="274" height="88" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-33.png" alt="" class="wp-image-46295" /></figure>



<p class="wp-block-paragraph">The driver encrypts <code>@d</code> with the same key, the server finds the encrypted values that are identical to the parameter, and the result is correct.</p>



<p class="wp-block-paragraph">You pay for it in three ways&#8230;</p>



<p class="wp-block-paragraph"><strong>First weakness: deterministic encryption can cause a data leak.</strong> You don&#8217;t need the keys to see it. Just connect <em>without</em> Always Encrypted and read the table: the encrypted columns come out as raw binary.</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
SELECT FirstName, DeptDet, DeptRand FROM dbo.Employees;

</pre></div>


<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="171" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-35-1024x171.png" alt="" class="wp-image-46298" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-35-1024x171.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-35-300x50.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-35-768x128.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-35.png 1164w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Look at the <code>DeptDet</code> column: Alice, Bob, and Chloe share <strong>exactly the same blob</strong>, and David and Emma share another. Two distinct values across five rows. The adversary doesn&#8217;t know what <code>0x012536…</code> means, but reads the structure: two departments, one with three people, the other with two, and who goes with whom. The <code>DeptRand</code> column, on the other hand, shows five all-different blobs: nothing to read.</p>



<p class="wp-block-paragraph">It&#8217;s harmless on five rows; it isn&#8217;t on a real table. On a low-cardinality column (region, sex, status), the distribution of blobs can be compared to a known distribution, and frequency analysis often reconstructs the plaintext. <a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-database-engine?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-database-engine?view=sql-server-ver17">Microsoft&#8217;s documentation</a> puts it bluntly: an unauthorized user can guess information by examining patterns, especially when the set of possible values is small.</p>



<p class="wp-block-paragraph"><strong>Second weakness: randomized encryption blocks every query.</strong> With randomized encryption, the driver adds a fresh random value to each cell before encrypting, so the same input produces a different cyphertext every time. Therefore, the leak is gone <span style="text-decoration: underline">but so is the query</span>:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
DECLARE @d NVARCHAR(30) = &#039;Sales&#039;;
SELECT DeptRand AS Enc, COUNT(*) FROM dbo.Employees WHERE DeptRand = @d
GROUP BY DeptRand;  

</pre></div>


<p class="has-vivid-red-color has-text-color has-link-color wp-elements-1 wp-block-paragraph"><em>Msg 33277, Level 16, State 2, Line 6<br>Encryption scheme mismatch for columns/variables &#8216;DeptRand&#8217;, &#8216;@d&#8217;. The<br>encryption scheme for the columns/variables is (encryption_type =<br>&#8216;RANDOMIZED&#8217;, …) and the expression near line &#8216;6&#8217; expects it to be<br>DETERMINISTIC, or RANDOMIZED, a BIN2 collation for string data types,<br>and an enclave-enabled column encryption key, or PLAINTEXT.</em></p>



<p class="wp-block-paragraph">The server can no longer compare, since the same plaintext produces a different cyphertext on every row. We gained confidentiality and lost the query. It&#8217;s all or nothing.</p>



<p class="wp-block-paragraph"><strong>Third weakness: no range, even with deterministic.</strong> Byte equality says nothing about order:</p>


<div class="wp-block-syntaxhighlighter-code "><pre class="brush: plain; title: ; notranslate">
DECLARE @s INT = 55000;
SELECT FirstName FROM dbo.Employees WHERE SalaryDet &amp;lt; @s;

</pre></div>


<p class="wp-block-paragraph"><code>WHERE SalaryDet &lt; @s</code> fails even though the column is deterministic. Sorting, <code>BETWEEN</code>, <code>LIKE</code>: out of reach for Always Encrypted alone.</p>



<p class="wp-block-paragraph">The verdict is clear: bare Always Encrypted means equality, or nothing (=, IN, GROUP BY, and DISTINCT supported).</p>



<h2 id="h-mongodb-queryability-lives-in-a-protocol" class="wp-block-heading">MongoDB: queryability lives in a protocol</h2>



<p class="wp-block-paragraph">MongoDB&#8217;s Queryable Encryption makes optimal use of randomized encryption. On the server side, everything is Randomized-encrypted, as <a href="https://www.mongodb.com/docs/manual/core/queryable-encryption/?msockid=17bb9dfe67e76329082e8b4866e962fe">the documentation explains</a>: <em>the server has no knowledge of the data it processes</em>. The <strong>encrypted view</strong> (a client connected without the keys) confirms it: even the three <code>Sales</code> employees come out with all-different <code>BinData</code>.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="598" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-55-1024x598.png" alt="" class="wp-image-46327" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-55-1024x598.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-55-300x175.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-55-768x448.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-55.png 1374w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">No frequency leak, unlike SQL Server&#8217;s deterministic encryption. With the keys, the same <code>find</code> returns the plaintext:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="970" height="842" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-54.png" alt="" class="wp-image-46325" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-54.png 970w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-54-300x260.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-54-768x667.png 768w" sizes="auto, (max-width: 970px) 100vw, 970px" /></figure>



<p class="wp-block-paragraph">The technical mechanism behind this lies in the collection&#8217;s declaration. Each encrypted field carries a <code>queryType</code>, its parameters, and a distinct key (<code>keyId</code>), one Data Encryption Key per field, which is mandatory:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="979" height="762" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-56.png" alt="" class="wp-image-46328" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-56.png 979w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-56-300x234.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-56-768x598.png 768w" sizes="auto, (max-width: 979px) 100vw, 979px" /></figure>



<p class="wp-block-paragraph">And queries with equality tests, range, and even equality on a field declared as range all work:</p>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="880" height="362" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-59.png" alt="" class="wp-image-46331" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-59.png 880w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-59-300x123.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-59-768x316.png 768w" sizes="auto, (max-width: 880px) 100vw, 880px" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="874" height="259" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-66.png" alt="" class="wp-image-46341" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-66.png 874w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-66-300x89.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-66-768x228.png 768w" sizes="auto, (max-width: 874px) 100vw, 874px" /></figure>



<figure class="wp-block-image size-full"><img loading="lazy" decoding="async" width="778" height="145" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-67.png" alt="" class="wp-image-46342" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-67.png 778w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-67-300x56.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-67-768x143.png 768w" sizes="auto, (max-width: 778px) 100vw, 778px" /></figure>



<p class="wp-block-paragraph">On the server side, MongoDB then maintains encrypted index structures, and for each query the driver generates cryptographic <em>tokens</em> that the server checks against those structures without ever seeing the plaintext. This is a <em><a href="https://www.mongodb.com/docs/manual/core/queryable-encryption/?msockid=17bb9dfe67e76329082e8b4866e962fe">searchable encryption</a></em> scheme. Range is available in <strong>GA</strong>, with no special hardware.</p>



<h2 id="h-the-real-difference-comes-down-to-one-thing-the-driver" class="wp-block-heading">The real difference comes down to one thing: the driver</h2>



<p class="wp-block-paragraph">On the SQL Server side, the driver&#8217;s work stays thin: it encrypts the parameters, rewrites the query, decrypts the results. Queryability itself is already carried by the cyphertext; the driver doesn&#8217;t have to handle it. On the MongoDB side, the driver carries the whole protocol: it generates the tokens checked against the encrypted indexes.</p>



<figure class="wp-block-image size-large"><img loading="lazy" decoding="async" width="1024" height="572" src="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-68-1024x572.png" alt="" class="wp-image-46348" srcset="https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-68-1024x572.png 1024w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-68-300x168.png 300w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-68-768x429.png 768w, https://www.dbi-services.com/blog/wp-content/uploads/sites/2/2026/08/image-68.png 1392w" sizes="auto, (max-width: 1024px) 100vw, 1024px" /></figure>



<p class="wp-block-paragraph">Once the collection is in place, <code>find({ department: "Sales" })</code> is written like a normal query and the driver handles the encryption on its own. Each encrypted field needs its own key (Data Encryption Key). The master key must be pinned, otherwise orphaned keys return an <code>HMAC validation failure</code> error. And the encryption API is only available from a client created as encrypted, so not from a standard Compass connection, for example.</p>



<h2 id="h-summary" class="wp-block-heading">Summary</h2>



<figure class="wp-block-table"><table class="has-fixed-layout"><thead><tr><th></th><th>Always Encrypted</th><th>Queryable Encryption</th></tr></thead><tbody><tr><td>Server-side encryption</td><td>deterministic <em>or</em> randomized</td><td>always randomized</td></tr><tr><td>Equality</td><td>yes (deterministic)</td><td>yes</td></tr><tr><td>Range / sort</td><td>no</td><td>yes (GA)</td></tr><tr><td>Frequency analysis attack</td><td>yes, with deterministic</td><td>no</td></tr><tr><td>Where the search happens</td><td>in the cyphertext</td><td>in the protocol</td></tr><tr><td>Driver weight</td><td>light</td><td>heavy</td></tr></tbody></table></figure>



<h2 id="h-what-if-you-wanted-range-while-staying-on-sql-server" class="wp-block-heading">What if you wanted range while staying on SQL Server?</h2>



<p class="wp-block-paragraph">This is where <strong><a href="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-enclaves?view=sql-server-ver17" data-type="link" data-id="https://learn.microsoft.com/en-us/sql/relational-databases/security/encryption/always-encrypted-enclaves?view=sql-server-ver17">secure enclaves</a></strong> come in. The engine delegates the computation to an enclave: a protected memory region where the data is decrypted and processed in the clear, out of reach, including from the machine&#8217;s administrator. This is what unlocks range, <code>LIKE</code>, sorting, and in-place encryption: inside the enclave the server no longer compares encrypted bytes, it works on the plaintext. This gain has a price. It requires compatible hardware or secure virtualization, an attestation service to deploy and maintain, and keys configured for the enclave, which noticeably increases architectural complexity compared with classic Always Encrypted.</p>



<p class="wp-block-paragraph">But it also changes the <strong>nature of the trust</strong>. Queryable Encryption rests on a cryptographic guarantee: the server <em>cannot</em> read, it&#8217;s a mathematical property. Enclaves rest on a hardware guarantee: you trust the CPU, and its attestation, to isolate the protected region.</p>



<p class="wp-block-paragraph">There remains a third path, often mentioned: homomorphic encryption (FHE), which computes directly on the cyphertext without ever decrypting it. Elegant on paper, but out of the game for database search: the computational cost is massive and response time collapses as the volume grows. So the practical choice really does play out between the two worlds described here.</p>
<p>L’article <a href="https://www.dbi-services.com/blog/sql-server-vs-mongodb-when-the-cloud-is-your-adversary-always-encrypted-vs-queryable-encryption/">SQL Server vs MongoDB: When the cloud is your adversary (Always Encrypted vs Queryable Encryption)</a> est apparu en premier sur <a href="https://www.dbi-services.com/blog">dbi Blog</a>.</p>
]]></content:encoded>
					
					<wfw:commentRss>https://www.dbi-services.com/blog/sql-server-vs-mongodb-when-the-cloud-is-your-adversary-always-encrypted-vs-queryable-encryption/feed/</wfw:commentRss>
			<slash:comments>0</slash:comments>
		
		
			</item>
	</channel>
</rss>

<!--
Performance optimized by W3 Total Cache. Learn more: https://www.boldgrid.com/w3-total-cache/?utm_source=w3tc&utm_medium=footer_comment&utm_campaign=free_plugin

Page Caching using Disk: Enhanced 
Lazy Loading (feed)

Served from: www.dbi-services.com @ 2026-10-16 16:41:50 by W3 Total Cache
-->