Database
 sql >> Base de Dados >  >> RDS >> Database

Como escrever uma consulta com vários comportamentos


Muitas vezes, quando escrevemos um procedimento armazenado, queremos que ele se comporte de maneiras diferentes com base na entrada do usuário. Vejamos o seguinte exemplo:
  CREATE PROCEDURE
  	Sales.GetOrders
  (
  	@CustomerID	AS INT			= NULL ,
  	@SortOrder	AS SYSNAME		= N'OrderDate'
  )
  AS
  SELECT TOP (10)
  	SalesOrderID	         = SalesOrders.SalesOrderID ,
  	OrderDate		= CAST (SalesOrders.OrderDate AS DATE) ,
  	OrderStatus		= SalesOrders.[Status] ,
  	CustomerID		= SalesOrders.CustomerID ,
  	OrderTotal		= SUM (SalesOrderDetails.LineTotal)
  FROM
  	Sales.SalesOrderHeader AS SalesOrders
  INNER JOIN
  	Sales.SalesOrderDetail AS SalesOrderDetails
  ON
  	SalesOrders.SalesOrderID = SalesOrderDetails.SalesOrderID
  WHERE
  	SalesOrders.CustomerID = @CustomerID OR @CustomerID IS NULL
  GROUP BY
  	SalesOrders.SalesOrderID ,
  	SalesOrders.OrderDate ,
  	SalesOrders.DueDate ,
  	SalesOrders.[Status] ,
  	SalesOrders.CustomerID
  ORDER BY
  	CASE @SortOrder
  		WHEN N'OrderDate'
  			THEN SalesOrders.OrderDate
  		WHEN N'SalesOrderID'
  			THEN SalesOrders.SalesOrderID
  	END ASC;
  GO

Esse procedimento armazenado, que criei no banco de dados AdventureWorks2017, tem dois parâmetros:@CustomerID e @SortOrder. O primeiro parâmetro, @CustomerID, afeta as linhas a serem retornadas. Se um ID de cliente específico for passado para o procedimento armazenado, ele retornará todos os pedidos (10 principais) para esse cliente. Caso contrário, se for NULL, o procedimento armazenado retornará todos os pedidos (10 principais), independentemente do cliente. O segundo parâmetro, @SortOrder, determina como os dados serão classificados — por OrderDate ou por SalesOrderID. Observe que apenas as primeiras 10 linhas serão retornadas de acordo com a ordem de classificação.

Assim, os usuários podem afetar o comportamento da consulta de duas maneiras:quais linhas retornar e como classificá-las. Para ser mais preciso, existem 4 comportamentos diferentes para esta consulta:
  1. Retornar as 10 principais linhas de todos os clientes classificados por OrderDate (o comportamento padrão)
  2. Retornar as 10 principais linhas de um cliente específico classificado por OrderDate
  3. Retornar as 10 principais linhas de todos os clientes classificados por SalesOrderID
  4. Retornar as 10 principais linhas de um cliente específico classificado por SalesOrderID

Vamos testar o procedimento armazenado com todas as 4 opções e examinar o plano de execução e as estatísticas de E/S.

Retorne as 10 principais linhas de todos os clientes classificados por OrderDate


Segue o código para executar o procedimento armazenado:
  EXECUTE Sales.GetOrders;
  GO

Segue o plano de execução:



Como não filtramos por cliente, precisamos varrer toda a tabela. O otimizador optou por verificar ambas as tabelas usando índices em SalesOrderID, o que permitiu um Stream Aggregate eficiente, bem como um Merge Join eficiente.

Se você verificar as propriedades do operador Clustered Index Scan na tabela Sales.SalesOrderHeader, encontrará o seguinte predicado:[AdventureWorks2017].[Sales].[SalesOrderHeader].[CustomerID] as [SalesOrders].[CustomerID]=[ @CustomerID] OU [@CustomerID] É NULO. O processador de consultas precisa avaliar esse predicado para cada linha da tabela, o que não é muito eficiente porque sempre será avaliado como verdadeiro.

Ainda precisamos classificar todos os dados por OrderDate para retornar as 10 primeiras linhas. Se houvesse um índice em OrderDate, o otimizador provavelmente o teria usado para verificar apenas as 10 primeiras linhas de Sales.SalesOrderHeader, mas não existe tal índice, então o plano parece bom considerando os índices disponíveis.

Aqui está a saída das estatísticas IO:
  • Tabela 'SalesOrderHeader'. Contagem de varredura 1, leituras lógicas 689
  • Tabela 'Detalhes do pedido de vendas'. Contagem de varredura 1, leituras lógicas 1248

Se você está perguntando por que há um aviso no operador SELECT, então é um aviso de concessão excessiva. Nesse caso, não é porque há um problema no plano de execução, mas porque o processador de consultas solicitou 1.024 KB (que é o mínimo por padrão) e usou apenas 16 KB.

Às vezes, planejar o cache não é uma boa ideia


Em seguida, queremos testar o cenário de retorno das 10 principais linhas de um cliente específico classificado por OrderDate. Abaixo segue o código:
  EXECUTE Sales.GetOrders
  	@CustomerID	= 11006;
  GO

O plano de execução é exatamente o mesmo de antes. Desta vez, o plano é muito ineficiente porque varre as duas tabelas apenas para retornar 3 pedidos. Existem maneiras muito melhores de executar essa consulta.

A razão, neste caso, é o cache do plano. O plano de execução foi gerado na primeira execução com base nos valores de parâmetro nessa execução específica - um método conhecido como sniffing de parâmetro. Esse plano foi armazenado no cache do plano para reutilização e, a partir de agora, todas as chamadas para esse procedimento armazenado reutilizarão o mesmo plano.

Este é um exemplo em que o cache do plano não é uma boa ideia. Devido à natureza desse procedimento armazenado, que possui 4 comportamentos diferentes, esperamos obter um plano diferente para cada comportamento. Mas estamos presos a um único plano, que serve apenas para uma das 4 opções, com base na opção usada na primeira execução.

Vamos desabilitar o cache do plano para este procedimento armazenado, apenas para que possamos ver o melhor plano que o otimizador pode criar para cada um dos outros 3 comportamentos. Faremos isso adicionando WITH RECOMPILE ao comando EXECUTE.

Retorne as 10 principais linhas de um cliente específico classificadas por OrderDate


Veja a seguir o código para retornar as 10 principais linhas de um cliente específico classificado por OrderDate:
  EXECUTE Sales.GetOrders
  	@CustomerID	= 11006
  WITH
  	RECOMPILE;
  GO

Segue o plano de execução:



Desta vez, temos um plano melhor, que usa um índice no CustomerID. O otimizador estima corretamente 2,6 linhas para CustomerID =11006 (o número real é 3). Mas observe que ele executa uma varredura de índice em vez de uma busca de índice. Ele não pode realizar uma busca de índice porque precisa avaliar o seguinte predicado para cada linha na tabela:[AdventureWorks2017].[Sales].[SalesOrderHeader].[CustomerID] as [SalesOrders].[CustomerID]=[@CustomerID ] OU [@CustomerID] É NULO.

Aqui está a saída das estatísticas IO:
  • Tabela 'Detalhes do pedido de vendas'. Contagem de varredura 3, leituras lógicas 9
  • Tabela 'SalesOrderHeader'. Contagem de varredura 1, leituras lógicas 66

Retorne as 10 principais linhas de todos os clientes classificados por SalesOrderID


Veja a seguir o código para retornar as 10 principais linhas de todos os clientes classificados por SalesOrderID:
  EXECUTE Sales.GetOrders
  	@SortOrder	= N'SalesOrderID'
  WITH
  	RECOMPILE;
  GO

Segue o plano de execução:



Ei, este é o mesmo plano de execução da primeira opção. Mas desta vez, algo está errado. Já sabemos que os índices clusterizados em ambas as tabelas são classificados por SalesOrderID. Também sabemos que o plano verifica ambos na ordem lógica para manter a ordem de classificação (a propriedade Ordered é definida como True). O operador Merge Join também mantém a ordem de classificação. Como agora estamos pedindo para classificar o resultado por SalesOrderID, e ele já está classificado dessa maneira, por que temos que pagar por um operador Sort caro?

Bem, se você verificar o operador Sort, notará que ele ordena os dados de acordo com Expr1004. E, se você verificar o operador Compute Scalar à direita do operador Sort, descobrirá que Expr1004 é o seguinte:



Não é uma visão bonita, eu sei. Esta é a expressão que temos na cláusula ORDER BY da nossa consulta. O problema é que o otimizador não pode avaliar essa expressão em tempo de compilação, portanto, ele precisa calculá-la para cada linha em tempo de execução e, em seguida, classificar todo o conjunto de registros com base nisso.

A saída das estatísticas IO é como na primeira execução:
  • Tabela 'SalesOrderHeader'. Contagem de varredura 1, leituras lógicas 689
  • Tabela 'Detalhes do pedido de vendas'. Contagem de varredura 1, leituras lógicas 1248

Retorne as 10 principais linhas de um cliente específico classificado por SalesOrderID


Veja a seguir o código para retornar as 10 principais linhas de um cliente específico classificado por SalesOrderID:
  EXECUTE Sales.GetOrders
  	@CustomerID	= 11006 ,
  	@SortOrder	= N'SalesOrderID'
  WITH
  	RECOMPILE;
  GO

O plano de execução é o mesmo da segunda opção (retorne as 10 principais linhas de um cliente específico classificado por OrderDate). O plano tem os mesmos dois problemas, que já mencionamos. O primeiro problema é executar uma varredura de índice em vez de uma busca de índice devido à expressão na cláusula WHERE. O segundo problema é realizar uma classificação cara devido à expressão na cláusula ORDER BY.

Então, o que devemos fazer?


Vamos nos lembrar primeiro com o que estamos lidando. Temos parâmetros, que determinam a estrutura da consulta. Para cada combinação de valores de parâmetro, obtemos uma estrutura de consulta diferente. No caso do parâmetro @CustomerID, os dois comportamentos diferentes são NULL ou NOT NULL e afetam a cláusula WHERE. No caso do parâmetro @SortOrder, existem dois valores possíveis e eles afetam a cláusula ORDER BY. O resultado são 4 estruturas de consulta possíveis, e gostaríamos de obter um plano diferente para cada uma.

Então temos dois problemas distintos. O primeiro é o cache do plano. Existe apenas um único plano para o procedimento armazenado e ele será gerado com base nos valores dos parâmetros na primeira execução. O segundo problema é que mesmo quando um novo plano é gerado, ele não é eficiente porque o otimizador não pode avaliar as expressões "dinâmicas" na cláusula WHERE e na cláusula ORDER BY em tempo de compilação.

Podemos tentar resolver esses problemas de várias maneiras:
  1. Use uma série de instruções IF-ELSE
  2. Divida o procedimento em procedimentos armazenados separados
  3. Usar OPÇÃO (RECOMPILAR)
  4. Gere a consulta dinamicamente

Use uma série de declarações IF-ELSE


A ideia é simples:em vez das expressões "dinâmicas" na cláusula WHERE e na cláusula ORDER BY, podemos dividir a execução em 4 ramificações usando instruções IF-ELSE - uma ramificação para cada comportamento possível.

Por exemplo, o seguinte é o código para a primeira ramificação:
  IF
  	@CustomerID IS NULL
  AND
  	@SortOrder = N'OrderDate'
  BEGIN
  	SELECT TOP (10)
  		SalesOrderID	        = SalesOrders.SalesOrderID ,
  		OrderDate		= CAST (SalesOrders.OrderDate AS DATE) ,
  		OrderStatus		= SalesOrders.[Status] ,
  		CustomerID		= SalesOrders.CustomerID ,
  		OrderTotal		= SUM (SalesOrderDetails.LineTotal)
  	FROM
  		Sales.SalesOrderHeader AS SalesOrders
  	INNER JOIN
  		Sales.SalesOrderDetail AS SalesOrderDetails
  	ON
  		SalesOrders.SalesOrderID = SalesOrderDetails.SalesOrderID
  	GROUP BY
  		SalesOrders.SalesOrderID,
  		SalesOrders.OrderDate,
  		SalesOrders.DueDate,
  		SalesOrders.[Status],
  		SalesOrders.CustomerID
  	ORDER BY
  		SalesOrders.OrderDate ASC;
  END;

Essa abordagem pode ajudar a gerar planos melhores, mas tem algumas limitações.

Primeiro, o procedimento armazenado se torna bastante longo e é mais difícil escrever, ler e manter. E isso é quando temos apenas dois parâmetros. Se tivéssemos 3 parâmetros, teríamos 8 ramos. Imagine que você precisa adicionar uma coluna à cláusula SELECT. Você teria que adicionar a coluna em 8 consultas diferentes. Torna-se um pesadelo de manutenção, com alto risco de erro humano.

Em segundo lugar, ainda temos o problema de cache de planos e sniffing de parâmetros até certo ponto. Isso porque na primeira execução, o otimizador vai gerar um plano para todas as 4 consultas com base nos valores dos parâmetros dessa execução. Digamos que a primeira execução usará os valores padrão para os parâmetros. Especificamente, o valor de @CustomerID será NULL. Todas as consultas serão otimizadas com base nesse valor, incluindo a consulta com a cláusula WHERE (SalesOrders.CustomerID =@CustomerID). O otimizador estimará 0 linhas para essas consultas. Agora, digamos que a segunda execução usará um valor não nulo para @CustomerID. O plano em cache, que estima 0 linhas, será usado, mesmo que o cliente tenha muitos pedidos na tabela.

Divida o procedimento em procedimentos armazenados separados


Em vez de 4 ramificações dentro do mesmo procedimento armazenado, podemos criar 4 procedimentos armazenados separados, cada um com os parâmetros relevantes e a consulta correspondente. Em seguida, podemos reescrever o aplicativo para decidir qual procedimento armazenado executar de acordo com os comportamentos desejados. Ou, se quisermos que seja transparente para o aplicativo, podemos reescrever o procedimento armazenado original para decidir qual procedimento executar com base nos valores dos parâmetros. Vamos usar as mesmas instruções IF-ELSE, mas em vez de executar uma consulta em cada ramificação, executaremos um procedimento armazenado separado.

A vantagem é que resolvemos o problema de cache de plano porque cada procedimento armazenado agora tem seu próprio plano, e o plano para cada procedimento armazenado será gerado em sua primeira execução com base na detecção de parâmetros.

Mas ainda temos o problema de manutenção. Algumas pessoas podem dizer que agora é ainda pior, porque precisamos manter vários procedimentos armazenados. Novamente, se aumentarmos o número de parâmetros para 3, acabaríamos com 8 procedimentos armazenados distintos.

Usar OPÇÃO (RECOMPILAR)


OPÇÃO (RECOMPILAR) funciona como mágica. Você só precisa dizer as palavras (ou anexá-las à consulta) e a mágica acontece. Realmente, ele resolve muitos problemas porque compila a consulta em tempo de execução e faz isso para todas as execuções.

Mas você deve ter cuidado porque sabe o que dizem:"Com grandes poderes vêm grandes responsabilidades". Se você usar OPTION (RECOMPILE) em uma consulta que é executada com muita frequência em um sistema OLTP ocupado, poderá matar o sistema porque o servidor precisa compilar e gerar um novo plano em cada execução, usando muitos recursos da CPU. Isso é realmente perigoso. No entanto, se a consulta for executada apenas de vez em quando, digamos uma vez a cada poucos minutos, provavelmente é seguro. Mas sempre teste o impacto em seu ambiente específico.

No nosso caso, supondo que podemos usar OPTION (RECOMPILE) com segurança, tudo o que precisamos fazer é adicionar as palavras mágicas no final de nossa consulta, conforme mostrado abaixo:
  ALTER PROCEDURE
  	Sales.GetOrders
  (
  	@CustomerID	AS INT			= NULL ,
  	@SortOrder	AS SYSNAME		= N'OrderDate'
  )
  AS
  SELECT TOP (10)
  	SalesOrderID	        = SalesOrders.SalesOrderID ,
  	OrderDate		= CAST (SalesOrders.OrderDate AS DATE) ,
  	OrderStatus		= SalesOrders.[Status] ,
  	CustomerID		= SalesOrders.CustomerID ,
  	OrderTotal		= SUM (SalesOrderDetails.LineTotal)
  FROM
  	Sales.SalesOrderHeader AS SalesOrders
  INNER JOIN
  	Sales.SalesOrderDetail AS SalesOrderDetails
  ON
  	SalesOrders.SalesOrderID = SalesOrderDetails.SalesOrderID
  WHERE
  	SalesOrders.CustomerID = @CustomerID OR @CustomerID IS NULL
  GROUP BY
  	SalesOrders.SalesOrderID ,
  	SalesOrders.OrderDate ,
  	SalesOrders.DueDate ,
  	SalesOrders.[Status] ,
  	SalesOrders.CustomerID
  ORDER BY
  	CASE @SortOrder
  		WHEN N'OrderDate'
  			THEN SalesOrders.OrderDate
  		WHEN N'SalesOrderID'
  			THEN SalesOrders.SalesOrderID
  	END ASC
  OPTION
  	(RECOMPILE);
  GO

Agora, vamos ver a magia em ação. Por exemplo, o seguinte é o plano para o segundo comportamento:
  EXECUTE Sales.GetOrders
  	@CustomerID	= 11006;
  GO



Agora obtemos uma busca de índice eficiente com uma estimativa correta de 2,6 linhas. Ainda precisamos classificar por OrderDate, mas agora a classificação é feita diretamente por Order Date e não precisamos mais calcular a expressão CASE na cláusula ORDER BY. Este é o melhor plano possível para este comportamento de consulta com base nos índices disponíveis.

Aqui está a saída das estatísticas IO:
  • Tabela 'Detalhes do pedido de vendas'. Contagem de varredura 3, leituras lógicas 9
  • Tabela 'SalesOrderHeader'. Contagem de varredura 1, leituras lógicas 11

A razão pela qual OPTION (RECOMPILE) é tão eficiente neste caso é que resolve exatamente os dois problemas que temos aqui. Lembre-se de que o primeiro problema é o cache do plano. OPTION (RECOMPILE) elimina esse problema completamente porque recompila a consulta sempre. O segundo problema é a incapacidade do otimizador de avaliar a expressão complexa na cláusula WHERE e na cláusula ORDER BY em tempo de compilação. Como OPTION (RECOMPILE) acontece em tempo de execução, ele resolve o problema. Porque em tempo de execução, o otimizador tem muito mais informações em relação ao tempo de compilação, e isso faz toda a diferença.

Agora, vamos ver o que acontece quando tentamos o terceiro comportamento:
  EXECUTE Sales.GetOrders
  	@SortOrder	= N'SalesOrderID';
  GO



Houston, nós temos um problema. O plano ainda verifica ambas as tabelas inteiramente e, em seguida, classifica tudo, em vez de verificar apenas as primeiras 10 linhas de Sales.SalesOrderHeader e evitar a classificação completamente. O que aconteceu?

Este é um "caso" interessante e tem a ver com a expressão CASE na cláusula ORDER BY. A expressão CASE avalia uma lista de condições e retorna uma das expressões de resultado. Mas as expressões de resultado podem ter diferentes tipos de dados. Então, qual seria o tipo de dados de toda a expressão CASE? Bem, a expressão CASE sempre retorna o tipo de dados de maior precedência. No nosso caso, a coluna OrderDate tem o tipo de dados DATETIME, enquanto a coluna SalesOrderID tem o tipo de dados INT. O tipo de dados DATETIME tem uma precedência mais alta, portanto, a expressão CASE sempre retorna DATETIME.

Isso significa que, se quisermos classificar por SalesOrderID, a expressão CASE precisa primeiro converter implicitamente o valor de SalesOrderID em DATETIME para cada linha antes de classificá-la. Veja o operador Compute Scalar à direita do operador Sort no plano acima? É exatamente isso que faz.

Este é um problema por si só e demonstra o quão perigoso pode ser misturar diferentes tipos de dados em uma única expressão CASE.

Podemos contornar esse problema reescrevendo a cláusula ORDER BY de outras maneiras, mas isso tornaria o código ainda mais feio e difícil de ler e manter. Então, não vou nessa direção.

Em vez disso, vamos tentar o próximo método…

Gerar a consulta dinamicamente


Como nosso objetivo é gerar 4 estruturas de consulta diferentes em uma única consulta, o SQL dinâmico pode ser muito útil nesse caso. A ideia é construir a consulta dinamicamente com base nos valores dos parâmetros. Dessa forma, podemos construir as 4 estruturas de consulta diferentes em um único código, sem precisar manter 4 cópias da consulta. Cada estrutura de consulta compilará uma vez, quando for executada pela primeira vez, e obterá o melhor plano porque não contém nenhuma expressão complexa.

Esta solução é muito semelhante à solução com os vários procedimentos armazenados, mas em vez de manter 8 procedimentos armazenados para 3 parâmetros, mantemos apenas um único código que constrói a consulta dinamicamente.

Eu sei, o SQL dinâmico também é feio e às vezes pode ser bastante difícil de manter, mas acho que ainda é mais fácil do que manter vários procedimentos armazenados e não aumenta exponencialmente à medida que o número de parâmetros aumenta.

Segue o código:
  ALTER PROCEDURE
  	Sales.GetOrders
  (
  	@CustomerID	AS INT			= NULL ,
  	@SortOrder	AS SYSNAME		= N'OrderDate'
  )
  AS
  DECLARE
  	@Command AS NVARCHAR(MAX);
  SET @Command =
  	N'
  		SELECT TOP (10)
  			SalesOrderID	        = SalesOrders.SalesOrderID ,
  			OrderDate		= CAST (SalesOrders.OrderDate AS DATE) ,
  			OrderStatus		= SalesOrders.[Status] ,
  			CustomerID		= SalesOrders.CustomerID ,
  			OrderTotal		= SUM (SalesOrderDetails.LineTotal)
  		FROM
  			Sales.SalesOrderHeader AS SalesOrders
  		INNER JOIN
  			Sales.SalesOrderDetail AS SalesOrderDetails
  		ON
  			SalesOrders.SalesOrderID = SalesOrderDetails.SalesOrderID
  		' +
  		CASE
  			WHEN @CustomerID IS NULL
  				THEN N''
  			ELSE
  				N'WHERE
  			SalesOrders.CustomerID = @pCustomerID
  		'
  		END +
  		N'GROUP BY
  			SalesOrders.SalesOrderID ,
  			SalesOrders.OrderDate ,
  			SalesOrders.DueDate ,
  			SalesOrders.[Status] ,
  			SalesOrders.CustomerID
  		ORDER BY
  			' +
  			CASE @SortOrder
  				WHEN N'OrderDate'
  					THEN N'SalesOrders.OrderDate'
  				WHEN N'SalesOrderID'
  					THEN N'SalesOrders.SalesOrderID'
  			END +
  		N' ASC;
  	';
  EXECUTE sys.sp_executesql
  	@stmt			= @Command ,
  	@params			= N'@pCustomerID AS INT' ,
  	@pCustomerID	= @CustomerID;
  GO

Observe que ainda uso um parâmetro interno para o ID do cliente e executo o código dinâmico usando sys.sp_executesql para passar o valor do parâmetro. Isto é importante por duas razões. Primeiro, para evitar várias compilações da mesma estrutura de consulta para valores diferentes de @CustomerID. Segundo, para evitar injeção de SQL.

Se você tentar executar o procedimento armazenado agora usando valores de parâmetro diferentes, verá que cada comportamento de consulta ou estrutura de consulta obtém o melhor plano de execução, e cada um dos 4 planos é compilado apenas uma vez.

Como exemplo, o seguinte é o plano para o terceiro comportamento:
  EXECUTE Sales.GetOrders
  	@SortOrder	= N'SalesOrderID';
  GO



Agora, verificamos apenas as primeiras 10 linhas da tabela Sales.SalesOrderHeader e também verificamos apenas as primeiras 110 linhas da tabela Sales.SalesOrderDetail. Além disso, não há operador Sort porque os dados já estão classificados por SalesOrderID.

Aqui está a saída das estatísticas IO:
  • Tabela 'Detalhes do pedido de vendas'. Contagem de varredura 1, leituras lógicas 4
  • Tabela 'SalesOrderHeader'. Contagem de varredura 1, leituras lógicas 3

Conclusão


Ao usar parâmetros para alterar a estrutura de sua consulta, não use expressões complexas na consulta para derivar o comportamento esperado. Na maioria dos casos, isso levará a um desempenho ruim e por boas razões. A primeira razão é que o plano será gerado com base na primeira execução e, em seguida, todas as execuções subsequentes reutilizarão o mesmo plano, que é apropriado apenas para uma estrutura de consulta. A segunda razão é que o otimizador é limitado em sua capacidade de avaliar essas expressões complexas em tempo de compilação.

Existem várias maneiras de superar esses problemas, e nós as examinamos neste artigo. Na maioria dos casos, o melhor método seria construir a consulta dinamicamente com base nos valores dos parâmetros. Dessa forma, cada estrutura de consulta será compilada uma vez com o melhor plano possível.

Ao construir a consulta usando SQL dinâmico, certifique-se de usar parâmetros onde apropriado e verifique se seu código é seguro.