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

Defendendo os gatilhos INSTEAD OF - Parte 1


No ano passado, publiquei uma dica chamada Melhorar a eficiência do SQL Server alternando para INSTEAD OF Triggers.

A grande razão pela qual costumo favorecer um gatilho INSTEAD OF, particularmente nos casos em que espero muitas violações da lógica de negócios, é que parece intuitivo que seria mais barato impedir uma ação completamente do que ir em frente e executá-la (e log it!), apenas para usar um gatilho AFTER para excluir as linhas ofensivas (ou reverter toda a operação). Os resultados mostrados nessa dica demonstraram que esse era, de fato, o caso – e suspeito que seriam ainda mais pronunciados com mais índices não clusterizados afetados pela operação.

No entanto, isso foi em um disco lento e em um CTP inicial do SQL Server 2014. Ao preparar um slide para uma nova apresentação que farei este ano sobre gatilhos, descobri que em uma versão mais recente do SQL Server 2014 – combinado com hardware atualizado – era um pouco mais complicado demonstrar o mesmo delta no desempenho entre um gatilho AFTER e INSTEAD OF. Então comecei a descobrir por que, mesmo sabendo imediatamente que isso daria mais trabalho do que eu já tinha feito para um único slide.

Uma coisa que quero mencionar é que os gatilhos podem usar tempdb de maneiras diferentes, e isso pode explicar algumas dessas diferenças. Um gatilho AFTER usa o armazenamento de versão para as pseudo-tabelas inseridas e excluídas, enquanto um gatilho INSTEAD OF faz uma cópia desses dados em uma tabela de trabalho interna. A diferença é sutil, mas vale a pena apontar.

As Variáveis


Vou testar vários cenários, incluindo:
  • Três acionadores diferentes:
    • Um gatilho AFTER que exclui linhas específicas que falham
    • Um gatilho AFTER que reverte toda a transação se alguma linha falhar
    • Um gatilho INSTEAD OF que insere apenas as linhas que passam
  • Diferentes modelos de recuperação e configurações de isolamento de instantâneos:
    • CHEIO com SNAPSHOT ativado
    • CHEIO com SNAPSHOT desativado
    • SIMPLE com SNAPSHOT ativado
    • SIMPLE com SNAPSHOT desativado
  • Diferentes layouts de disco*:
    • Dados no SSD, faça login no HDD de 7200 RPM
    • Dados no SSD, faça login no SSD
    • Dados no HDD de 7.200 RPM, faça login no SSD
    • Dados em HDD de 7200 RPM, faça login em HDD de 7200 RPM
  • Diferentes taxas de falha:
    • 10%, 25% e 50% de taxa de falha em:
      • Inserção de lote único de 20.000 linhas
      • 10 lotes de 2.000 linhas
      • 100 lotes de 200 linhas
      • 1.000 lotes de 20 linhas
      • 20.000 inserções singleton

    * tempdb é um único arquivo de dados em um disco lento de 7200 RPM. Isso é intencional e visa amplificar quaisquer gargalos causados ​​pelos vários usos de tempdb . Pretendo revisitar este teste em algum momento quando tempdb está em um SSD mais rápido.

Ok, TL;DR já!


Se você quer apenas saber os resultados, pule para baixo. Tudo no meio é apenas um pano de fundo e uma explicação de como configurei e executei os testes. Não estou com o coração partido porque nem todos estarão interessados ​​em todas as minúcias.

O cenário


Para esse conjunto específico de testes, o cenário da vida real é aquele em que um usuário escolhe um nome de tela e o gatilho é projetado para detectar casos em que o nome escolhido viola algumas regras. Por exemplo, não pode ser qualquer variação de "ninny-muggins" (você certamente pode usar sua imaginação aqui).

Eu criei uma tabela com 20.000 nomes de usuários exclusivos:
USE model;
GO
 
-- 20,000 distinct, good Names
;WITH distinct_Names AS
(
  SELECT Name FROM sys.all_columns
  UNION 
  SELECT Name FROM sys.all_objects
)
SELECT TOP (20000) Name 
INTO dbo.GoodNamesSource
FROM
(
  SELECT Name FROM distinct_Names
  UNION 
  SELECT Name + 'x' FROM distinct_Names
  UNION 
  SELECT Name + 'y' FROM distinct_Names
  UNION 
  SELECT Name + 'z' FROM distinct_Names
) AS x;
 
CREATE UNIQUE CLUSTERED INDEX x ON dbo.GoodNamesSource(Name);

Então eu criei uma tabela que seria a fonte para meus "nomes impertinentes" para verificar. Neste caso, é apenas ninny-muggins-00001 através de ninny-muggins-10000 :
USE model;
GO
 
CREATE TABLE dbo.NaughtyUserNames
(
  Name NVARCHAR(255) PRIMARY KEY
);
GO
 
-- 10,000 "bad" names
INSERT dbo.NaughtyUserNames(Name)
  SELECT N'ninny-muggins-' + RIGHT(N'0000' + RTRIM(n),5)
  FROM
  (
    SELECT TOP (10000) n = ROW_NUMBER() OVER (ORDER BY Name)
	FROM dbo.GoodNamesSource
  ) AS x;

Criei essas tabelas no model banco de dados para que toda vez que eu criar um banco de dados, ele exista localmente, e pretendo criar muitos bancos de dados para testar a matriz de cenários listada acima (em vez de apenas alterar as configurações do banco de dados, limpar o log etc.). Observe que, se você criar objetos no modelo para fins de teste, certifique-se de excluir esses objetos quando terminar.

Como um aparte, vou deixar intencionalmente violações de chave e outros erros de manipulação de fora disso, fazendo a suposição ingênua de que o nome escolhido é verificado quanto à exclusividade muito antes de a inserção ser tentada, mas dentro da mesma transação (assim como o nome escolhido verificação contra a tabela de nomes impertinentes poderia ter sido feita com antecedência).

Para dar suporte a isso, também criei as três tabelas a seguir quase idênticas em model , para fins de isolamento de teste:
USE model;
GO
 
 
-- AFTER (rollback)
CREATE TABLE dbo.UserNames_After_Rollback
(
  UserID INT IDENTITY(1,1) PRIMARY KEY,
  Name NVARCHAR(255) NOT NULL UNIQUE,
  DateCreated DATE NOT NULL DEFAULT SYSDATETIME()
);
CREATE INDEX x ON dbo.UserNames_After_Rollback(DateCreated) INCLUDE(Name);
 
 
-- AFTER (delete)
CREATE TABLE dbo.UserNames_After_Delete
(
  UserID INT IDENTITY(1,1) PRIMARY KEY,
  Name NVARCHAR(255) NOT NULL UNIQUE,
  DateCreated DATE NOT NULL DEFAULT SYSDATETIME()
);
CREATE INDEX x ON dbo.UserNames_After_Delete(DateCreated) INCLUDE(Name);
 
 
-- INSTEAD
CREATE TABLE dbo.UserNames_Instead
(
  UserID INT IDENTITY(1,1) PRIMARY KEY,
  Name NVARCHAR(255) NOT NULL UNIQUE,
  DateCreated DATE NOT NULL DEFAULT SYSDATETIME()
);
CREATE INDEX x ON dbo.UserNames_Instead(DateCreated) INCLUDE(Name);
GO

E os três gatilhos a seguir, um para cada tabela:
USE model;
GO
 
 
-- AFTER (rollback)
CREATE TRIGGER dbo.trUserNames_After_Rollback
ON dbo.UserNames_After_Rollback
AFTER INSERT
AS
BEGIN
  SET NOCOUNT ON;
 
  IF EXISTS 
  (
   SELECT 1 FROM inserted AS i
    WHERE EXISTS
    (
      SELECT 1 FROM dbo.NaughtyUserNames
      WHERE Name = i.Name
    )
  )
  BEGIN
    ROLLBACK TRANSACTION;
  END
END
GO
 
 
-- AFTER (delete)
CREATE TRIGGER dbo.trUserNames_After_Delete
ON dbo.UserNames_After_Delete
AFTER INSERT
AS
BEGIN
  SET NOCOUNT ON;
 
  DELETE d
    FROM inserted AS i
    INNER JOIN dbo.NaughtyUserNames AS n
    ON i.Name = n.Name
    INNER JOIN dbo.UserNames_After_Delete AS d
    ON i.UserID = d.UserID;
END
GO
 
 
-- INSTEAD
CREATE TRIGGER dbo.trUserNames_Instead
ON dbo.UserNames_Instead
INSTEAD OF INSERT
AS
BEGIN
  SET NOCOUNT ON;
 
  INSERT dbo.UserNames_Instead(Name)
    SELECT i.Name
      FROM inserted AS i
      WHERE NOT EXISTS
      (
        SELECT 1 FROM dbo.NaughtyUserNames
        WHERE Name = i.Name
      );
END
GO

Você provavelmente gostaria de considerar o tratamento adicional para notificar o usuário de que sua escolha foi revertida ou ignorada – mas isso também é deixado de fora para simplificar.

A configuração do teste


Criei dados de amostra representando as três taxas de falha que eu queria testar, alterando 10% para 25 e depois 50, e adicionando essas tabelas também ao model :
USE model;
GO
 
DECLARE @pct INT = 10, @cap INT = 20000;
-- change this ----^^ to 25 and 50
 
DECLARE @good INT = @cap - (@cap*(@pct/100.0));
 
SELECT Name, rn = ROW_NUMBER() OVER (ORDER BY NEWID()) 
INTO dbo.Source10Percent FROM 
-- change this ^^ to 25 and 50
(
  SELECT Name FROM 
  (
    SELECT TOP (@good) Name FROM dbo.GoodNamesSource ORDER BY NEWID()
  ) AS g
  UNION ALL
  SELECT Name FROM 
  (
    SELECT TOP (@cap-@good) Name FROM dbo.NaughtyUserNames ORDER BY NEWID()
  ) AS b
) AS x;
 
CREATE UNIQUE CLUSTERED INDEX x ON dbo.Source10Percent(rn);
-- and here as well -------------------------^^

Cada tabela tem 20.000 linhas, com uma combinação diferente de nomes que serão aprovados e reprovados, e a coluna do número da linha facilita a divisão dos dados em diferentes tamanhos de lote para diferentes testes, mas com taxas de falha repetíveis para todos os testes.

Claro que precisamos de um lugar para capturar os resultados. Optei por usar um banco de dados separado para isso, executando cada teste várias vezes, simplesmente capturando a duração.
CREATE DATABASE ControlDB;
GO
 
USE ControlDB;
GO
 
CREATE TABLE dbo.Tests
(
  TestID        INT, 
  DiskLayout    VARCHAR(15),
  RecoveryModel VARCHAR(6),
  TriggerType   VARCHAR(14),
  [snapshot]    VARCHAR(3),
  FailureRate   INT,
  [sql]         NVARCHAR(MAX)
);
 
CREATE TABLE dbo.TestResults
(
  TestID INT,
  BatchDescription VARCHAR(15),
  Duration INT
);

Eu preenchi o dbo.Tests table com o script a seguir, para que eu pudesse executar diferentes partes para configurar os quatro bancos de dados para corresponder aos parâmetros de teste atuais. Observe que D:\ é um SSD, enquanto G:\ é um disco de 7200 RPM:
TRUNCATE TABLE dbo.Tests;
TRUNCATE TABLE dbo.TestResults;
 
;WITH d AS 
(
  SELECT DiskLayout FROM (VALUES
    ('DataSSD_LogHDD'),
    ('DataSSD_LogSSD'),
    ('DataHDD_LogHDD'),
    ('DataHDD_LogSSD')) AS d(DiskLayout)
),
t AS 
(
  SELECT TriggerType FROM (VALUES
  ('After_Delete'),
  ('After_Rollback'),
  ('Instead')) AS t(TriggerType)
),
m AS 
(
  SELECT RecoveryModel = 'FULL' 
      UNION ALL SELECT 'SIMPLE'
),
s AS 
(
  SELECT IsSnapshot = 0 
      UNION ALL SELECT 1
),
p AS 
(
  SELECT FailureRate = 10 
      UNION ALL SELECT 25 
	  UNION ALL SELECT 50
)
INSERT ControlDB.dbo.Tests
(
  TestID, 
  DiskLayout, 
  RecoveryModel, 
  TriggerType, 
  IsSnapshot, 
  FailureRate, 
  Command
)
SELECT 
  TestID = ROW_NUMBER() OVER 
  (
    ORDER BY d.DiskLayout, t.TriggerType, m.RecoveryModel, s.IsSnapshot, p.FailureRate
  ),
  d.DiskLayout, 
  m.RecoveryModel, 
  t.TriggerType, 
  s.IsSnapshot, 
  p.FailureRate, 
  [sql]= N'SET NOCOUNT ON;
 
CREATE DATABASE ' + QUOTENAME(d.DiskLayout) 
 + N' ON (name = N''data'', filename = N''' + CASE d.DiskLayout 
WHEN 'DataSSD_LogHDD' THEN N'D:\data\data1.mdf'') 
  LOG ON (name = N''log'', filename = N''G:\log\data1.ldf'');'
WHEN 'DataSSD_LogSSD' THEN N'D:\data\data2.mdf'') 
  LOG ON (name = N''log'', filename = N''D:\log\data2.ldf'');'
WHEN 'DataHDD_LogHDD' THEN N'G:\data\data3.mdf'') 
  LOG ON (name = N''log'', filename = N''G:\log\data3.ldf'');'
WHEN 'DataHDD_LogSSD' THEN N'G:\data\data4.mdf'') 
  LOG ON (name = N''log'', filename = N''D:\log\data4.ldf'');' END
+ '
EXEC sp_executesql N''ALTER DATABASE ' + QUOTENAME(d.DiskLayout) 
  + ' SET RECOVERY ' + m.RecoveryModel + ';'';'
+ CASE WHEN s.IsSnapshot = 1 THEN 
'
EXEC sp_executesql N''ALTER DATABASE ' + QUOTENAME(d.DiskLayout) 
  + ' SET ALLOW_SNAPSHOT_ISOLATION ON;'';
EXEC sp_executesql N''ALTER DATABASE ' + QUOTENAME(d.DiskLayout) 
  + ' SET READ_COMMITTED_SNAPSHOT ON;'';' 
ELSE '' END
+ '
 
DECLARE @d DATETIME2(7), @i INT, @LoopID INT, @loops INT, @perloop INT;
 
DECLARE c CURSOR LOCAL FAST_FORWARD FOR
  SELECT LoopID, loops, perloop FROM dbo.Loops; 
 
OPEN c;
 
FETCH c INTO @LoopID, @loops, @perloop;
 
WHILE @@FETCH_STATUS <> -1
BEGIN
  EXEC sp_executesql N''TRUNCATE TABLE ' 
    + QUOTENAME(d.DiskLayout) + '.dbo.UserNames_' + t.TriggerType + ';'';
 
  SELECT @d = SYSDATETIME(), @i = 1;
 
  WHILE @i <= @loops
  BEGIN
    BEGIN TRY
      INSERT ' + QUOTENAME(d.DiskLayout) + '.dbo.UserNames_' + t.TriggerType + '(Name)
        SELECT Name FROM ' + QUOTENAME(d.DiskLayout) + '.dbo.Source' + RTRIM(p.FailureRate) + 'Percent
	    WHERE rn > (@i-1)*@perloop AND rn <= @i*@perloop;
    END TRY
    BEGIN CATCH
      SET @TestID = @TestID;
    END CATCH
 
    SET @i += 1;
  END
 
  INSERT ControlDB.dbo.TestResults(TestID, LoopID, Duration)
    SELECT @TestID, @LoopID, DATEDIFF(MILLISECOND, @d, SYSDATETIME());
 
  FETCH c INTO @LoopID, @loops, @perloop;
END
 
CLOSE c;
DEALLOCATE c;
 
DROP DATABASE ' + QUOTENAME(d.DiskLayout) + ';'
FROM d, t, m, s, p;  -- implicit CROSS JOIN! Do as I say, not as I do! :-)

Então foi simples executar todos os testes várias vezes:
USE ControlDB;
GO
 
SET NOCOUNT ON;
 
DECLARE @TestID INT, @Command NVARCHAR(MAX), @msg VARCHAR(32);
 
DECLARE d CURSOR LOCAL FAST_FORWARD FOR 
  SELECT TestID, Command
    FROM ControlDB.dbo.Tests ORDER BY TestID;
 
OPEN d;
 
FETCH d INTO @TestID, @Command;
 
WHILE @@FETCH_STATUS <> -1
BEGIN
  SET @msg = 'Starting ' + RTRIM(@TestID);
  RAISERROR(@msg, 0, 1) WITH NOWAIT;
 
  EXEC sp_executesql @Command, N'@TestID INT', @TestID;
 
  SET @msg = 'Finished ' + RTRIM(@TestID);
  RAISERROR(@msg, 0, 1) WITH NOWAIT;
 
  FETCH d INTO @TestID, @Command;
END
 
CLOSE d;
DEALLOCATE d;
 
GO 10

No meu sistema, isso levou cerca de 6 horas, portanto, esteja preparado para deixar isso seguir seu curso ininterrupto. Além disso, verifique se você não tem conexões ativas ou janelas de consulta abertas no model banco de dados, caso contrário, você pode receber este erro quando o script tentar criar um banco de dados:
Msg 1807, Level 16, State 3
Não foi possível obter bloqueio exclusivo no banco de dados 'model'. Repita a operação mais tarde.

Resultados


Há muitos pontos de dados a serem observados (e todas as consultas usadas para derivar os dados são referenciadas no Apêndice). Lembre-se de que cada duração média indicada aqui é superior a 10 testes e está inserindo um total de 100.000 linhas na tabela de destino.

Gráfico 1 – Agregados gerais

O primeiro gráfico mostra as agregações gerais (duração média) para as diferentes variáveis ​​isoladamente (portanto, *todos* os testes usando um gatilho AFTER que exclui, *todos* os testes usando um gatilho AFTER que reverte etc.).


Duração média, em milissegundos, para cada variável isoladamente

Algumas coisas saltam para nós imediatamente:
  • O acionador INSTEAD OF aqui é duas vezes mais rápido que os dois acionadores AFTER.
  • Ter o log de transações no SSD fez um pouco de diferença. Localização do arquivo de dados muito menos.
  • O lote de 20.000 inserções singleton foi de 7 a 8 vezes mais lento do que qualquer outra distribuição de lote.
  • A inserção de lote único de 20.000 linhas foi mais lenta do que qualquer uma das distribuições não singleton.
  • A taxa de falhas, o isolamento de instantâneos e o modelo de recuperação tiveram pouco ou nenhum impacto no desempenho.

Gráfico 2 – 10 melhores no geral

Este gráfico mostra os 10 resultados mais rápidos quando todas as variáveis ​​são consideradas. Esses são todos os gatilhos INSTEAD OF em que a maior porcentagem de linhas falha (50%). Surpreendentemente, o mais rápido (embora não muito) tinha dados e log no mesmo HDD (não SSD). Há uma mistura de layouts de disco e modelos de recuperação aqui, mas todos os 10 tinham o isolamento de instantâneo ativado e os 7 principais resultados envolveram o tamanho do lote de 10 x 2.000 linhas.


As 10 melhores durações, em milissegundos, considerando todas as variáveis

O gatilho AFTER mais rápido – uma variante ROLLBACK com 10% de taxa de falha no tamanho do lote de 100 x 200 linhas – veio na posição #144 (806 ms).

Gráfico 3 – Piores 10 no geral

Este gráfico mostra os 10 resultados mais lentos quando todas as variáveis ​​são consideradas; todas são variantes AFTER, todas envolvem as 20.000 inserções singleton e todas têm dados e log no mesmo disco rígido lento.


Piores 10 durações, em milissegundos, considerando todas as variáveis

O teste INSTEAD OF mais lento foi na posição #97, a 5.680 ms – um teste de inserção de 20.000 singletons em que 10% falharam. É interessante também observar que nem um único gatilho AFTER usando o tamanho de lote de inserção de 20.000 singletons se saiu melhor – na verdade, o 96º pior resultado foi um teste AFTER (delete) que chegou a 10.219 ms – quase o dobro do próximo resultado mais lento.

Gráfico 4 – Tipo de disco de registro, inserções singleton

Os gráficos acima nos dão uma ideia aproximada dos maiores pontos problemáticos, mas eles estão muito ampliados ou não são ampliados o suficiente. Este gráfico filtra os dados com base na realidade:na maioria dos casos, esse tipo de operação será uma inserção singleton. Eu pensei em dividi-lo pela taxa de falha e pelo tipo de disco em que o log está, mas apenas observe as linhas em que o lote é composto de 20.000 inserções individuais.


Duração, em milissegundos, agrupada por taxa de falha e localização do log, para 20.000 inserções individuais

Aqui vemos que todos os gatilhos AFTER estão em média no intervalo de 10 a 11 segundos (dependendo da localização do log), enquanto todos os gatilhos INSTEAD OF estão bem abaixo da marca de 6 segundos.

Conclusão


Até agora, parece claro para mim que o gatilho INSTEAD OF é um vencedor na maioria dos casos – em alguns casos mais do que em outros (por exemplo, à medida que a taxa de falha aumenta). Outros fatores, como o modelo de recuperação, parecem ter muito menos impacto no desempenho geral.

Se você tiver outras ideias sobre como dividir os dados, ou gostaria de uma cópia dos dados para realizar seu próprio corte e corte, por favor me avise. Se você quiser ajuda para configurar este ambiente para que possa executar seus próprios testes, também posso ajudar.

Embora este teste mostre que os gatilhos INSTEAD OF definitivamente valem a pena ser considerados, não é toda a história. Eu literalmente juntei esses gatilhos usando a lógica que achei que fazia mais sentido para cada cenário, mas o código do gatilho – como qualquer instrução T-SQL – pode ser ajustado para planos ideais. Em um post de acompanhamento, vou dar uma olhada em uma potencial otimização que pode tornar o gatilho AFTER mais competitivo.


Apêndice


Consultas usadas para a seção Resultados:

Gráfico 1 – Agregados gerais
SELECT RTRIM(l.loops) + ' x ' + RTRIM(l.perloop), AVG(r.Duration*1.0)
  FROM dbo.TestResults AS r
  INNER JOIN dbo.Loops AS l
  ON r.LoopID = l.LoopID
  GROUP BY RTRIM(l.loops) + ' x ' + RTRIM(l.perloop);
 
SELECT t.IsSnapshot, AVG(Duration*1.0)
  FROM dbo.TestResults AS tr
  INNER JOIN dbo.Tests AS t
  ON tr.TestID = t.TestID 
  GROUP BY t.IsSnapshot;
 
SELECT t.RecoveryModel, AVG(Duration*1.0)
  FROM dbo.TestResults AS tr
  INNER JOIN dbo.Tests AS t
  ON tr.TestID = t.TestID 
  GROUP BY t.RecoveryModel;
 
SELECT t.DiskLayout, AVG(Duration*1.0)
  FROM dbo.TestResults AS tr
  INNER JOIN dbo.Tests AS t
  ON tr.TestID = t.TestID 
  GROUP BY t.DiskLayout;
 
SELECT t.TriggerType, AVG(Duration*1.0)
  FROM dbo.TestResults AS tr
  INNER JOIN dbo.Tests AS t
  ON tr.TestID = t.TestID 
  GROUP BY t.TriggerType;
 
SELECT t.FailureRate, AVG(Duration*1.0)
  FROM dbo.TestResults AS tr
  INNER JOIN dbo.Tests AS t
  ON tr.TestID = t.TestID 
  GROUP BY t.FailureRate;

Gráfico 2 e 3 – 10 melhores e piores
;WITH src AS 
(
    SELECT DiskLayout, RecoveryModel, TriggerType, FailureRate, IsSnapshot,
      Batch = RTRIM(l.loops) + ' x ' + RTRIM(l.perloop),
      Duration = AVG(Duration*1.0)
    FROM dbo.Tests AS t
    INNER JOIN dbo.TestResults AS tr
    ON tr.TestID = t.TestID 
    INNER JOIN dbo.Loops AS l
    ON tr.LoopID = l.LoopID
    GROUP BY DiskLayout, RecoveryModel, TriggerType, FailureRate, IsSnapshot,
      RTRIM(l.loops) + ' x ' + RTRIM(l.perloop)
),
agg AS
(
    SELECT label = REPLACE(REPLACE(DiskLayout,'Data',''),'_Log','/')
      + ', ' + RecoveryModel + ' recovery, ' + TriggerType
  	+ ', ' + RTRIM(FailureRate) + '% fail'
	+ ', Snapshot = ' + CASE IsSnapshot WHEN 1 THEN 'ON' ELSE 'OFF' END
  	+ ', ' + Batch + ' (ops x rows)',
      best10  = ROW_NUMBER() OVER (ORDER BY Duration), 
      worst10 = ROW_NUMBER() OVER (ORDER BY Duration DESC),
      Duration
    FROM src
)
SELECT grp, label, Duration FROM
(
  SELECT TOP (20) grp = 'best', label = RIGHT('0' + RTRIM(best10),2) + '. ' + label, Duration
    FROM agg WHERE best10 <= 10
    ORDER BY best10 DESC
  UNION ALL
  SELECT TOP (20) grp = 'worst', label = RIGHT('0' + RTRIM(worst10),2) + '. ' + label, Duration
    FROM agg WHERE worst10 <= 10
    ORDER BY worst10 DESC
  ) AS b
  ORDER BY grp;

Gráfico 4 – Tipo de disco de registro, inserções singleton
;WITH x AS
(
    SELECT 
      TriggerType,FailureRate,
      LogLocation = RIGHT(DiskLayout,3), 
      Duration = AVG(Duration*1.0)
    FROM dbo.TestResults AS tr
    INNER JOIN dbo.Tests AS t
    ON tr.TestID = t.TestID 
    INNER JOIN dbo.Loops AS l
    ON l.LoopID = tr.LoopID
    WHERE l.loops = 20000
    GROUP BY RIGHT(DiskLayout,3), FailureRate, TriggerType
)
SELECT TriggerType, FailureRate, 
  HDDDuration = MAX(CASE WHEN LogLocation = 'HDD' THEN Duration END),
  SSDDuration = MAX(CASE WHEN LogLocation = 'SSD' THEN Duration END)
FROM x 
GROUP BY TriggerType, FailureRate
ORDER BY TriggerType, FailureRate;