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

Surpresas e suposições de desempenho:TOP 1 arbitrário


Em um tópico recente no StackExchange, um usuário teve o seguinte problema:

Quero uma consulta que retorne a primeira pessoa na tabela com GroupID =2. Se não existir ninguém com GroupID =2, quero a primeira pessoa com RoleID =2.

Vamos descartar, por enquanto, o fato de que "primeiro" é terrivelmente definido. Na verdade, o usuário não se importava com a pessoa que ele pegava, se veio aleatoriamente, arbitrariamente ou através de alguma lógica explícita além de seus critérios principais. Ignorando isso, digamos que você tenha uma tabela básica:
CREATE TABLE dbo.Users
(
  UserID  INT PRIMARY KEY,
  GroupID INT,
  RoleID  INT
);

No mundo real, provavelmente existem outras colunas, restrições adicionais, talvez chaves estrangeiras para outras tabelas e certamente outros índices. Mas vamos manter isso simples e fazer uma consulta.

Soluções prováveis


Com esse design de mesa, resolver o problema parece simples, certo? A primeira tentativa que você provavelmente faria é:
SELECT TOP (1) UserID, GroupID, RoleID
  FROM dbo.Users
  WHERE GroupID = 2 OR RoleID = 2
  ORDER BY CASE GroupID WHEN 2 THEN 1 ELSE 2 END;

Isso usa TOP e um ORDER BY condicional para tratar os usuários com GroupID =2 como prioridade mais alta. O plano para esta consulta é bastante simples, com a maior parte do custo acontecendo em uma operação de classificação. Aqui estão as métricas de tempo de execução em uma tabela vazia:



Isso parece ser o melhor que você pode fazer - um plano simples que verifica a mesa apenas uma vez, e que não seja um tipo irritante com o qual você deve ser capaz de conviver, sem problemas, certo?

Bem, outra resposta no tópico ofereceu essa variação mais complexa:
SELECT TOP (1) UserID, GroupID, RoleID FROM 
(
  SELECT TOP (1) UserID, GroupID, RoleID, o = 1
  FROM dbo.Users
  WHERE GroupId = 2 
 
  UNION ALL
 
  SELECT TOP (1) UserID, GroupID, RoleID, o = 2
  FROM dbo.Users
  WHERE RoleID = 2
) 
AS x ORDER BY o;

À primeira vista, você provavelmente pensaria que essa consulta é extremamente menos eficiente, pois requer duas verificações de índice clusterizado. Você definitivamente estaria certo sobre isso; aqui estão as métricas do plano e do tempo de execução em relação a uma tabela vazia:


Mas agora, vamos adicionar dados


Para testar essas consultas, eu queria usar alguns dados realistas. Então, primeiro eu preenchi 1.000 linhas de sys.all_objects, com operações de módulo contra o object_id para obter uma distribuição decente:
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000) ABS([object_id]), ABS([object_id]) % 7, ABS([object_id]) % 4
FROM sys.all_objects
ORDER BY [object_id]; 
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 126
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 248
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 26 overlap

Agora, quando executo as duas consultas, aqui estão as métricas de tempo de execução:



A versão UNION ALL vem com um pouco menos de E/S (4 leituras vs. 5), menor duração e menor custo geral estimado, enquanto a versão condicional ORDER BY tem menor custo estimado de CPU. Os dados aqui são muito pequenos para tirar conclusões; Eu só queria isso como uma estaca no chão. Agora, vamos alterar a distribuição para que a maioria das linhas atenda a pelo menos um dos critérios (e às vezes ambos):
DROP TABLE dbo.Users;
GO
 
CREATE TABLE dbo.Users
(
  UserID INT PRIMARY KEY,
  GroupID INT,
  RoleID INT
);
GO
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000) ABS([object_id]), ABS([object_id]) % 2 + 1, 
  SUBSTRING(RTRIM([object_id]),7,1) % 2 + 1
FROM sys.all_objects
WHERE ABS([object_id]) > 9999999
ORDER BY [object_id]; 
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 500
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 475
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 221 overlap

Desta vez, a ordem condicional por tem os maiores custos estimados em CPU e E/S:



Mas, novamente, com esse tamanho de dados, há um impacto relativamente inconsequente na duração e nas leituras e, além dos custos estimados (que são amplamente compostos de qualquer maneira), é difícil declarar um vencedor aqui.

Então, vamos adicionar muito mais dados


Embora eu goste de construir dados de amostra a partir das visualizações do catálogo, já que todo mundo tem isso, desta vez vou desenhar na tabela Sales.SalesOrderHeader Ampliado de AdventureWorks2012, expandido usando este script de Jonathan Kehayias. No meu sistema, esta tabela tem 1.258.600 linhas. O script a seguir inserirá um milhão dessas linhas em nossa tabela dbo.Users:
-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (1000000) SalesOrderID, SalesOrderID % 7, SalesOrderID % 4
FROM Sales.SalesOrderHeaderEnlarged;
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 142,857
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 250,000
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 35,714 overlap

Ok, agora quando executamos as consultas, vemos um problema:a variação ORDER BY ficou paralela e obliterou as leituras e a CPU, gerando uma diferença de quase 120X na duração:



Eliminar o paralelismo (usando MAXDOP) não ajudou:



(O plano UNION ALL ainda parece o mesmo.)

E se alterarmos a inclinação para par, onde 95% das linhas atendem a pelo menos um critério:
-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (475000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged
WHERE SalesOrderID % 2 = 1
UNION ALL
SELECT TOP (475000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged
WHERE SalesOrderID % 2 = 0;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, 1, 1
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 542,851
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 542,851
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 135,702 overlap

As consultas ainda mostram que o tipo é proibitivamente caro:



E com MAXDOP =1 foi muito pior (basta olhar a duração):



Por fim, como cerca de 95% de inclinação em qualquer direção (por exemplo, a maioria das linhas atende aos critérios GroupID ou a maioria das linhas atende aos critérios RoleID)? Este script garantirá que pelo menos 95% dos dados tenham GroupID =2:
-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (950000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 957,143
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 185,714
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 142,857 overlap

Os resultados são bastante semelhantes (vou parar de tentar o MAXDOP de agora em diante):



E então, se inclinarmos para o outro lado, onde pelo menos 95% dos dados têm RoleID =2:
-- DROP and CREATE, as before
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (950000) SalesOrderID, 2, SalesOrderID % 7
FROM Sales.SalesOrderHeaderEnlarged;
 
INSERT dbo.Users(UserID, GroupID, RoleID)
SELECT TOP (50000) SalesOrderID, SalesOrderID % 7, 2
FROM Sales.SalesOrderHeaderEnlarged AS h
WHERE NOT EXISTS (SELECT 1 FROM dbo.Users
  WHERE UserID = h.SalesOrderID);
 
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2; -- 185,714
SELECT COUNT(*) FROM dbo.Users WHERE RoleID = 2;  -- 957,143
SELECT COUNT(*) FROM dbo.Users WHERE GroupID = 2 AND RoleID = 2; -- 142,857 overlap

Resultados:


Conclusão


Em nenhum caso que eu pudesse fabricar, a consulta ORDER BY "mais simples" - mesmo com uma varredura de índice clusterizado a menos - superou a consulta UNION ALL mais complexa. Às vezes, você precisa ter muito cuidado com o que o SQL Server precisa fazer ao introduzir operações como classificações em sua semântica de consulta e não confiar apenas na simplicidade do plano (não importa qualquer preconceito que você possa ter com base em cenários anteriores).

Seu primeiro instinto pode muitas vezes estar correto, mas aposto que há momentos em que há uma opção melhor que parece, na superfície, que não poderia funcionar melhor. Como neste exemplo. Estou ficando um pouco melhor em questionar suposições que fiz a partir de observações e não fazer declarações gerais como "scans nunca funcionam bem" e "consultas mais simples sempre são mais rápidas". Se você eliminar as palavras nunca e sempre do seu vocabulário, você pode se ver colocando mais dessas suposições e declarações gerais à prova, e terminar muito melhor.