Il mese scorso, ho coperto un puzzle che prevedeva l'abbinamento di ogni riga di una tabella con la corrispondenza più vicina di un'altra tabella. Ho ricevuto questo enigma da Karen Ly, analista di reddito fisso Jr. presso RBC. Ho trattato due principali soluzioni relazionali che combinavano l'operatore APPLY con subquery basate su TOP. La soluzione 1 ha sempre un ridimensionamento quadratico. La soluzione 2 ha funzionato abbastanza bene quando è stata fornita con buoni indici di supporto, ma senza quegli indici aveva anche il ridimensionamento quadrico. In questo articolo tratterò soluzioni iterative, che, nonostante siano generalmente disapprovate dai professionisti SQL, forniscono un ridimensionamento molto migliore nel nostro caso anche senza un'indicizzazione ottimale.
La sfida
Come rapido promemoria, la nostra sfida riguarda le tabelle denominate T1 e T2, che crei con il seguente codice:
SET NOCOUNT ON; IF DB_ID('testdb') IS NULL CREATE DATABASE testdb; GO USE testdb; DROP TABLE IF EXISTS dbo.T1, dbo.T2; CREATE TABLE dbo.T1 ( keycol INT NOT NULL IDENTITY CONSTRAINT PK_T1 PRIMARY KEY, val INT NOT NULL, othercols BINARY(100) NOT NULL CONSTRAINT DFT_T1_col1 DEFAULT(0xAA) ); CREATE TABLE dbo.T2 ( keycol INT NOT NULL IDENTITY CONSTRAINT PK_T2 PRIMARY KEY, val INT NOT NULL, othercols BINARY(100) NOT NULL CONSTRAINT DFT_T2_col1 DEFAULT(0xBB) );
Utilizzare quindi il codice seguente per popolare le tabelle con piccoli insiemi di dati di esempio al fine di verificare la correttezza delle soluzioni:
TRUNCATE TABLE dbo.T1; TRUNCATE TABLE dbo.T2; INSERT INTO dbo.T1 (val) VALUES(1),(1),(3),(3),(5),(8),(13),(16),(18),(20),(21); INSERT INTO dbo.T2 (val) VALUES(2),(2),(7),(3),(3),(11),(11),(13),(17),(19);
Ricordiamo che la sfida era abbinare a ciascuna riga di T1 la riga di T2 dove la differenza assoluta tra T2.val e T1.val è la più bassa. In caso di pareggio, dovresti usare val crescente, keycol ordine crescente come spareggio.
Ecco il risultato desiderato per i dati di esempio forniti:
keycol1 val1 othercols1 keycol2 val2 othercols2 ----------- ----------- ---------- ----------- ----------- ---------- 1 1 0xAA 1 2 0xBB 2 1 0xAA 1 2 0xBB 3 3 0xAA 4 3 0xBB 4 3 0xAA 4 3 0xBB 5 5 0xAA 4 3 0xBB 6 8 0xAA 3 7 0xBB 7 13 0xAA 8 13 0xBB 8 16 0xAA 9 17 0xBB 9 18 0xAA 9 17 0xBB 10 20 0xAA 10 19 0xBB 11 21 0xAA 10 19 0xBB
Per verificare le prestazioni delle tue soluzioni sono necessari set di dati campione più ampi. Per prima cosa crei la funzione di supporto GetNums, che genera una sequenza di numeri interi in un intervallo richiesto, utilizzando il codice seguente:
DROP FUNCTION IF EXISTS dbo.GetNums; GO CREATE OR ALTER FUNCTION dbo.GetNums(@low AS BIGINT, @high AS BIGINT) RETURNS TABLE AS RETURN WITH L0 AS (SELECT c FROM (SELECT 1 UNION ALL SELECT 1) AS D(c)), L1 AS (SELECT 1 AS c FROM L0 AS A CROSS JOIN L0 AS B), L2 AS (SELECT 1 AS c FROM L1 AS A CROSS JOIN L1 AS B), L3 AS (SELECT 1 AS c FROM L2 AS A CROSS JOIN L2 AS B), L4 AS (SELECT 1 AS c FROM L3 AS A CROSS JOIN L3 AS B), L5 AS (SELECT 1 AS c FROM L4 AS A CROSS JOIN L4 AS B), Nums AS (SELECT ROW_NUMBER() OVER(ORDER BY (SELECT NULL)) AS rownum FROM L5) SELECT TOP(@high - @low + 1) @low + rownum - 1 AS n FROM Nums ORDER BY rownum; GO
Si popola quindi T1 e T2 utilizzando il seguente codice, regolando i parametri indicando il numero di righe e i valori massimi in base alle proprie esigenze:
DECLARE @numrowsT1 AS INT = 1000000, @maxvalT1 AS INT = 10000000, @numrowsT2 AS INT = 1000000, @maxvalT2 AS INT = 10000000; TRUNCATE TABLE dbo.T1; TRUNCATE TABLE dbo.T2; INSERT INTO dbo.T1 WITH(TABLOCK) (val) SELECT ABS(CHECKSUM(NEWID())) % @maxvalT1 + 1 AS val FROM dbo.GetNums(1, @numrowsT1) AS Nums; INSERT INTO dbo.T2 WITH(TABLOCK) (val) SELECT ABS(CHECKSUM(NEWID())) % @maxvalT2 + 1 AS val FROM dbo.GetNums(1, @numrowsT2) AS Nums;
In questo esempio stai compilando le tabelle con 1.000.000 di righe ciascuna, con valori nell'intervallo 1 – 10.000.000 nella colonna val (bassa densità).
Soluzione 3, utilizzando un cursore e una variabile di tabella basata su disco
Un'efficiente soluzione iterativa per la nostra sfida di corrispondenza più vicina si basa su un algoritmo simile all'algoritmo Merge join. L'idea è di applicare un solo passaggio ordinato a ciascuna tabella utilizzando i cursori, valutando gli elementi di ordinamento e tiebreak in ogni round per decidere da che parte avanzare e abbinando le righe lungo il percorso.
Il passaggio ordinato su ciascuna tabella trarrà sicuramente vantaggio dal supporto degli indici, ma l'implicazione della mancanza di quelli è che avrà luogo l'ordinamento esplicito. Ciò significa che la parte di ordinamento subirà n log n ridimensionamento, ma è molto meno grave del ridimensionamento quadratico che ottieni dalla Soluzione 2 in circostanze simili.
Inoltre, le prestazioni delle soluzioni 1 e 2 sono state influenzate dalla densità della colonna val. Con una densità maggiore il piano applicava meno rilegature. Al contrario, poiché le soluzioni iterative eseguono un solo passaggio su ciascuno degli input, la densità della colonna val non è un fattore che influisce sulle prestazioni.
Utilizza il codice seguente per creare indici di supporto:
CREATE INDEX idx_val_key ON dbo.T1(val, keycol) INCLUDE(othercols); CREATE INDEX idx_val_key ON dbo.T2(val, keycol) INCLUDE(othercols);
Assicurati di testare le soluzioni sia con che senza questi indici.
Ecco il codice completo per la Soluzione 3:
SET NOCOUNT ON; BEGIN TRAN; DECLARE @keycol1 AS INT, @val1 AS INT, @othercols1 AS BINARY(100), @keycol2 AS INT, @val2 AS INT, @othercols2 AS BINARY(100), @prevkeycol2 AS INT, @prevval2 AS INT, @prevothercols2 AS BINARY(100), @C1 AS CURSOR, @C2 AS CURSOR, @C1fetch_status AS INT, @C2fetch_status AS INT; DECLARE @Result AS TABLE ( keycol1 INT NOT NULL PRIMARY KEY, val1 INT NOT NULL, othercols1 BINARY(100) NOT NULL, keycol2 INT NULL, val2 INT NULL, othercols2 BINARY(100) NULL ); SET @C1 = CURSOR FORWARD_ONLY STATIC READ_ONLY FOR SELECT keycol, val, othercols FROM dbo.T1 ORDER BY val, keycol; SET @C2 = CURSOR FORWARD_ONLY STATIC READ_ONLY FOR SELECT keycol, val, othercols FROM dbo.T2 ORDER BY val, keycol; OPEN @C1; OPEN @C2; FETCH NEXT FROM @C2 INTO @keycol2, @val2, @othercols2; SET @C2fetch_status = @@fetch_status; SELECT @prevkeycol2 = @keycol2, @prevval2 = @val2, @prevothercols2 = @othercols2; FETCH NEXT FROM @C1 INTO @keycol1, @val1, @othercols1; SET @C1fetch_status = @@fetch_status; WHILE @C1fetch_status = 0 BEGIN IF @val1 <= @val2 OR @C2fetch_status <> 0 BEGIN IF ABS(@val1 - @val2) < ABS(@val1 - @prevval2) INSERT INTO @Result(keycol1, val1, othercols1, keycol2, val2, othercols2) VALUES(@keycol1, @val1, @othercols1, @keycol2, @val2, @othercols2); ELSE INSERT INTO @Result(keycol1, val1, othercols1, keycol2, val2, othercols2) VALUES(@keycol1, @val1, @othercols1, @prevkeycol2, @prevval2, @prevothercols2); FETCH NEXT FROM @C1 INTO @keycol1, @val1, @othercols1; SET @C1fetch_status = @@fetch_status; END ELSE IF @C2fetch_status = 0 BEGIN IF @val2 > @prevval2 SELECT @prevkeycol2 = @keycol2, @prevval2 = @val2, @prevothercols2 = @othercols2; FETCH NEXT FROM @C2 INTO @keycol2, @val2, @othercols2; SET @C2fetch_status = @@fetch_status; END; END; SELECT keycol1, val1, SUBSTRING(othercols1, 1, 1) AS othercols1, keycol2, val2, SUBSTRING(othercols2, 1, 1) AS othercols2 FROM @Result; COMMIT TRAN;
Il codice usa una variabile di tabella chiamata @Result per memorizzare le corrispondenze e alla fine le restituisce interrogando la variabile di tabella. Si noti che il codice esegue il lavoro in una transazione per ridurre la registrazione.
Il codice utilizza variabili cursore denominate @C1 e @C2 per scorrere le righe rispettivamente in T1 e T2, in entrambi i casi ordinate per val, keycol. Le variabili locali vengono utilizzate per memorizzare i valori di riga correnti di ciascun cursore (@keycol1, @val1 e @othercols1 per @C1 e @keycol2, @val2 e @othercols2 per @C2). Ulteriori variabili locali memorizzano i valori della riga precedente da @C2 (@prevkeycol2, @prevval2 e @prevothercols2). Le variabili @C1fetch_status e @C2fetch_status mantengono lo stato dell'ultimo recupero dal rispettivo cursore.
Dopo aver dichiarato e aperto entrambi i cursori, il codice recupera una riga da ciascun cursore nelle rispettive variabili locali e inizialmente memorizza i valori di riga correnti da @C2 anche nelle variabili di riga precedenti. Il codice entra quindi in un ciclo che continua a essere eseguito mentre l'ultimo recupero da @C1 ha avuto esito positivo (@C1fetch_status =0). Il corpo del ciclo applica il seguente pseudocodice in ogni round:
If @val1 <= @val2 or reached end of @C2 Begin If absolute difference between @val1 and @val2 is less than between @val1 and @prevval2 Add row to @Result with current row values from @C1 and current row values from @C2 Else Add row to @Result with current row values from @C1 and previous row values from @C2 Fetch next row from @C1 End Else if last fetch from @C2 was successful Begin If @val2 > @prevval2 Set variables holding @C2’s previous row values to values of current row variables Fetch next row from @C2 End
Il codice quindi interroga semplicemente la variabile della tabella @Result per restituire tutte le corrispondenze.
Utilizzando i grandi set di dati di esempio (1.000.000 di righe in ogni tabella), con l'indicizzazione ottimale in atto, questa soluzione ha richiesto 38 secondi per essere completata sul mio sistema ed ha eseguito 28.240 letture logiche. Naturalmente, il ridimensionamento di questa soluzione è quindi lineare. Senza un'indicizzazione ottimale, sono stati necessari 40 secondi per il completamento (solo 2 secondi in più!) ed è stato eseguito 29.519 letture logiche. La parte di ordinamento in questa soluzione ha n log n ridimensionamento.
Soluzione 4, utilizzando un cursore e una variabile di tabella ottimizzata per la memoria
Nel tentativo di migliorare le prestazioni dell'approccio iterativo, una cosa che potresti provare è sostituire l'uso della variabile di tabella basata su disco con una ottimizzata per la memoria. Poiché la soluzione prevede la scrittura di 1.000.000 di righe nella variabile di tabella, ciò potrebbe comportare un miglioramento non trascurabile.
Innanzitutto, è necessario abilitare OLTP in memoria nel database creando un filegroup contrassegnato come CONTAINS MEMORY_OPTIMIZED_DATA e al suo interno un contenitore che punta a una cartella nel file system. Supponendo che tu abbia creato una cartella principale chiamata C:\IMOLTP\, usa il codice seguente per applicare questi due passaggi:
ALTER DATABASE testdb ADD FILEGROUP testdb_MO CONTAINS MEMORY_OPTIMIZED_DATA; ALTER DATABASE testdb ADD FILE ( NAME = testdb_dir, FILENAME = 'C:\IMOLTP\testdb_dir' ) TO FILEGROUP testdb_MO;
Il passaggio successivo consiste nel creare un tipo di tabella ottimizzato per la memoria come modello per la nostra variabile di tabella eseguendo il codice seguente:
DROP TYPE IF EXISTS dbo.TYPE_closestmatch; GO CREATE TYPE dbo.TYPE_closestmatch AS TABLE ( keycol1 INT NOT NULL PRIMARY KEY NONCLUSTERED, val1 INT NOT NULL, othercols1 BINARY(100) NOT NULL, keycol2 INT NULL, val2 INT NULL, othercols2 BINARY(100) NULL ) WITH (MEMORY_OPTIMIZED = ON);
Quindi, invece della dichiarazione originale della variabile di tabella @Result, useresti il seguente codice:
DECLARE @Result AS dbo.TYPE_closestmatch;
Ecco il codice completo della soluzione:
SET NOCOUNT ON; USE testdb; BEGIN TRAN; DECLARE @keycol1 AS INT, @val1 AS INT, @othercols1 AS BINARY(100), @keycol2 AS INT, @val2 AS INT, @othercols2 AS BINARY(100), @prevkeycol2 AS INT, @prevval2 AS INT, @prevothercols2 AS BINARY(100), @C1 AS CURSOR, @C2 AS CURSOR, @C1fetch_status AS INT, @C2fetch_status AS INT; DECLARE @Result AS dbo.TYPE_closestmatch; SET @C1 = CURSOR FORWARD_ONLY STATIC READ_ONLY FOR SELECT keycol, val, othercols FROM dbo.T1 ORDER BY val, keycol; SET @C2 = CURSOR FORWARD_ONLY STATIC READ_ONLY FOR SELECT keycol, val, othercols FROM dbo.T2 ORDER BY val, keycol; OPEN @C1; OPEN @C2; FETCH NEXT FROM @C2 INTO @keycol2, @val2, @othercols2; SET @C2fetch_status = @@fetch_status; SELECT @prevkeycol2 = @keycol2, @prevval2 = @val2, @prevothercols2 = @othercols2; FETCH NEXT FROM @C1 INTO @keycol1, @val1, @othercols1; SET @C1fetch_status = @@fetch_status; WHILE @C1fetch_status = 0 BEGIN IF @val1 <= @val2 OR @C2fetch_status <> 0 BEGIN IF ABS(@val1 - @val2) < ABS(@val1 - @prevval2) INSERT INTO @Result(keycol1, val1, othercols1, keycol2, val2, othercols2) VALUES(@keycol1, @val1, @othercols1, @keycol2, @val2, @othercols2); ELSE INSERT INTO @Result(keycol1, val1, othercols1, keycol2, val2, othercols2) VALUES(@keycol1, @val1, @othercols1, @prevkeycol2, @prevval2, @prevothercols2); FETCH NEXT FROM @C1 INTO @keycol1, @val1, @othercols1; SET @C1fetch_status = @@fetch_status; END ELSE IF @C2fetch_status = 0 BEGIN IF @val2 > @prevval2 SELECT @prevkeycol2 = @keycol2, @prevval2 = @val2, @prevothercols2 = @othercols2; FETCH NEXT FROM @C2 INTO @keycol2, @val2, @othercols2; SET @C2fetch_status = @@fetch_status; END; END; SELECT keycol1, val1, SUBSTRING(othercols1, 1, 1) AS othercols1, keycol2, val2, SUBSTRING(othercols2, 1, 1) AS othercols2 FROM @Result; COMMIT TRAN;
Con l'indicizzazione ottimale in atto, questa soluzione ha richiesto 27 secondi per essere completata sulla mia macchina (rispetto a 38 secondi con la variabile della tabella basata su disco) e senza un'indicizzazione ottimale ci sono voluti 29 secondi per essere completata (rispetto a 40 secondi). Si tratta di una riduzione di quasi il 30% del tempo di esecuzione.
Soluzione 5, utilizzando SQL CLR
Un altro modo per migliorare ulteriormente le prestazioni dell'approccio iterativo consiste nell'implementare la soluzione utilizzando SQL CLR, dato che la maggior parte dell'overhead della soluzione T-SQL è dovuta alle inefficienze del recupero del cursore e del ciclo in T-SQL.
Ecco il codice completo della soluzione che implementa lo stesso algoritmo che ho usato nelle soluzioni 3 e 4 con C#, usando oggetti SqlDataReader invece di cursori T-SQL:
using System; using System.Data; using System.Data.SqlClient; using System.Data.SqlTypes; using Microsoft.SqlServer.Server; public partial class ClosestMatch { [SqlProcedure] public static void GetClosestMatches() { using (SqlConnection conn = new SqlConnection("data source=MyServer\\MyInstance;Database=testdb;Trusted_Connection=True;MultipleActiveResultSets=true;")) { SqlCommand comm1 = new SqlCommand(); SqlCommand comm2 = new SqlCommand(); comm1.Connection = conn; comm2.Connection = conn; comm1.CommandText = "SELECT keycol, val, othercols FROM dbo.T1 ORDER BY val, keycol;"; comm2.CommandText = "SELECT keycol, val, othercols FROM dbo.T2 ORDER BY val, keycol;"; SqlMetaData[] columns = new SqlMetaData[6]; columns[0] = new SqlMetaData("keycol1", SqlDbType.Int); columns[1] = new SqlMetaData("val1", SqlDbType.Int); columns[2] = new SqlMetaData("othercols1", SqlDbType.Binary, 100); columns[3] = new SqlMetaData("keycol2", SqlDbType.Int); columns[4] = new SqlMetaData("val2", SqlDbType.Int); columns[5] = new SqlMetaData("othercols2", SqlDbType.Binary, 100); SqlDataRecord record = new SqlDataRecord(columns); SqlContext.Pipe.SendResultsStart(record); conn.Open(); SqlDataReader reader1 = comm1.ExecuteReader(); SqlDataReader reader2 = comm2.ExecuteReader(); SqlInt32 keycol1 = SqlInt32.Null; SqlInt32 val1 = SqlInt32.Null; SqlBinary othercols1 = SqlBinary.Null; SqlInt32 keycol2 = SqlInt32.Null; SqlInt32 val2 = SqlInt32.Null; SqlBinary othercols2 = SqlBinary.Null; SqlInt32 prevkeycol2 = SqlInt32.Null; SqlInt32 prevval2 = SqlInt32.Null; SqlBinary prevothercols2 = SqlBinary.Null; Boolean reader2foundrow = reader2.Read(); if (reader2foundrow) { keycol2 = reader2.GetSqlInt32(0); val2 = reader2.GetSqlInt32(1); othercols2 = reader2.GetSqlBinary(2); prevkeycol2 = keycol2; prevval2 = val2; prevothercols2 = othercols2; } Boolean reader1foundrow = reader1.Read(); if (reader1foundrow) { keycol1 = reader1.GetSqlInt32(0); val1 = reader1.GetSqlInt32(1); othercols1 = reader1.GetSqlBinary(2); } while (reader1foundrow) { if (val1 <= val2 || !reader2foundrow) { if (Math.Abs((int)(val1 - val2)) < Math.Abs((int)(val1 - prevval2))) { record.SetSqlInt32(0, keycol1); record.SetSqlInt32(1, val1); record.SetSqlBinary(2, othercols1); record.SetSqlInt32(3, keycol2); record.SetSqlInt32(4, val2); record.SetSqlBinary(5, othercols2); SqlContext.Pipe.SendResultsRow(record); } else { record.SetSqlInt32(0, keycol1); record.SetSqlInt32(1, val1); record.SetSqlBinary(2, othercols1); record.SetSqlInt32(3, prevkeycol2); record.SetSqlInt32(4, prevval2); record.SetSqlBinary(5, prevothercols2); SqlContext.Pipe.SendResultsRow(record); } reader1foundrow = reader1.Read(); if (reader1foundrow) { keycol1 = reader1.GetSqlInt32(0); val1 = reader1.GetSqlInt32(1); othercols1 = reader1.GetSqlBinary(2); } } else if (reader2foundrow) { if (val2 > prevval2) { prevkeycol2 = keycol2; prevval2 = val2; prevothercols2 = othercols2; } reader2foundrow = reader2.Read(); if (reader2foundrow) { keycol2 = reader2.GetSqlInt32(0); val2 = reader2.GetSqlInt32(1); othercols2 = reader2.GetSqlBinary(2); } } } SqlContext.Pipe.SendResultsEnd(); } } }
Per connetterti al database dovresti normalmente usare l'opzione "context connection=true" invece di una stringa di connessione completa. Sfortunatamente, questa opzione non è disponibile quando devi lavorare con più set di risultati attivi. La nostra soluzione che emula il lavoro in parallelo con due cursori utilizzando due oggetti SqlDataReader, quindi è necessaria una stringa di connessione completa, con l'opzione MultipleActiveResultSets=true. Ecco la stringa di connessione completa:
"data source=MyServer\\MyInstance;Database=testdb;Trusted_Connection=True;MultipleActiveResultSets=true;"
Ovviamente nel tuo caso dovresti sostituire MyServer\\MyInstance con i nomi del tuo server e dell'istanza (se pertinente).
Inoltre, il fatto che tu non abbia utilizzato "context connection=true" piuttosto una stringa di connessione esplicita significa che l'assembly deve accedere a una risorsa esterna e quindi essere attendibile. Normalmente, lo si ottiene firmandolo con un certificato o una chiave asimmetrica che dispone di un account di accesso corrispondente con l'autorizzazione corretta o inserendolo nella whitelist utilizzando la procedura sp_add_trusted_assembly. Per semplicità, imposterò l'opzione del database TRUSTWORTHY su ON e specificherò il set di autorizzazioni EXTERNAL_ACCESS durante la creazione dell'assembly. Il codice seguente distribuisce la soluzione nel database:
EXEC sys.sp_configure 'advanced', 1; RECONFIGURE; EXEC sys.sp_configure 'clr enabled', 1; EXEC sys.sp_configure 'clr strict security', 0; RECONFIGURE; EXEC sys.sp_configure 'advanced', 0; RECONFIGURE; ALTER DATABASE testdb SET TRUSTWORTHY ON; USE testdb; DROP PROC IF EXISTS dbo.GetClosestMatches; DROP ASSEMBLY IF EXISTS ClosestMatch; CREATE ASSEMBLY ClosestMatch FROM 'C:\ClosestMatch\ClosestMatch\bin\Debug\ClosestMatch.dll' WITH PERMISSION_SET = EXTERNAL_ACCESS; GO CREATE PROCEDURE dbo.GetClosestMatches AS EXTERNAL NAME ClosestMatch.ClosestMatch.GetClosestMatches;
Il codice abilita CLR nell'istanza, disabilita l'opzione di sicurezza rigorosa CLR, imposta l'opzione del database TRUSTWORTHY su ON, crea l'assembly e crea la procedura GetClosestMatches.
Utilizzare il codice seguente per testare la stored procedure:
EXEC dbo.GetClosestMatches;
La soluzione CLR ha richiesto 8 secondi per essere completata sul mio sistema con un'indicizzazione ottimale e 9 secondi senza. Si tratta di un miglioramento delle prestazioni piuttosto impressionante rispetto a tutte le altre soluzioni, sia relazionali che iterative.
Conclusione
Le soluzioni iterative sono generalmente disapprovate nella comunità SQL poiché non seguono il modello relazionale. La realtà però è che a volte non si è in grado di creare soluzioni relazionali performanti e le prestazioni sono una priorità. Utilizzando un approccio iterativo, non sei limitato agli algoritmi a cui ha accesso l'ottimizzatore di SQL Server, ma puoi implementare qualsiasi algoritmo che ti piace. Come dimostrato in questo articolo, utilizzando un algoritmo di tipo merge, sei stato in grado di eseguire l'attività con un singolo passaggio ordinato su ciascuno degli input. Usando i cursori T-SQL e una variabile di tabella basata su disco si ottengono prestazioni e ridimensionamento ragionevoli. Sei stato in grado di migliorare le prestazioni di circa il 30 percento passando a una variabile di tabella ottimizzata per la memoria e molto di più utilizzando SQL CLR.