Reprinted with Permission by Quest Software Oct. 2006


Ad-hoc SQL is Bloating My Instance
by Julien Crawford

The Scenario

"My instance is using 100's of meg RAM for caching queries that only ever run once". Poor application implementation can cause database admin angst. Often the developer innocently writes up their code to build SQL commands, which then are passed to a middle layer to execute. I have done this myself back when I was a c/c++ developer. However, had I the know-how, I would have used parameterized/prepared queries; the reason being that SQLServer (and probably other databases) do clever things to make it fast. The one I want to discuss is the 'prepare' command.

Consider the SQL:

insert into mytable (n,m) values (1,2)
insert into mytable (n,m) values (3,4)

which will have two entries in the cache. Yet:

insert into mytable (n,m) values (@n,@m)	

will have one entry in the cache that can be used to insert any values for @n and @m.

The Analysis

The supplied SQL shows the number of entries for similar ad-hoc SQL, the potential memory savings of each, and the total saving available. Be sure to load the SQL function 'qs_fn_stripdownchars' appended at the bottom of this article, as it is required for much of the other SQL here.

-- SQL: Potential Memory saving moving to Prepared SQL Commnands.
select	count(*)	SimilarCacheEntries
	,	(sum(pagesused))*8	TotalKB
	,	(sum(pagesused)-avg(pagesused))*8	PotentialKBSaving
	,	dbo.qs_fn_stripdownchars((c.sql),0)	thesql
into	#t
from	master.dbo.syscacheobjects	c
where	c.objtype = 'Adhoc'
group by dbo.qs_fn_stripdownchars((c.sql),0)
order by SimilarCacheEntries desc

select	*
from	#t

select	sum(PotentialKBSaving)/1024.0	TotalPotentialMBSaving
	,	sum(TotalKB)/1024.0 TotalAdhocMB
from	#t

drop table #t

Be sure to run this query multiple times for a couple of minutes, because as a cache, the old entries get flushed out and this will skew the values.

The maximum value over all your runs in the allotted time period (1 minute on my load-simulated environment) is the amount of memory essentially wasted. This memory would be better served holding something that is accessed more than once.

** Ideally your cache contents should be mostly stable. **

You can more easily test the movement in your cache by running the following SQL.

-- SQL: Moving Cache size, and Adhoc percentage.
set nocount on
declare @t table (dts datetime, AdhocSizeMB numeric(20,10), CacheSizeMB numeric(20,10))

declare @nDuration int
set		@nDuration = 60
declare @dts datetime
set		@dts = getdate()
while	getdate() < dateadd(second,@nDuration,@dts)
begin
	insert into @t (dts,AdhocSizeMB,CacheSizeMB)
	select	getdate()
		,	sum(case when objtype = 'Adhoc' then pagesused else 0 end)*(8/1024.0)	AdhocSizeMB
		,	sum(pagesused)*(8/1024.0)	CacheSizeMB
	from	master.dbo.syscacheobjects

	waitfor delay '00:00:02'
end
set nocount off
select	*
	,	convert(numeric(10,2),(100.0*AdhocSizeMB)/CacheSizeMB)
			AdhocSQLPercent
from	@t

The Current Usage

Now let's look at the current usage of these ad-hoc SQL commands. The following SQL will list the number of sessions using similar ad-hoc SQL and the distinct number of handles used by each. If the number of handles = 1, this is excellent as its probably prepared SQL. (Prepared SQL begins with a left bracket.)

-- SQL 2005: How many sessions are running Ad-hoc SQL that could be sharing the same plans?
select	db_name(y.dbid) DBName
	,	count(*) nSessions
	,	count(distinct y.sql_handle)	distinct_handles
	,	sum(y.waittime) waittime
	,	sum(y.waittime)/count(*) WaittimePerSimilarSQL
	,	y.thesql_clean
	,	max(y.open_tran)	open_tran
from	(
	select	x.sql_handle
		,	x.dbid
		,	x.waittime
		,	(dbo.qs_fn_stripdownchars((sql.text),0))	thesql_clean
		,	x.open_tran
		,	x.spid
		,	x.blocked
	from	master.dbo.sysprocesses	x
	cross apply sys.dm_exec_sql_text(x.sql_handle) sql
	where	x.spid>50
)	y
group by y.dbid, y.thesql_clean
order by nSessions desc

-- SQL 2000: How many sessions are running Ad-hoc SQL that could be sharing the same plans?
set nocount on
select	x.sql_handle
	,	x.dbid
	,	x.waittime
	,	x.open_tran
	,	x.spid
	,	x.blocked
	,	convert(nvarchar(3500),null) sql
into	#t
from	master.dbo.sysprocesses	x
where	x.spid>20

-- populate the sql foreach session 
declare @handle binary(20)
while 1=1
begin
	select @handle = sql_handle
	from	#t where sql is null
	if @handle is null
		break

	update	t
	set		sql = case when @handle = 
	0x0000000000000000000000000000000000000000 then '' else 
	isnull((select convert(nvarchar(2000),text) from 
	::fn_get_sql(@handle)),'<expired sql handle>') end
	from	#t t
	where	t.sql_handle = @handle

	set @handle = null
end

set nocount off
select	db_name(max(x.dbid)) DBName
	,	count(*) nSessions
	,	count(distinct x.sql_handle)	distinct_handles
	,	sum(x.waittime) waittime
	,	sum(x.waittime)/count(*) WaittimePerSimilarSQL
	,	x.thesql_clean
	,	max(x.open_tran)	open_tran
from	(
	select	*
	,	(dbo.qs_fn_stripdownchars(t.sql,0))	thesql_clean
	from	#t t
)	x
group by thesql_clean
order by nSessions desc

drop table #t

The quick and dirty fix? Turn your SQL commands into stored procedures. This way the only ad-hoc commands are calls to the stored procedures. These will typically be smaller than the SQL contained within the procedure. Of course, all you have done is slow the growth of the cache; it is still going to suffer bloat if it did before, but at a lesser rate.

The Summary

In the end you need to decide if the RAM used by ad-hoc queries is worth recovering at the cost of fixing applications – as this is often seen as high risk, when in it is often just as risky. That however, is the subject of another article – "Application fix verses SQLServer fix". Watch this space.

-- qs_fn_stripdownchars: Required for above SQL.
if exists	(select	*
             from sysobjects
             where id = object_id(N'qs_fn_stripdownchars')
               and OBJECTPROPERTY(id,	N'IsScalarFunction')	=	1)
  drop function qs_fn_stripdownchars
go

create function qs_fn_stripdownchars
(	@s	nvarchar(4000), @nShowPlacements int = 0)
returns nvarchar(4000)
as
begin
	declare	@s2	nvarchar(4000)
	declare @c	nchar(2)
	declare @p	int
	set		@p	= 0
	set		@s2	= ''
	declare @pComment	int
	set		@pComment = 1

	declare @sComment1			sysname
	declare @sComment2			sysname
	declare @sNumber			sysname
	declare @sConstantString	sysname
	if (@nShowPlacements = 0)
		select	@sComment1	= ''
			,	@sComment2	= ''
			,	@sNumber	= ''
			,	@sConstantString	= ''
	else
		select	@sComment1		= '<comment1>'
			,	@sComment2		= '<comment2>'
			,	@sNumber		= '<number>'
			,	@sConstantString= '<constant-string>'

	declare @nMaxLoops	int		-- Defensive programming to avoid infinite looping (as the conditions for looping are odd)
	set		@nMaxLoops = 100
	while @nMaxLoops>0
	begin
		set @nMaxLoops = @nMaxLoops - 1
		select	@p	= patindex('%[0-9'']%',@s)
		if (@p is null or @p = 0)
			break

		begin
			select	@pComment = charindex('--',@s)	-- From the Previous Value
			if @pComment < @p and @pComment>0
				set @p = @pComment
			
			select	@pComment = charindex('/*',@s)	-- From the Previous Value
			if @pComment < @p and @pComment>0
				set @p = @pComment
		end

		set	@c = substring(@s,@p,2)
		if (substring(@c,1,1) = '''')	-- Is A Quote
		begin
			set @s2 = @s2 + substring(@s,1,@p-1) + @sConstantString  	-- Copy the fixed chars.
			set	@p	= charindex('''',@s,@p+1)
			set @s	= substring(@s,@p+1,4000)	-- Reassign @s to AFTER the next Quote.
		end
		else if (@c = '--')
		begin
			set @s2 = @s2 + substring(@s,1,@p-1) + @sComment1	-- Copy the fixed chars.
			set @p  = charindex(convert(nchar(1),0x0D),@s,@p+1)	-- Up to the newline!
			if (@p = 0)
				set @p = len(@s)
			set @s	= substring(@s,@p+1,4000)	-- Reassign @s to AFTER the next Quote.
		end
		else if (@c = '/*')
		begin
			set @s2 = @s2 + substring(@s,1,@p-1) + @sComment2  	-- Copy the fixed chars.
			set @p  = charindex('*/',@s,@p+1)
			if (@p = 0)
				set @p = len(@s)
			set @s	= substring(@s,@p+2,4000)	-- Reassign @s to AFTER the next Quote.
		end
		else
		begin
			set @s2 = @s2 + substring(@s,1,@p-1) + @sNumber	-- Copy the fixed chars.
			set @s	= substring(@s,@p+1,4000)	
			set @s	= substring(@s,patindex('%[^0-9]%',@s), 4000)	--  Reassign @s to AFTER the next Non-number.
		end
	end

	return @s2 + @s
end
go

Julien Crawford graduated from the University of Melbourne Australia in 1995 and joined the Software Industry as a C/C++ Developer. Initially an Application developer, he moved to server-side coding and middleware coding within a few years. Julien is currently a SQLServer Developer – writing code in TSQL stored procedures and doing work in conjunction with server-side and client-side developers.