Várias instruções INSERT vs. único INSERT com vários VALUES

119

Estou executando uma comparação de desempenho entre o uso de 1000 instruções INSERT:

INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('6f3f7257-a3d8-4a78-b2e1-c9b767cfe1c1', 'First 0', 'Last 0', 0)
INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('32023304-2e55-4768-8e52-1ba589b82c8b', 'First 1', 'Last 1', 1)
...
INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('f34d95a7-90b1-4558-be10-6ceacd53e4c4', 'First 999', 'Last 999', 999)

..versus usando uma única instrução INSERT com 1000 valores:

INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
VALUES 
('db72b358-e9b5-4101-8d11-7d7ea3a0ae7d', 'First 0', 'Last 0', 0),
('6a4874ab-b6a3-4aa4-8ed4-a167ab21dd3d', 'First 1', 'Last 1', 1),
...
('9d7f2a58-7e57-4ed4-ba54-5e9e335fb56c', 'First 999', 'Last 999', 999)

Para minha grande surpresa, os resultados são o oposto do que eu pensava:

  • 1000 instruções INSERT: 290 mseg.
  • 1 instrução INSERT com 1000 VALUES: 2800 mseg.

O teste é executado diretamente no MSSQL Management Studio com o SQL Server Profiler usado para medição (e obtive resultados semelhantes executando-o em código C # usando SqlClient, o que é ainda mais surpreendente considerando todas as viagens de ida e volta das camadas DAL)

Isso pode ser razoável ou de alguma forma explicado? Como é que um método supostamente mais rápido resulta em um desempenho 10 vezes (!) Pior ?

Obrigado.

EDIT: Anexando planos de execução para ambos: Planos Executivos

Borka
fonte
1
esses são testes limpos, nada está sendo executado em paralelo, sem dados repetidos (cada consulta é com dados diferentes, é claro, para evitar o cache simples)
Borka
1
há algum gatilho envolvido?
AK
2
Converti um programa para TVP para ultrapassar o limite de 1000 valores e obtive um grande ganho de desempenho. Vou fazer uma comparação.
paparazzo
1
relevante: simple-talk.com/sql/performance/…
desconhecido

Respostas:

126

Adição: o SQL Server 2012 mostra algum desempenho aprimorado nesta área, mas não parece resolver os problemas específicos observados abaixo. Aparentemente, isso deve ser corrigido na próxima versão principal após o SQL Server 2012!

Seu plano mostra que as inserções individuais estão usando procedimentos parametrizados (possivelmente parametrizados automaticamente), então o tempo de análise / compilação para eles deve ser mínimo.

Pensei em analisar isso um pouco mais, então configure um loop ( script ) e tente ajustar o número de VALUEScláusulas e registrar o tempo de compilação.

Em seguida, dividi o tempo de compilação pelo número de linhas para obter o tempo médio de compilação por cláusula. Os resultados estão abaixo

Gráfico

Até 250 VALUEScláusulas presentes, o tempo de compilação / número de cláusulas tem uma leve tendência de aumento, mas nada muito dramático.

Gráfico

Mas então ocorre uma mudança repentina.

Essa seção dos dados é mostrada abaixo.

+------+----------------+-------------+---------------+---------------+
| Rows | CachedPlanSize | CompileTime | CompileMemory | Duration/Rows |
+------+----------------+-------------+---------------+---------------+
|  245 |            528 |          41 |          2400 | 0.167346939   |
|  246 |            528 |          40 |          2416 | 0.162601626   |
|  247 |            528 |          38 |          2416 | 0.153846154   |
|  248 |            528 |          39 |          2432 | 0.157258065   |
|  249 |            528 |          39 |          2432 | 0.156626506   |
|  250 |            528 |          40 |          2448 | 0.16          |
|  251 |            400 |         273 |          3488 | 1.087649402   |
|  252 |            400 |         274 |          3496 | 1.087301587   |
|  253 |            400 |         282 |          3520 | 1.114624506   |
|  254 |            408 |         279 |          3544 | 1.098425197   |
|  255 |            408 |         290 |          3552 | 1.137254902   |
+------+----------------+-------------+---------------+---------------+

O tamanho do plano em cache, que vinha crescendo linearmente, cai de repente, mas o CompileTime aumenta 7 vezes e o CompileMemory dispara. Este é o ponto de corte entre o plano ser auto parametrizado (com 1.000 parâmetros) e um não parametrizado. Depois disso, parece se tornar linearmente menos eficiente (em termos de número de cláusulas de valor processadas em um determinado tempo).

Não sei por que isso deveria ser. Presumivelmente, ao compilar um plano para valores literais específicos, ele deve realizar alguma atividade que não seja escalonada linearmente (como classificação).

Não parece afetar o tamanho do plano de consulta em cache quando tentei uma consulta consistindo inteiramente em linhas duplicadas e nem afeta a ordem de saída da tabela das constantes (e como você está inserindo em um heap, o tempo gasto na classificação seria inútil de qualquer maneira, mesmo que fizesse).

Além disso, se um índice clusterizado for adicionado à tabela, o plano ainda mostra uma etapa de classificação explícita, portanto, não parece estar classificando em tempo de compilação para evitar uma classificação em tempo de execução.

Plano

Tentei ver isso em um depurador, mas os símbolos públicos para minha versão do SQL Server 2008 não parecem estar disponíveis, então, em vez disso, tive que olhar para a UNION ALLconstrução equivalente no SQL Server 2005.

Um rastreamento de pilha típico está abaixo

sqlservr.exe!FastDBCSToUnicode()  + 0xac bytes  
sqlservr.exe!nls_sqlhilo()  + 0x35 bytes    
sqlservr.exe!CXVariant::CmpCompareStr()  + 0x2b bytes   
sqlservr.exe!CXVariantPerformCompare<167,167>::Compare()  + 0x18 bytes  
sqlservr.exe!CXVariant::CmpCompare()  + 0x11f67d bytes  
sqlservr.exe!CConstraintItvl::PcnstrItvlUnion()  + 0xe2 bytes   
sqlservr.exe!CConstraintProp::PcnstrUnion()  + 0x35e bytes  
sqlservr.exe!CLogOp_BaseSetOp::PcnstrDerive()  + 0x11a bytes    
sqlservr.exe!CLogOpArg::PcnstrDeriveHandler()  + 0x18f bytes    
sqlservr.exe!CLogOpArg::DeriveGroupProperties()  + 0xa9 bytes   
sqlservr.exe!COpArg::DeriveNormalizedGroupProperties()  + 0x40 bytes    
sqlservr.exe!COptExpr::DeriveGroupProperties()  + 0x18a bytes   
sqlservr.exe!COptExpr::DeriveGroupProperties()  + 0x146 bytes   
sqlservr.exe!COptExpr::DeriveGroupProperties()  + 0x146 bytes   
sqlservr.exe!COptExpr::DeriveGroupProperties()  + 0x146 bytes   
sqlservr.exe!CQuery::PqoBuild()  + 0x3cb bytes  
sqlservr.exe!CStmtQuery::InitQuery()  + 0x167 bytes 
sqlservr.exe!CStmtDML::InitNormal()  + 0xf0 bytes   
sqlservr.exe!CStmtDML::Init()  + 0x1b bytes 
sqlservr.exe!CCompPlan::FCompileStep()  + 0x176 bytes   
sqlservr.exe!CSQLSource::FCompile()  + 0x741 bytes  
sqlservr.exe!CSQLSource::FCompWrapper()  + 0x922be bytes    
sqlservr.exe!CSQLSource::Transform()  + 0x120431 bytes  
sqlservr.exe!CSQLSource::Compile()  + 0x2ff bytes   

Portanto, ir dos nomes no rastreamento de pilha parece levar muito tempo comparando strings.

Este artigo da base de conhecimento indica que DeriveNormalizedGroupPropertiesestá associado ao que costumava ser chamado de estágio de normalização do processamento de consulta

Este estágio agora é chamado de ligação ou algebrização e leva a saída da árvore de análise de expressão do estágio de análise anterior e produz uma árvore de expressão algbrizada (árvore do processador de consulta) para avançar para a otimização (otimização de plano trivial neste caso) [ref] .

Eu tentei mais um experimento ( Script ) que era repetir o teste original, mas olhando para três casos diferentes.

  1. Strings de nome e sobrenome de 10 caracteres sem duplicatas.
  2. Strings de nome e sobrenome de 50 caracteres sem duplicatas.
  3. Strings de nome e sobrenome de 10 caracteres com todas as duplicatas.

Gráfico

Pode-se ver claramente que quanto mais longas as strings, piores as coisas ficam e que, inversamente, quanto mais duplicatas, melhor as coisas ficam. Como mencionado anteriormente, as duplicatas não afetam o tamanho do plano em cache, então presumo que deve haver um processo de identificação de duplicatas ao construir a própria árvore de expressão algbrizada.

Editar

Um lugar onde essas informações são aproveitadas é mostrado por @Lieven aqui

SELECT * 
FROM (VALUES ('Lieven1', 1),
             ('Lieven2', 2),
             ('Lieven3', 3))Test (name, ID)
ORDER BY name, 1/ (ID - ID) 

Como em tempo de compilação, ele pode determinar que a Namecoluna não tem duplicatas, ele pula a ordenação pela 1/ (ID - ID)expressão secundária em tempo de execução (a classificação no plano tem apenas uma ORDER BYcoluna) e nenhum erro de divisão por zero é gerado. Se duplicatas forem adicionadas à tabela, o operador de classificação mostrará duas ordens por colunas e o erro esperado será gerado.

Martin Smith
fonte
6
O número mágico que você tem é NumberOfRows / ColumnCount = 250. Altere sua consulta para usar apenas três colunas e a alteração acontecerá em 333. O número mágico 1000 pode ser algo como o número máximo de parâmetros usados ​​em um plano em cache. Parece ser "mais fácil" gerar um plano com um do <ParameterList>que um com uma <ConstantScan><Values><Row>lista.
Mikael Eriksson
1
@MikaelEriksson - Concordo. A linha 250 com 1000 valores é parametrizada automaticamente, a linha 251 não, então essa parece ser a diferença. Não tenho certeza porque embora. Talvez ele gaste tempo classificando os valores literais procurando por duplicatas ou algo assim, quando houver.
Martin Smith
1
Este é um problema muito louco, eu só fiquei triste com isso. Esta é uma ótima resposta, obrigado
Não gostei
1
@MikaelEriksson Você quer dizer que o número mágico é NumberOfRows * ColumnCount = 1000?
paparazzo
1
@Blam - Sim. Quando o número total de elementos é maior que 1000 (NumberOfRows * ColumnCount), o plano de consulta é alterado para usar em <ConstantScan><Values><Row>vez de <ParameterList>.
Mikael Eriksson
23

Não é muito surpreendente: o plano de execução para a pequena inserção é calculado uma vez e, em seguida, reutilizado 1000 vezes. Analisar e preparar o plano é rápido, porque ele tem apenas quatro valores para delinear. Um plano de 1000 linhas, por outro lado, precisa lidar com 4000 valores (ou 4000 parâmetros se você parametrizou seus testes C #). Isso poderia consumir facilmente a economia de tempo que você ganha ao eliminar 999 viagens de ida e volta para o SQL Server, especialmente se sua rede não for excessivamente lenta.

dasblinkenlight
fonte
9

O problema provavelmente está relacionado ao tempo que leva para compilar a consulta.

Se você quiser acelerar as inserções, o que realmente precisa fazer é envolvê-las em uma transação:

BEGIN TRAN;
INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('6f3f7257-a3d8-4a78-b2e1-c9b767cfe1c1', 'First 0', 'Last 0', 0);
INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('32023304-2e55-4768-8e52-1ba589b82c8b', 'First 1', 'Last 1', 1);
...
INSERT INTO T_TESTS (TestId, FirstName, LastName, Age) 
   VALUES ('f34d95a7-90b1-4558-be10-6ceacd53e4c4', 'First 999', 'Last 999', 999);
COMMIT TRAN;

No C #, você também pode considerar o uso de um parâmetro com valor de tabela. A emissão de vários comandos em um único lote, separando-os com ponto e vírgula, é outra abordagem que também ajudará.

RickNZ
fonte
1
Re: "Emitir vários comandos em um único lote": isso ajuda um pouco, mas não muito. Mas eu definitivamente concordo com as outras duas opções de envolver em uma TRANSACTION (TRANS realmente funciona ou deveria ser apenas TRAN?) Ou usar um TVP.
Solomon Rutzky
1

Eu me deparei com uma situação semelhante ao tentar converter uma tabela com várias linhas de 100k com um programa C ++ (MFC / ODBC).

Como essa operação demorou muito, imaginei agrupar várias inserções em uma (até 1000 devido às limitações do MSSQL ). Meu palpite de que muitas instruções de inserção simples criariam uma sobrecarga semelhante ao que é descrito aqui .

No entanto, descobriu-se que a conversão demorou um pouco mais:

        Method 1       Method 2     Method 3 
        Single Insert  Multi Insert Joined Inserts
Rows    1000           1000         1000
Insert  390 ms         765 ms       270 ms
per Row 0.390 ms       0.765 ms     0.27 ms

Portanto, 1000 chamadas únicas para CDatabase :: ExecuteSql, cada uma com uma única instrução INSERT (método 1), são quase duas vezes mais rápidas do que uma única chamada para CDatabase :: ExecuteSql com uma instrução INSERT de várias linhas com 1000 tuplas de valor (método 2).

Atualização: Então, a próxima coisa que tentei foi agrupar 1000 instruções INSERT separadas em uma única string e fazer com que o servidor executasse isso (método 3). Acontece que isso é um pouco mais rápido do que o método 1.

Editar: Estou usando o Microsoft SQL Server Express Edition (64 bits) v10.0.2531.0

uceumern
fonte