Database
 sql >> Database >  >> RDS >> Database

Fare il caso per INVECE DI Trigger - Parte 1

L'anno scorso ho pubblicato un suggerimento chiamato Migliora l'efficienza di SQL Server passando a INSTEAD OF Triggers.

Il motivo principale per cui tendo a favorire un trigger INVECE DI, in particolare nei casi in cui mi aspetto molte violazioni della logica aziendale, è che sembra intuitivo che sarebbe più economico prevenire del tutto un'azione, piuttosto che andare avanti ed eseguirla (e log it!), solo per utilizzare un trigger AFTER per eliminare le righe incriminate (o ripristinare l'intera operazione). I risultati mostrati in quel suggerimento hanno dimostrato che questo era, in effetti, il caso - e sospetto che sarebbero ancora più pronunciati con indici più non raggruppati interessati dall'operazione.

Tuttavia, era su un disco lento e su uno dei primi CTP di SQL Server 2014. Nel preparare una diapositiva per una nuova presentazione che farò quest'anno sui trigger, ho scoperto che su una build più recente di SQL Server 2014:combinato con l'hardware aggiornato, è stato un po' più complicato dimostrare lo stesso delta nelle prestazioni tra un trigger AFTER e INSTEAD OF. Quindi ho deciso di scoprire il motivo, anche se ho subito capito che sarebbe stato più lavoro di quanto avessi mai fatto per una singola diapositiva.

Una cosa che voglio menzionare è che i trigger possono usare tempdb in modi diversi, e questo potrebbe spiegare alcune di queste differenze. Un trigger AFTER utilizza l'archivio versioni per le pseudo-tabelle inserite ed eliminate, mentre un trigger INSTEAD OF esegue una copia di questi dati in una tabella di lavoro interna. La differenza è sottile, ma vale la pena sottolineare.

Le variabili

Testerò vari scenari, tra cui:

  • Tre diversi trigger:
    • Un trigger AFTER che elimina righe specifiche che non riescono
    • Un trigger AFTER che esegue il rollback dell'intera transazione se una riga non riesce
    • Un trigger INSTEAD OF che inserisce solo le righe che passano
  • Diversi modelli di ripristino e impostazioni di isolamento degli snapshot:
    • COMPLETO con SNAPSHOT abilitato
    • FULL con SNAPSHOT disabilitato
    • SEMPLICE con SNAPSHOT abilitato
    • SEMPLICE con SNAPSHOT disabilitato
  • Diversi layout del disco*:
    • Dati su SSD, accedi a HDD da 7200 RPM
    • Dati su SSD, accedi a SSD
    • Dati su HDD da 7200 RPM, accesso su SSD
    • Dati su HDD da 7200 RPM, accesso su HDD da 7200 RPM
  • Diversi tassi di errore:
    • Tasso di errore del 10%, 25% e 50% su:
      • Inserimento batch singolo di 20.000 righe
      • 10 batch da 2.000 righe
      • 100 batch di 200 righe
      • 1.000 lotti di 20 righe
      • 20.000 inserti singleton

    * tempdb è un singolo file di dati su un disco lento da 7200 RPM. Questo è intenzionale e ha lo scopo di amplificare eventuali colli di bottiglia causati dai vari usi di tempdb . Ho intenzione di rivisitare questo test ad un certo punto quando tempdb è su un SSD più veloce.

Ok, già TL;DR!

Se vuoi solo conoscere i risultati, salta giù. Tutto nel mezzo è solo uno sfondo e una spiegazione di come ho impostato ed eseguito i test. Non ho il cuore spezzato dal fatto che non tutti saranno interessati a tutte le minuzie.

Lo scenario

Per questo particolare insieme di test, lo scenario reale è quello in cui un utente sceglie un nome visualizzato e il trigger è progettato per rilevare i casi in cui il nome scelto viola alcune regole. Ad esempio, non può essere una variazione di "ninny-muggins" (puoi sicuramente usare la tua immaginazione qui).

Ho creato una tabella con 20.000 nomi utente univoci:

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);

Quindi ho creato una tabella che sarebbe stata la fonte per i miei "nomi cattivi" da confrontare. In questo caso è solo ninny-muggins-00001 tramite 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;

Ho creato queste tabelle nel model database in modo che ogni volta che creo un database, esista localmente e ho intenzione di creare molti database per testare la matrice dello scenario sopra elencata (piuttosto che modificare semplicemente le impostazioni del database, cancellare il registro, ecc.). Tieni presente che se crei oggetti nel modello a scopo di test, assicurati di eliminarli quando hai finito.

Per inciso, lascerò intenzionalmente violazioni chiave e altri errori nella gestione di questo, supponendo ingenuamente che il nome scelto venga verificato per l'unicità molto prima che venga tentato l'inserimento, ma all'interno della stessa transazione (proprio come il il controllo contro la tabella dei nomi cattivi potrebbe essere stato fatto in anticipo).

Per supportare questo, ho anche creato le seguenti tre tabelle quasi identiche in model , ai fini dell'isolamento del test:

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 i seguenti tre trigger, uno per ogni tabella:

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

Probabilmente vorresti prendere in considerazione una gestione aggiuntiva per notificare all'utente che la sua scelta è stata annullata o ignorata, ma anche questo è omesso per semplicità.

La configurazione del test

Ho creato dati di esempio che rappresentano i tre tassi di errore che volevo testare, cambiando il 10 percento in 25 e poi 50 e aggiungendo anche queste tabelle a 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 -------------------------^^

Ogni tabella ha 20.000 righe, con una diversa combinazione di nomi che passeranno e non avranno esito negativo, e la colonna del numero di riga semplifica la suddivisione dei dati in batch di diverse dimensioni per test diversi, ma con tassi di errore ripetibili per tutti i test.

Ovviamente abbiamo bisogno di un posto dove catturare i risultati. Ho scelto di utilizzare un database separato per questo, eseguendo ogni test più volte, catturando semplicemente la durata.

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
);

Ho compilato dbo.Tests tabella con il seguente script, in modo da poter eseguire porzioni diverse per impostare i quattro database in modo che corrispondano ai parametri di test correnti. Nota che D:\ è un SSD, mentre G:\ è un disco da 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! :-)

Quindi è stato semplice eseguire tutti i test più volte:

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

Sul mio sistema ci sono volute quasi 6 ore, quindi preparati a lasciare che questo segua il suo corso ininterrottamente. Inoltre, assicurati di non avere connessioni attive o finestre di query aperte rispetto al model database, altrimenti potresti ricevere questo errore quando lo script tenta di creare un database:

Msg 1807, livello 16, stato 3
Impossibile ottenere il blocco esclusivo sul "modello" del database. Riprova l'operazione più tardi.

Risultati

Ci sono molti punti dati da esaminare (e tutte le query utilizzate per derivare i dati sono referenziate nell'Appendice). Tieni presente che ogni durata media indicata qui supera i 10 test e inserisce un totale di 100.000 righe nella tabella di destinazione.

Grafico 1 – Aggregati complessivi

Il primo grafico mostra gli aggregati complessivi (durata media) per le diverse variabili in isolamento (quindi *tutti* i test utilizzano un trigger AFTER che elimina, *tutti* i test utilizzano un trigger AFTER che esegue il rollback, ecc.).


Durata media, in millisecondi, per ogni variabile isolata em>

Alcune cose ci saltano subito all'occhio:

  • Il trigger INSTEAD OF qui è due volte più veloce di entrambi i trigger AFTER.
  • Avere il registro delle transazioni su SSD ha fatto un po' la differenza. Posizione del file di dati molto meno.
  • Il batch di 20.000 inserti singleton era 7-8 volte più lento di qualsiasi altra distribuzione batch.
  • L'inserimento in batch singolo di 20.000 righe è stato più lento di qualsiasi distribuzione non singleton.
  • Il tasso di errore, l'isolamento degli snapshot e il modello di ripristino hanno avuto un impatto minimo o nullo sulle prestazioni.

Grafico 2 – I migliori 10 in assoluto

Questo grafico mostra i 10 risultati più veloci quando viene considerata ogni variabile. Questi sono tutti INVECE DI trigger in cui la percentuale più alta di righe non riesce (50%). Sorprendentemente, il più veloce (anche se non di molto) aveva sia i dati che l'accesso sullo stesso HDD (non SSD). C'è un mix di layout del disco e modelli di ripristino qui, ma tutti e 10 avevano l'isolamento degli snapshot abilitato e i primi 7 risultati riguardavano tutti la dimensione del batch di 10 x 2.000 righe.


Le migliori 10 durate, in millisecondi, considerando ogni variabile

Il trigger AFTER più veloce, una variante ROLLBACK con un tasso di errore del 10% nella dimensione batch di 100 x 200 righe, è arrivato alla posizione n. 144 (806 ms).

Grafico 3 – I peggiori 10 in assoluto

Questo grafico mostra i 10 risultati più lenti quando si considera ogni variabile; tutti sono varianti AFTER, tutti coinvolgono i 20.000 inserti singleton e tutti hanno dati e accedono allo stesso HDD lento.


Le 10 durate peggiori, in millisecondi, considerando ogni variabile

Il test INSTEAD OF più lento era nella posizione n. 97, a 5.680 ms, un test di inserimento di 20.000 singleton in cui il 10% fallisce. È interessante anche osservare che nessun singolo trigger AFTER che utilizzava la dimensione del batch di 20.000 inserti singleton è andato meglio – infatti il ​​96° peggior risultato è stato un test AFTER (cancellazione) che è arrivato a 10.219 ms – quasi il doppio del successivo risultato più lento.

Grafico 4 – Tipo di disco di registro, inserimenti singleton

I grafici sopra ci danno un'idea approssimativa dei maggiori punti dolenti, ma sono troppo ingranditi o non abbastanza ingranditi. Questo grafico filtra i dati in base alla realtà:nella maggior parte dei casi questo tipo di operazione sarà un inserto singleton. Ho pensato di suddividerlo in base alla frequenza di errore e al tipo di disco su cui si trova il registro, ma guardare solo le righe in cui il batch è composto da 20.000 singoli inserti.


Durata, in millisecondi, raggruppata per tasso di errore e posizione del registro, per 20.000 singoli inserti

Qui vediamo che tutti i trigger AFTER hanno una media nell'intervallo di 10-11 secondi (a seconda della posizione del registro), mentre tutti i trigger INSTEAD OF sono ben al di sotto dei 6 secondi.

Conclusione

Finora, mi sembra chiaro che il trigger INSTEAD OF è vincente nella maggior parte dei casi, in alcuni casi più di altri (ad esempio, poiché il tasso di errore aumenta). Altri fattori, come il modello di recupero, sembrano avere un impatto molto minore sulle prestazioni complessive.

Se hai altre idee su come scomporre i dati o desideri una copia dei dati per eseguire le tue affettature e cubetti, faccelo sapere. Se desideri aiuto per configurare questo ambiente in modo da poter eseguire i tuoi test, posso aiutarti anche con quello.

Sebbene questo test dimostri che INSTEAD OF trigger vale sicuramente la pena considerare, non è l'intera storia. Ho letteralmente messo insieme questi trigger usando la logica che pensavo avesse più senso per ogni scenario, ma il codice trigger, come qualsiasi istruzione T-SQL, può essere ottimizzato per piani ottimali. In un post successivo, darò un'occhiata a una potenziale ottimizzazione che potrebbe rendere più competitivo il trigger AFTER.

Appendice

Query utilizzate per la sezione Risultati:

Grafico 1 – Aggregati complessivi

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;

Grafico 2 e 3:i migliori e i peggiori 10

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

Grafico 4 – Tipo di disco di registro, inserimenti 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;