Do que não se trata:
Esta não é uma pergunta sobre consultas abrangentes que aceitam entrada do usuário ou usam variáveis.
Isso é estritamente sobre consultas onde ISNULL()
é usado na WHERE
cláusula para substituir NULL
valores por um valor canário para comparação com um predicado e diferentes maneiras de reescrever essas consultas para serem SARGable no SQL Server.
Por que você não se senta ali?
Nossa consulta de exemplo é em uma cópia local do banco de dados Stack Overflow no SQL Server 2016 e procura usuários com NULL
idade ou idade < 18.
SELECT COUNT(*)
FROM dbo.Users AS u
WHERE ISNULL(u.Age, 17) < 18;
O plano de consulta mostra um Scan de um índice não clusterizado bastante cuidadoso.
O operador de varredura mostra (graças às adições ao XML do plano de execução real nas versões mais recentes do SQL Server) que lemos cada linha ruim.
No geral, fazemos 9157 leituras e usamos cerca de meio segundo de tempo de CPU:
Table 'Users'. Scan count 1, logical reads 9157, physical reads 0, read-ahead reads 0, lob logical reads 0, lob physical reads 0, lob read-ahead reads 0.
SQL Server Execution Times:
CPU time = 485 ms, elapsed time = 483 ms.
A pergunta: Quais são as maneiras de reescrever essa consulta para torná-la mais eficiente e talvez até SARGable?
Fique à vontade para dar outras sugestões. Eu não acho que minha resposta seja necessariamente a resposta, e existem pessoas inteligentes o suficiente por aí para apresentar alternativas que podem ser melhores.
Se você quiser jogar no seu próprio computador, acesse aqui para baixar o banco de dados SO .
Obrigado!
Seção de resposta
Existem várias maneiras de reescrever isso usando diferentes construções T-SQL. Analisaremos os prós e os contras e faremos uma comparação geral abaixo.
Primeiro : Usando
OR
O uso
OR
nos dá um plano de busca mais eficiente, que lê o número exato de linhas que precisamos, mas adiciona o que o mundo técnico chamaa whole mess of malarkey
ao plano de consulta.Observe também que o Seek é executado duas vezes aqui, o que realmente deve ser mais óbvio do operador gráfico:
Segundo : Usar tabelas derivadas com
UNION ALL
Nossa consulta também pode ser reescrita assimIsso produz o mesmo tipo de plano, com muito menos malandragem e um grau mais aparente de honestidade sobre quantas vezes o índice foi buscado (procurado?)
Ele faz a mesma quantidade de leituras (8233) que a
OR
consulta, mas reduz cerca de 100ms de tempo de CPU.No entanto, você precisa ter muito cuidado aqui, porque se esse plano tentar ficar paralelo, as duas
COUNT
operações separadas serão serializadas, porque cada uma delas é considerada uma agregação escalar global. Se forçarmos um plano paralelo usando Trace Flag 8649, o problema se torna óbvio.Isso pode ser evitado alterando um pouco nossa consulta.
Agora, ambos os nós que executam um Seek são totalmente paralelizados até atingirmos o operador de concatenação.
Para o que vale a pena, a versão totalmente paralela tem alguns bons benefícios. Ao custo de cerca de 100 leituras adicionais e cerca de 90 ms de tempo de CPU adicional, o tempo decorrido diminui para 93 ms.
E o CROSS APPLY? Nenhuma resposta está completa sem a magia do
CROSS APPLY
!Infelizmente, temos mais problemas com o
COUNT
.Esse plano é horrível. Este é o tipo de plano com o qual você acaba quando aparece por último no Dia de São Patrício. Embora bem paralelo, por algum motivo está escaneando o PK/CX. Ai credo. O plano tem um custo de 2198 dólares de consulta.
O que é uma escolha estranha, porque se o forçarmos a usar o índice não clusterizado, o custo cairá significativamente para 1.798 dólares de consulta.
Ei, procura! Dá uma olhada lá. Observe também que com a magia do
CROSS APPLY
, não precisamos fazer nada bobo para ter um plano quase totalmente paralelo.A aplicação cruzada acaba se saindo melhor sem as
COUNT
coisas lá dentro.O plano parece bom, mas as leituras e a CPU não são uma melhoria.
Reescrever a aplicação cruzada para ser uma junção derivada resulta exatamente no mesmo tudo. Não vou postar novamente o plano de consulta e as informações de estatísticas - eles realmente não mudaram.
Álgebra Relacional : Para ser completo e evitar que Joe Celko assombre meus sonhos, precisamos pelo menos tentar algumas coisas relacionais estranhas. Aqui vai nada!
Uma tentativa com
INTERSECT
E aqui está uma tentativa com
EXCEPT
Pode haver outras maneiras de escrever isso, mas deixarei isso para as pessoas que talvez usem
EXCEPT
e comINTERSECT
mais frequência do que eu.Se você realmente precisa de uma contagem , eu uso
COUNT
em minhas consultas como um atalho (leia: às vezes sou muito preguiçoso para criar cenários mais complicados). Se você precisar apenas de uma contagem, poderá usar umaCASE
expressão para fazer praticamente a mesma coisa.These both get the same plan and have the same CPU and read characteristics.
The winner? In my tests, the forced parallel plan with SUM over a derived table performed the best. And yeah, many of these queries could have been assisted by adding a couple filtered indexes to account for both predicates, but I wanted to leave some experimentation to others.
Thanks!
I wasn't game to restore a 110 GB database for just one table so I created my own data. The age distributions should match what's on Stack Overflow but obviously the table itself won't match. I don't think that it's too much of an issue because the queries are going to hit indexes anyway. I'm testing on a 4 CPU computer with SQL Server 2016 SP1. One thing to note is that for queries that finish this quickly it's important not to include the actual execution plan. That can slow things down quite a bit.
I started by going through some of the solutions in Erik's excellent answer. For this one:
I got the following results from sys.dm_exec_sessions over 10 trials (the query naturally went parallel for me):
The query that worked better for Erik actually performed worse on my machine:
Results from 10 trials:
I'm not immediately able to explain why it's that bad, but it's not clear why we want to force nearly every operator in the query plan to go parallel. In the original plan we have a serial zone that finds all rows with
AGE < 18
. There are only a few thousand rows. On my machine I get 9 logical reads for that part of the query and 9 ms of reported CPU time and elapsed time. There's also a serial zone for the global aggregate for the rows withAGE IS NULL
but that only processes one row per DOP. On my machine this is just four rows.My takeaway is that it's most important to optimize the part of the query that finds rows with a
NULL
forAge
because there are millions of those rows. I wasn't able to create an index with less pages that covered the data than a simple page-compressed one on the column. I assume that there's a minimum index size per row or that a lot of the index space cannot be avoided with the tricks that I tried. So if we're stuck with about the same number of logical reads to get the data then the only way to make it faster is to make the query more parallel, but this needs to be done in a different way than Erik's query that used TF 8649. In the query above we have a ratio of 3.62 for CPU time to elapsed time which is pretty good. The ideal would be a ratio of 4.0 on my machine.One possible area of improvement is to divide the work more evenly among threads. In the screenshot below we can see that one of my CPUs decided to take a little break:
Index scan is one of the few operators that can be implemented in parallel and we can't do anything about how the rows are distributed to threads. There's an element of chance to it as well but pretty consistently I saw one underworked thread. One way to work around this is to do parallelism the hard way: on the inner part of a nested loop join. Anything on the inner part of a nested loop will be implemented in a serial way but many serial threads can run concurrently. As long as we get a favorable parallel distribution method (such as round robin), we can control exactly how many rows are sent to each thread.
I'm running queries with DOP 4 so I need to evenly divide the
NULL
rows in the table into four buckets. One way to do this is to create a bunch of indexes on computed columns:I'm not quite sure why four separate indexes is a little faster than one index but that's one what I found in my testing.
To get a parallel nested loop plan I'm going to use the undocumented trace flag 8649. I'm also going to write the code a little strangely to encourage the optimizer not to process more rows than necessary. Below is one implementation which appears to work well:
The results from ten trials:
Com essa consulta, temos uma taxa de CPU para tempo decorrido de 3,85! Reduzimos 17 ms do tempo de execução e foram necessárias apenas 4 colunas e índices computados para fazer isso! Cada encadeamento processa muito próximo do mesmo número de linhas em geral porque cada índice tem muito próximo do mesmo número de linhas e cada encadeamento verifica apenas um índice:
Em uma nota final, também podemos apertar o botão fácil e adicionar um CCI não clusterizado à
Age
coluna:A seguinte consulta termina em 3 ms na minha máquina:
Isso vai ser difícil de vencer.
Although I don't have a local copy of the Stack Overflow database, I was able to try out a couple of queries. My thought was to get a count of users from a system catalog view (as opposed to directly getting a count of rows from the underlying table). Then get a count of rows that do (or maybe do not) match Erik's criteria, and do some simple math.
I used the Stack Exchange Data Explorer (Along with
SET STATISTICS TIME ON;
andSET STATISTICS IO ON;
) to test the queries. For a point of reference, here are some queries and the CPU/IO statistics:QUERY 1
QUERY 2
QUERY 3
1st Attempt
This was slower than all of Erik's queries I listed here...at least in terms of elapsed time.
2nd Attempt
Here I opted for a variable to store the total number of users (instead of a sub-query). The scan count increased from 1 to 17 compared to the 1st attempt. Logical reads stayed the same. However, elapsed time dropped considerably.
Other Notes: DBCC TRACEON is not permitted on Stack Exchange Data Explorer, as noted below:
Use variables?
Per the comment can skip the variables
Uma solução trivial é calcular count(*) - count (idade >= 18):
Ou:
Resultados aqui
Bem usando
SET ANSI_NULLS OFF;
Isso é algo que surgiu na minha mente. Apenas executei isso em https://data.stackexchange.com
Mas não tão eficiente quanto @blitz_erik embora