Por que o uso de uma variável de tabela é duas vezes mais rápido que uma tabela #temp neste caso específico?

37

Eu estava olhando o artigo aqui Tabelas temporárias versus variáveis ​​de tabela e seus efeitos no desempenho do SQL Server e no SQL Server 2008 foi capaz de reproduzir resultados semelhantes aos mostrados lá em 2005.

Ao executar os procedimentos armazenados (definições abaixo) com apenas 10 linhas, a versão da variável da tabela executa a versão da tabela temporária mais de duas vezes.

Limpei o cache do procedimento e executei os dois procedimentos armazenados 10.000 vezes e repeti o processo por mais 4 execuções. Resultados abaixo (tempo em ms por lote)

T2_Time     V2_Time
----------- -----------
8578        2718      
6641        2781    
6469        2813   
6766        2797
6156        2719

Minha pergunta é: Qual o motivo do melhor desempenho da versão da variável de tabela?

Eu fiz alguma investigação. por exemplo, olhando os contadores de desempenho com

SELECT cntr_value
from sys.dm_os_performance_counters
where counter_name = 'Temp Tables Creation Rate';

confirma que nos dois casos os objetos temporários estão sendo armazenados em cache após a primeira execução, conforme o esperado vez de serem criados do zero novamente para cada chamada.

Da mesma forma traçando o Auto Stats, SP:Recompile, SQL:StmtRecompileeventos no Profiler (imagem abaixo) mostra que esses eventos ocorrem apenas uma vez (na primeira chamada do #tempprocedimento armazenado tabela) e os outros 9.999 execuções não levantam qualquer um desses eventos. (A versão da variável de tabela não recebe nenhum desses eventos)

Vestígio

A sobrecarga um pouco maior da primeira execução do procedimento armazenado não pode, de forma alguma, explicar a grande diferença geral, no entanto, como ainda são necessários apenas alguns ms para limpar o cache do procedimento e executar os dois procedimentos uma vez, então não acredito em estatísticas ou recompilar pode ser a causa.

Criar objetos de banco de dados necessários

CREATE DATABASE TESTDB_18Feb2012;

GO

USE TESTDB_18Feb2012;

CREATE TABLE NUM 
  ( 
     n INT PRIMARY KEY, 
     s VARCHAR(128) 
  ); 

WITH NUMS(N) 
     AS (SELECT TOP 1000000 ROW_NUMBER() OVER (ORDER BY $/0) 
         FROM   master..spt_values v1, 
                master..spt_values v2) 
INSERT INTO NUM 
SELECT N, 
       'Value: ' + CONVERT(VARCHAR, N) 
FROM   NUMS 

GO

CREATE PROCEDURE [dbo].[T2] @total INT 
AS 
  CREATE TABLE #T 
    ( 
       n INT PRIMARY KEY, 
       s VARCHAR(128) 
    ) 

  INSERT INTO #T 
  SELECT n, 
         s 
  FROM   NUM 
  WHERE  n%100 > 0 
         AND n <= @total 

  DECLARE @res VARCHAR(128) 

  SELECT @res = MAX(s) 
  FROM   NUM 
  WHERE  n <= @total 
         AND NOT EXISTS(SELECT * 
                        FROM   #T 
                        WHERE  #T.n = NUM.n) 
GO

CREATE PROCEDURE [dbo].[V2] @total INT 
AS 
  DECLARE @V TABLE ( 
    n INT PRIMARY KEY, 
    s VARCHAR(128)) 

  INSERT INTO @V 
  SELECT n, 
         s 
  FROM   NUM 
  WHERE  n%100 > 0 
         AND n <= @total 

  DECLARE @res VARCHAR(128) 

  SELECT @res = MAX(s) 
  FROM   NUM 
  WHERE  n <= @total 
         AND NOT EXISTS(SELECT * 
                        FROM   @V V 
                        WHERE  V.n = NUM.n) 


GO

Script de teste

SET NOCOUNT ON;

DECLARE @T1 DATETIME2,
        @T2 DATETIME2,
        @T3 DATETIME2,  
        @Counter INT = 0

SET @T1 = SYSDATETIME()

WHILE ( @Counter < 10000)
BEGIN
EXEC dbo.T2 10
SET @Counter += 1
END

SET @T2 = SYSDATETIME()
SET @Counter = 0

WHILE ( @Counter < 10000)
BEGIN
EXEC dbo.V2 10
SET @Counter += 1
END

SET @T3 = SYSDATETIME()

SELECT DATEDIFF(MILLISECOND,@T1,@T2) AS T2_Time,
       DATEDIFF(MILLISECOND,@T2,@T3) AS V2_Time
Martin Smith
fonte
O rastreamento do criador de perfil indica que as estatísticas são criadas apenas na #temptabela uma vez, apesar de serem limpas e preenchidas novamente 9.999 vezes depois disso.
Martin Smith

Respostas:

31

A saída de SET STATISTICS IO ONpara ambos parece semelhante

SET STATISTICS IO ON;
PRINT 'V2'
EXEC dbo.V2 10
PRINT 'T2'
EXEC dbo.T2 10

V2
Table '#58B62A60'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3

Table '#58B62A60'. Scan count 10, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3

T2
Table '#T__ ... __00000000E2FE'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3

Table '#T__ ... __00000000E2FE'. Scan count 0, logical reads 20
Table 'NUM'. Scan count 1, logical reads 3

E, como Aaron aponta nos comentários, o plano para a versão da variável de tabela é realmente menos eficiente, enquanto ambos têm um plano de loops aninhados acionado por uma pesquisa de índice na versão dbo.NUMda #temptabela realiza uma pesquisa no índice [#T].n = [dbo].[NUM].[n]com predicado residual, [#T].[n]<=[@total]enquanto a variável da tabela version executa uma busca de índice @V.n <= [@total]com predicado residual @V.[n]=[dbo].[NUM].[n]e, portanto, processa mais linhas (é por isso que esse plano tem um desempenho tão ruim para um número maior de linhas)

O uso de Eventos estendidos para examinar os tipos de espera para o spid específico fornece esses resultados para 10.000 execuções deEXEC dbo.T2 10

+---------------------+------------+----------------+----------------+----------------+
|                     |            |     Total      | Total Resource |  Total Signal  |
| Wait Type           | Wait Count | Wait Time (ms) | Wait Time (ms) | Wait Time (ms) |
+---------------------+------------+----------------+----------------+----------------+
| SOS_SCHEDULER_YIELD | 16         | 19             | 19             | 0              |
| PAGELATCH_SH        | 39998      | 14             | 0              | 14             |
| PAGELATCH_EX        | 1          | 0              | 0              | 0              |
+---------------------+------------+----------------+----------------+----------------+

e esses resultados para 10.000 execuções de EXEC dbo.V2 10

+---------------------+------------+----------------+----------------+----------------+
|                     |            |     Total      | Total Resource |  Total Signal  |
| Wait Type           | Wait Count | Wait Time (ms) | Wait Time (ms) | Wait Time (ms) |
+---------------------+------------+----------------+----------------+----------------+
| PAGELATCH_EX        | 2          | 0              | 0              | 0              |
| PAGELATCH_SH        | 1          | 0              | 0              | 0              |
| SOS_SCHEDULER_YIELD | 676        | 0              | 0              | 0              |
+---------------------+------------+----------------+----------------+----------------+

Portanto, fica claro que o número de PAGELATCH_SHesperas é muito maior no #tempcaso da tabela. Não tenho conhecimento de nenhuma maneira de adicionar o recurso de espera ao rastreamento de eventos estendidos, portanto, para investigar isso, executei

WHILE 1=1
EXEC dbo.T2 10

Enquanto em outra conexão de pesquisa sys.dm_os_waiting_tasks

CREATE TABLE #T(resource_description NVARCHAR(2048))

WHILE 1=1
INSERT INTO #T
SELECT resource_description
FROM sys.dm_os_waiting_tasks
WHERE session_id=<spid_of_other_session> and wait_type='PAGELATCH_SH'

Depois de deixar essa operação por cerca de 15 segundos, ela reuniu os seguintes resultados

+-------+----------------------+
| Count | resource_description |
+-------+----------------------+
|  1098 | 2:1:150              |
|  1689 | 2:1:146              |
+-------+----------------------+

Ambas as páginas bloqueadas pertencem a índices (diferentes) não agrupados na tempdb.sys.sysschobjstabela base denominada 'nc1'e'nc2' .

A consulta tempdb.sys.fn_dblogdurante as execuções indica que o número de registros de log adicionados pela primeira execução de cada procedimento armazenado era um tanto variável, mas para execuções subseqüentes o número adicionado por cada iteração era muito consistente e previsível. Depois que os planos de procedimento são armazenados em cache, o número de entradas de log é cerca da metade das necessárias para o#temp versão.

+-----------------+----------------+------------+
|                 | Table Variable | Temp Table |
+-----------------+----------------+------------+
| First Run       |            126 | 72 or 136  |
| Subsequent Runs |             17 | 32         |
+-----------------+----------------+------------+

Examinando as entradas do log de transações com mais detalhes para a #tempversão da tabela do SP, cada chamada subsequente do procedimento armazenado cria três transações e a variável da tabela uma apenas duas.

+---------------------------------+----+---------------------------------+----+
|           #Temp Table                |         @Table Variable              |
+---------------------------------+----+---------------------------------+----+
| CREATE TABLE                    |  9 |                                 |    |
| INSERT                          | 12 | TVQuery                         | 12 |
| FCheckAndCleanupCachedTempTable | 11 | FCheckAndCleanupCachedTempTable |  5 |
+---------------------------------+----+---------------------------------+----+

O INSERT/TVQUERY transações são idênticas, exceto pelo nome. Ele contém os registros de log para cada uma das 10 linhas inseridas na tabela temporária ou na variável da tabela mais as entradas LOP_BEGIN_XACT/ LOP_COMMIT_XACT.

A CREATE TABLEtransação aparece apenas no#Temp versão e tem a seguinte aparência.

+-----------------+-------------------+---------------------+
|    Operation    |      Context      |    AllocUnitName    |
+-----------------+-------------------+---------------------+
| LOP_BEGIN_XACT  | LCX_NULL          |                     |
| LOP_SHRINK_NOOP | LCX_NULL          |                     |
| LOP_MODIFY_ROW  | LCX_CLUSTERED     | sys.sysschobjs.clst |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc1  |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF    | sys.sysschobjs.nc1  |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc2  |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF    | sys.sysschobjs.nc2  |
| LOP_MODIFY_ROW  | LCX_CLUSTERED     | sys.sysschobjs.clst |
| LOP_COMMIT_XACT | LCX_NULL          |                     |
+-----------------+-------------------+---------------------+

A FCheckAndCleanupCachedTempTabletransação aparece nos dois, mas possui 6 entradas adicionais na #tempversão. Estas são as 6 linhas referentes sys.sysschobjse têm exatamente o mesmo padrão que acima.

+-----------------+-------------------+----------------------------------------------+
|    Operation    |      Context      |                AllocUnitName                 |
+-----------------+-------------------+----------------------------------------------+
| LOP_BEGIN_XACT  | LCX_NULL          |                                              |
| LOP_DELETE_ROWS | LCX_NONSYS_SPLIT  | dbo.#7240F239.PK__#T________3BD0199374293AAB |
| LOP_HOBT_DELTA  | LCX_NULL          |                                              |
| LOP_HOBT_DELTA  | LCX_NULL          |                                              |
| LOP_MODIFY_ROW  | LCX_CLUSTERED     | sys.sysschobjs.clst                          |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc1                           |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF    | sys.sysschobjs.nc1                           |
| LOP_DELETE_ROWS | LCX_MARK_AS_GHOST | sys.sysschobjs.nc2                           |
| LOP_INSERT_ROWS | LCX_INDEX_LEAF    | sys.sysschobjs.nc2                           |
| LOP_MODIFY_ROW  | LCX_CLUSTERED     | sys.sysschobjs.clst                          |
| LOP_COMMIT_XACT | LCX_NULL          |                                              |
+-----------------+-------------------+----------------------------------------------+

Observando essas 6 linhas nas duas transações, elas correspondem às mesmas operações. A primeira LOP_MODIFY_ROW, LCX_CLUSTEREDé uma atualização para a modify_datecoluna em sys.objects. As cinco linhas restantes estão relacionadas à renomeação de objetos. Porque nameé uma coluna-chave dos dois NCIs afetados (nc1 enc2 ), isso é realizado como uma exclusão / inserção para aqueles, então ele volta ao índice em cluster e o atualiza também.

Parece que, para a #tempversão da tabela, quando o procedimento armazenado termina parte da limpeza realizada pela FCheckAndCleanupCachedTempTabletransação, renomeie a tabela temporária de algo como #T__________________________________________________________________________________________________________________00000000E316um nome interno diferente, como #2F4A0079quando é inserida, a CREATE TABLEtransação renomeia a mesma. Esse nome de flip-flop pode ser visto em uma conexão executando dbo.T2em um loop enquanto em outra

WHILE 1=1
SELECT name, object_id, create_date, modify_date
FROM tempdb.sys.objects 
WHERE name LIKE '#%'

Resultados de exemplo

Captura de tela

Portanto, uma explicação potencial para o diferencial de desempenho observado, como aludido por Alex, é que esse trabalho adicional é manter as tabelas do sistema tempdbresponsáveis.


Executando os dois procedimentos em um loop, o criador de perfil do Visual Studio Code revela o seguinte

+-------------------------------+--------------------+-------+-----------+
|           Function            |    Explanation     | Temp  | Table Var |
+-------------------------------+--------------------+-------+-----------+
| CXStmtDML::XretExecute        | Insert ... Select  | 16.93 | 37.31     |
| CXStmtQuery::ErsqExecuteQuery | Select Max         | 8.77  | 23.19     |
+-------------------------------+--------------------+-------+-----------+
| Total                         |                    | 25.7  | 60.5      |
+-------------------------------+--------------------+-------+-----------+

A versão da variável da tabela gasta cerca de 60% do tempo executando a instrução insert e a seleção subsequente, enquanto a tabela temporária é menos da metade disso. Isso está alinhado com os tempos mostrados no OP e com a conclusão acima de que a diferença no desempenho se deve ao tempo gasto na execução de trabalhos auxiliares, não devido ao tempo gasto na própria execução da consulta.

As funções mais importantes que contribuem para os 75% "ausentes" na versão temporária da tabela são

+------------------------------------+-------------------+
|              Function              | Inclusive Samples |
+------------------------------------+-------------------+
| CXStmtCreateTableDDL::XretExecute  | 26.26%            |
| CXStmtDDL::FinishNormalImp         | 4.17%             |
| TmpObject::Release                 | 27.77%            |
+------------------------------------+-------------------+
| Total                              | 58.20%            |
+------------------------------------+-------------------+

Nas funções de criação e liberação, a função CMEDProxyObject::SetNameé mostrada com um valor de amostra inclusivo de 19.6%. Pelo qual deduzo que 39,2% do tempo no caso de tabela temporária é ocupado com a renomeação descrita anteriormente.

E os maiores na versão variável de tabela que contribuem para os outros 40% são

+-----------------------------------+-------------------+
|             Function              | Inclusive Samples |
+-----------------------------------+-------------------+
| CTableCreate::LCreate             | 7.41%             |
| TmpObject::Release                | 12.87%            |
+-----------------------------------+-------------------+
| Total                             | 20.28%            |
+-----------------------------------+-------------------+

Perfil da tabela temporária

insira a descrição da imagem aqui

Perfil da variável de tabela

insira a descrição da imagem aqui

Martin Smith
fonte
10

Disco Inferno

Como essa é uma pergunta mais antiga, decidi revisar o problema nas versões mais recentes do SQL Server para verificar se o mesmo perfil de desempenho ainda existe ou se as características foram alteradas.

Especificamente, a adição de tabelas de sistema na memória para o SQL Server 2019 parece uma ocasião interessante para testar novamente.

Estou usando um equipamento de teste um pouco diferente, desde que deparei com esse problema enquanto trabalhava em outra coisa.

Teste, teste

Usando a versão 2013 do Stack Overflow , eu tenho esse índice e esses dois procedimentos:

Índice:

CREATE INDEX ix_whatever 
    ON dbo.Posts(OwnerUserId) INCLUDE(Score);
GO

Tabela temporária:

    CREATE OR ALTER PROCEDURE dbo.TempTableTest(@Id INT)
    AS
    BEGIN
    SET NOCOUNT ON;

        CREATE TABLE #t(i INT NOT NULL);
        DECLARE @i INT;

        INSERT #t ( i )
        SELECT p.Score
        FROM dbo.Posts AS p
        WHERE p.OwnerUserId = @Id;

        SELECT @i = AVG(t.i)
        FROM #t AS t;

    END;
    GO 

Variável de tabela:

    CREATE OR ALTER PROCEDURE dbo.TableVariableTest(@Id INT)
    AS
    BEGIN
    SET NOCOUNT ON;

        DECLARE @t TABLE (i INT NOT NULL);
        DECLARE @i INT;

        INSERT @t ( i )
        SELECT p.Score
        FROM dbo.Posts AS p
        WHERE p.OwnerUserId = @Id;

        SELECT @i = AVG(t.i)
        FROM @t AS t;

    END;
    GO 

Para evitar qualquer potencial espera do ASYNC_NETWORK_IO , estou usando procedimentos de wrapper.

CREATE PROCEDURE #TT AS
SET NOCOUNT ON;
    DECLARE @i INT = 1;
    DECLARE @StartDate DATETIME2(7) = SYSDATETIME();

    WHILE @i <= 50000
        BEGIN
            EXEC dbo.TempTableTest @Id = @i;
            SET @i += 1;
        END;
    SELECT DATEDIFF(MILLISECOND, @StartDate, SYSDATETIME()) AS [ElapsedTimeMilliseconds];
GO

CREATE PROCEDURE #TV AS
SET NOCOUNT ON;
    DECLARE @i INT = 1;
    DECLARE @StartDate DATETIME2(7) = SYSDATETIME();

    WHILE @i <= 50000
        BEGIN
            EXEC dbo.TableVariableTest @Id = @i;
            SET @i += 1;
        END;
    SELECT DATEDIFF(MILLISECOND, @StartDate, SYSDATETIME()) AS [ElapsedTimeMilliseconds];
GO

SQL Server 2017

Como 2014 e 2016 são basicamente RELICS neste momento, estou iniciando meus testes com 2017. Além disso, por uma questão de brevidade, vou direto ao perfil do código com Perfview . Na vida real, observei esperas, fechos, spinlocks, bandeiras malucas e outras coisas.

A criação de perfil do código é a única coisa que revelou algo de interesse.

Diferença de tempo:

  • Tabela de temperaturas: 17891 ms
  • Variável de tabela: 5891 ms

Ainda é uma diferença muito clara, não é? Mas o que o SQL Server está atingindo agora?

NUTS

Observando os dois principais aumentos nas amostras diferenciadas, vemos sqlmine sqlsqllang!TCacheStore<CacheClockAlgorithm>::GetNextUserDataInHashBucketsomos os dois maiores infratores.

NUTS

A julgar pelos nomes nas pilhas de chamadas, limpar e renomear internamente as tabelas temporárias parece ser o maior tempo gasto na chamada da tabela temporária versus a chamada da variável da tabela.

Mesmo que as variáveis ​​de tabela sejam apoiadas internamente por tabelas temporárias, isso não parece ser um problema.

SET STATISTICS IO ON;
DECLARE @t TABLE(id INT);
SELECT * FROM @t AS t;

Tabela '# B98CE339'. Contagem de digitalizações 1

Examinar as pilhas de chamadas para o teste de variável de tabela não mostra nenhum dos principais infratores:

NUTS

SQL Server 2019 (baunilha)

Tudo bem, então isso ainda é um problema no SQL Server 2017, há algo diferente em 2019 pronto para uso?

Primeiro, para mostrar que não há nada na manga:

SELECT c.name,
       c.value_in_use,
       c.description
FROM sys.configurations AS c
WHERE c.name = 'tempdb metadata memory-optimized';

NUTS

Diferença de tempo:

  • Tabela temporária: 15765 ms
  • Variável de tabela: 7250 ms

Ambos os procedimentos foram diferentes. A chamada da tabela temporária foi alguns segundos mais rápida e a chamada da variável da tabela foi cerca de 1,5 segundos mais lenta. A desaceleração da variável da tabela pode ser parcialmente explicada pela compilação adiada pela variável da tabela , uma nova opção de otimizador em 2019.

Olhando para o diff no Perfview, ele mudou um pouco - o sqlmin não está mais lá - mas sqllang!TCacheStore<CacheClockAlgorithm>::GetNextUserDataInHashBucketestá.

NUTS

SQL Server 2019 (tabelas de sistema Tempdb na memória)

E essa novidade da tabela do sistema de memória? Hum? Sup com isso?

Vamos ligá-lo!

EXEC sys.sp_configure @configname = 'advanced', 
                      @configvalue = 1  
RECONFIGURE;

EXEC sys.sp_configure @configname = 'tempdb metadata memory-optimized', 
                      @configvalue = 1 
RECONFIGURE;

Observe que isso requer uma reinicialização do SQL Server para iniciar, então me desculpe enquanto eu reinicializo o SQL nesta adorável tarde de sexta-feira.

Agora as coisas parecem diferentes:

SELECT c.name,
       c.value_in_use,
       c.description
FROM sys.configurations AS c
WHERE c.name = 'tempdb metadata memory-optimized';

SELECT *, 
       OBJECT_NAME(object_id) AS object_name, 
       @@VERSION AS sql_server_version
FROM tempdb.sys.memory_optimized_tables_internal_attributes;

NUTS

Diferença de tempo:

  • Tabela temporária: 11638 ms
  • Variável de tabela: 7403 ms

As tabelas temporárias foram cerca de 4 segundos melhores! Isso é algo.

Eu gosto de alguma coisa

Desta vez, o diff Perfview não é muito interessante. Lado a lado, é interessante observar a proximidade dos horários:

NUTS

Um ponto interessante no diff são as chamadas para hkengine!, o que pode parecer óbvio, já que os recursos hekaton-ish estão em uso agora.

NUTS

Quanto aos dois itens principais no diff, eu não consigo entender muito ntoskrnl!?:

NUTS

Ou sqltses!CSqlSortManager_80::GetSortKey, mas eles estão aqui para Smrtr Ppl ™ olhar:

NUTS

Observe que há um documento não documentado e definitivamente não é seguro para produção, portanto, não o use sinalizador de rastreamento de inicialização que você pode usar para ter objetos adicionais do sistema da tabela temporária (sysrowsets, sysallocunits e sysseobjvalues) incluídos no recurso de memória, mas não fez uma diferença notável nos tempos de execução nesse caso.

Arredondar para cima

Mesmo nas versões mais recentes do SQL Server, as chamadas de alta frequência para variáveis ​​de tabela são muito mais rápidas que as chamadas de alta frequência para tabelas temporárias.

Embora seja tentador culpar compilações, recompilações, estatísticas automáticas, travas, spinlocks, cache ou outros problemas, o problema ainda está claramente relacionado ao gerenciamento da limpeza da tabela temporária.

É uma chamada mais próxima no SQL Server 2019 com as tabelas do sistema na memória ativadas, mas as variáveis ​​da tabela ainda apresentam melhor desempenho quando a frequência de chamadas é alta.

Obviamente, como um sábio vaping pensou: "use variáveis ​​de tabela quando a escolha do plano não for um problema".

Erik Darling
fonte
Bom - desculpe, eu perdi o fato de você ter adicionado uma resposta a isso até seguir o link na sua postagem de "depuração"
Martin Smith