Почему в этом конкретном случае использование табличной переменной более чем в два раза быстрее, чем таблицы #temp?

37

Я просматривал здесь статью « Временные таблицы и переменные таблиц и их влияние на производительность SQL Server», а на SQL Server 2008 удалось воспроизвести результаты, аналогичные показанным в 2005 году.

При выполнении хранимых процедур (определения ниже) только с 10 строками версия табличной переменной out выполняет временную версию таблицы более чем в два раза.

Я очистил кэш процедур и запустил обе хранимые процедуры 10 000 раз, затем повторил процесс еще для 4 запусков. Результаты ниже (время в мс на пакет)

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

У меня вопрос: в чем причина лучшей производительности версии табличной переменной?

Я провел некоторое расследование. например, глядя на счетчики производительности с

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

подтверждает, что в обоих случаях временные объекты кэшируются после первого запуска, как ожидалось, а не создаются заново с нуля для каждого вызова.

Точно так же отслеживание Auto Stats, SP:Recompile, SQL:StmtRecompileсобытие в Profiler (скриншот ниже) показывает , что эти события происходят один раз (на первый вызов #tempхранимой процедуры таблицы) и другие 9,999 казни не вызывает какую - либо из этих событий. (Версия табличной переменной не получает ни одно из этих событий)

след

Немного большие накладные расходы при первом запуске хранимой процедуры никоим образом не могут объяснить большую общую разницу, тем не менее, поскольку для очистки кэша процедур и запуска обеих процедур требуется всего несколько мс, поэтому я не верю ни статистике, ни перекомпиляция может быть причиной.

Создать необходимые объекты базы данных

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

Тестовый скрипт

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
Мартин Смит
источник
Трассировка профилировщика показывает, что статистика создается в #tempтаблице только один раз, несмотря на то, что она очищается и повторно заполняется еще 9 999 раз после этого.
Мартин Смит

Ответы:

31

Выход SET STATISTICS IO ONдля обоих выглядит одинаково

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

И, как указывает Аарон в комментариях, план для версии табличной переменной на самом деле менее эффективен, поскольку оба имеют план вложенных циклов, управляемый поиском по индексу dbo.NUMв #tempтабличной версии, выполняет поиск по индексу [#T].n = [dbo].[NUM].[n]с остаточным предикатом, [#T].[n]<=[@total]тогда как табличная переменная version выполняет поиск по индексу @V.n <= [@total]с остаточным предикатом @V.[n]=[dbo].[NUM].[n]и, таким образом, обрабатывает больше строк (именно поэтому этот план работает так плохо для большего числа строк)

Использование расширенных событий для просмотра типов ожидания для конкретного spid дает эти результаты для 10 000 выполненийEXEC 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              |
+---------------------+------------+----------------+----------------+----------------+

и эти результаты для 10000 казней 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              |
+---------------------+------------+----------------+----------------+----------------+

Таким образом, ясно, что количество PAGELATCH_SHожиданий намного выше в #tempслучае таблицы. Я не знаю ни одного способа добавления ресурса ожидания в расширенную трассировку событий, поэтому для дальнейшего изучения я запустил

WHILE 1=1
EXEC dbo.T2 10

Пока в другой связи опрос 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'

После того, как он был запущен в течение 15 секунд, он получил следующие результаты

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

Обе эти блокируемые страницы принадлежат (разным) некластеризованным индексам в tempdb.sys.sysschobjsбазовой таблице с именами 'nc1'и 'nc2'.

Запросы tempdb.sys.fn_dblogво время выполнения указывают, что количество записей журнала, добавленных при первом выполнении каждой хранимой процедуры, было несколько переменным, но для последующих выполнений число, добавленное каждой итерацией, было очень последовательным и предсказуемым. После того, как планы процедур кэшированы, количество записей в журнале становится примерно вдвое меньше, чем необходимо для #tempверсии.

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

Рассматривая записи журнала транзакций более подробно для #tempтабличной версии SP, каждый последующий вызов хранимой процедуры создает три транзакции, а переменная таблицы - только две.

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

В INSERT/ TVQUERYоперации идентичны , за исключением имени. Он содержит записи журнала для каждой из 10 строк, вставленных во временную таблицу или переменную таблицы, плюс записи LOP_BEGIN_XACT/ LOP_COMMIT_XACT.

CREATE TABLEТранзакция появляется только в #Tempверсии и выглядит следующим образом .

+-----------------+-------------------+---------------------+
|    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          |                     |
+-----------------+-------------------+---------------------+

FCheckAndCleanupCachedTempTableТранзакция появляется в обоих , но имеет 6 дополнительных записей в #tempверсии. Это 6 строк, которые относятся к sys.sysschobjsтому же шаблону, что и выше.

+-----------------+-------------------+----------------------------------------------+
|    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          |                                              |
+-----------------+-------------------+----------------------------------------------+

Глядя на эти 6 строк в обеих транзакциях, они соответствуют одним и тем же операциям. Первый LOP_MODIFY_ROW, LCX_CLUSTERED- это обновление modify_dateстолбца в sys.objects. Все остальные пять строк связаны с переименованием объекта. Поскольку nameэто ключевой столбец обоих затронутых NCI ( nc1и nc2), он выполняется как удаление / вставка для тех, кто возвращается к кластерному индексу и обновляет его.

Похоже, что для #tempверсии таблицы, когда хранимая процедура завершается, часть очистки, выполняемой FCheckAndCleanupCachedTempTableтранзакцией, состоит в том, чтобы переименовать временную таблицу из чего-то похожего #T__________________________________________________________________________________________________________________00000000E316на другое внутреннее имя, например, #2F4A0079когда она введена, CREATE TABLEтранзакция переименовывает ее обратно. Это триггерное имя можно увидеть в одном соединении, которое выполняется dbo.T2в цикле, а в другом

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

Пример результатов

Скриншот

Таким образом, одно потенциальное объяснение наблюдаемой разницы в производительности, на которое ссылается Алекс, заключается в том, что именно эта дополнительная работа по сопровождению системных таблиц tempdbявляется ответственной.


При выполнении обеих процедур в цикле профилировщик кода Visual Studio обнаруживает следующее

+-------------------------------+--------------------+-------+-----------+
|           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      |
+-------------------------------+--------------------+-------+-----------+

Версия табличной переменной тратит около 60% времени на выполнение оператора вставки и последующего выбора, тогда как временная таблица меньше, чем половина. Это согласуется с временными интервалами, показанными в OP, и с заключением выше, что разница в производительности обусловлена ​​временем, затрачиваемым на выполнение вспомогательной работы, а не временем, затрачиваемым на само выполнение запроса.

Наиболее важные функции, способствующие «пропущенным» 75% в версии временного стола:

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

В обеих функциях создания и выпуска функция CMEDProxyObject::SetNameотображается с включенным значением выборки 19.6%. Из чего я делаю вывод, что 39,2% времени в случае временной таблицы занято переименованием, описанным ранее.

А самые большие в версии табличных переменных, которые вносят вклад в остальные 40%,

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

Профиль временного стола

введите описание изображения здесь

Профиль переменной таблицы

введите описание изображения здесь

Мартин Смит
источник
10

Дискотека Inferno

Поскольку это более старый вопрос, я решил вернуться к этой проблеме в более новых версиях SQL Server, чтобы посмотреть, существует ли тот же профиль производительности или же характеристики изменились вообще.

В частности, добавление системных таблиц в памяти для SQL Server 2019 представляется достойным поводом для повторного тестирования.

Я использую немного другой тестовый комплект, поскольку столкнулся с этой проблемой, работая над чем-то другим.

Тестирование, тестирование

Используя версию переполнения стека 2013 года , у меня есть этот индекс и эти две процедуры:

Показатель:

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

Временная таблица:

    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 

Переменная таблицы:

    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 

Чтобы предотвратить потенциальное ожидание ASYNC_NETWORK_IO , я использую процедуры-оболочки.

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

Так как 2014 и 2016 годы в основном являются RELICS, я начинаю свое тестирование с 2017 года. Также, для краткости, я прыгаю прямо к профилированию кода с помощью Perfview . В реальной жизни я смотрел на ожидания, защелки, спин-блокировки, сумасшедшие трассировочные флаги и другие вещи.

Профилирование кода - единственное, что выявило что-либо интересное.

Разница во времени:

  • Temp Table: 17891 мс
  • Переменная таблицы: 5891 мс

Все еще очень четкая разница, а? Но что сейчас поражает SQL Server?

NUTS

Глядя на два верхних прироста разбросанных образцов, мы видим sqlminи sqlsqllang!TCacheStore<CacheClockAlgorithm>::GetNextUserDataInHashBucketявляемся двумя крупнейшими преступниками.

NUTS

Судя по именам в стеках вызовов, очистка и внутреннее переименование временных таблиц, по-видимому, являются самыми большими временными отсосами при вызове временной таблицы по сравнению с вызовом табличной переменной.

Несмотря на то, что табличные переменные внутренне поддерживаются временными таблицами, это не кажется проблемой.

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

Таблица "# B98CE339". Количество сканирования 1

Просмотр стеков вызовов для теста табличных переменных вообще не показывает ни одного из основных нарушителей:

NUTS

SQL Server 2019 (Vanilla)

Хорошо, так что это все еще проблема в SQL Server 2017, что-то другое в 2019 году из коробки?

Во-первых, чтобы показать, что в моем рукаве ничего нет:

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

NUTS

Разница во времени:

  • Временная таблица: 15765 мс
  • Переменная таблицы: 7250 мс

Обе процедуры были разные. Вызов временной таблицы был на пару секунд быстрее, а вызов табличной переменной - примерно на 1,5 секунды медленнее. Замедление табличных переменных может быть частично объяснено отложенной компиляцией табличных переменных , новым выбором оптимизатора в 2019 году.

Глядя на diff в Perfview, он немного изменился - sqlmin больше нет - но sqllang!TCacheStore<CacheClockAlgorithm>::GetNextUserDataInHashBucketесть.

NUTS

SQL Server 2019 (системные таблицы Tempdb в памяти)

Как насчет этого нового в памяти системной таблицы? Хм? С чем это?

Давайте включим это!

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

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

Обратите внимание, что для этого требуется перезапуск SQL Server, поэтому простите меня, пока я перезагружаю SQL в этот прекрасный пятничный день.

Теперь все выглядит иначе:

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

Разница во времени:

  • Временная таблица: 11638 мс
  • Переменная таблицы: 7403 мс

Временные таблицы работали на 4 секунды лучше! Это что-то.

Мне что то нравится

На этот раз разница в Perfview не очень интересна. Наряду с этим интересно отметить, насколько близки времена по всем направлениям:

NUTS

Один интересный момент в diff - это вызовы hkengine!, которые могут показаться очевидными, поскольку в настоящее время используются функции hekaton-ish.

NUTS

Что касается двух верхних элементов в diff, я не могу сделать большую часть из ntoskrnl!?:

NUTS

Или sqltses!CSqlSortManager_80::GetSortKey, но они здесь для Smrtr Ppl ™, чтобы посмотреть:

NUTS

Обратите внимание, что документ недокументирован и определенно небезопасен для производства, поэтому, пожалуйста, не используйте его флаг трассировки запуска, который можно использовать для включения дополнительных системных объектов временных таблиц (sysrowsets, sysallocunits и sysseobjvalues), включенных в функцию в памяти, но это не сделал заметной разницы во времени выполнения в этом случае.

Округлять

Даже в более новых версиях SQL-сервера высокочастотные вызовы табличных переменных намного быстрее, чем высокочастотные вызовы временных таблиц.

Хотя соблазнительно винить компиляцию, перекомпиляцию, автоматическую статистику, защелки, спин-блокировки, кэширование или другие проблемы, проблема все еще остается в управлении очисткой временной таблицы.

Это более близкий вызов в SQL Server 2019 с включенными системными таблицами в памяти, но табличные переменные все еще работают лучше, когда частота вызовов высока.

Конечно, как сказал вейп-мудрец, «используйте переменные таблицы, когда выбор плана не является проблемой».

Эрик Дарлинг
источник
Хорошо - извините, я пропустил, что вы добавили ответ на этот вопрос, пока не перешли по ссылке в своем посте «отладка» в блоге
Мартин Смит