La scorsa settimana ho scritto dei limiti di Always Encrypted e dell'impatto sulle prestazioni. Volevo pubblicare un follow-up dopo aver eseguito più test, principalmente a causa delle seguenti modifiche:
- Ho aggiunto un test per locale, per vedere se l'overhead di rete era significativo (in precedenza, il test era solo remoto). Tuttavia, dovrei mettere "overhead di rete" tra virgolette aeree, perché si tratta di due macchine virtuali sullo stesso host fisico, quindi non proprio una vera analisi bare metal.
- Ho aggiunto alcune colonne extra (non crittografate) alla tabella per renderla più realistica (ma non così realistica).
DateCreated DATETIME NOT NULL DEFAULT SYSUTCDATETIME(), DateModified DATETIME NOT NULL DEFAULT SYSUTCDATETIME(), IsActive BIT NOT NULL DEFAULT 1
Quindi ha modificato di conseguenza la procedura di recupero:
ALTER PROCEDURE dbo.RetrievePeople AS BEGIN SET NOCOUNT ON; SELECT TOP (100) LastName, Salary, DateCreated, DateModified, Active FROM dbo.Employees ORDER BY NEWID(); END GO
- Aggiunta una procedura per troncare la tabella (in precedenza lo facevo manualmente tra i test):
CREATE PROCEDURE dbo.Cleanup AS BEGIN SET NOCOUNT ON; TRUNCATE TABLE dbo.Employees; END GO
- Aggiunta una procedura per la registrazione dei tempi (in precedenza stavo analizzando manualmente l'output della console):
USE Utility; GO CREATE TABLE dbo.Timings ( Test NVARCHAR(32), InsertTime INT, SelectTime INT, TestCompleted DATETIME NOT NULL DEFAULT SYSUTCDATETIME(), HostName SYSNAME NOT NULL DEFAULT HOST_NAME() ); GO CREATE PROCEDURE dbo.AddTiming @Test VARCHAR(32), @InsertTime INT, @SelectTime INT AS BEGIN SET NOCOUNT ON; INSERT dbo.Timings(Test,InsertTime,SelectTime) SELECT @Test,@InsertTime,@SelectTime; END GO
- Ho aggiunto una coppia di database che utilizzavano la compressione della pagina:sappiamo tutti che i valori crittografati non si comprimono bene, ma questa è una caratteristica polarizzante che può essere utilizzata unilateralmente anche su tabelle con colonne crittografate, quindi ho pensato di profila anche questi. (E aggiunto altre due stringhe di connessione a
App.Config
.)<connectionStrings> <add name="Normal" connectionString="...;Initial Catalog=Normal;"/> <add name="Encrypt" connectionString="...;Initial Catalog=Encrypt;Column Encryption Setting=Enabled;"/> <add name="NormalCompress" connectionString="...;Initial Catalog=NormalCompress;"/> <add name="EncryptCompress" connectionString="...;Initial Catalog=EncryptCompress;Column Encryption Setting=Enabled;"/> </connectionStrings>
- Ho apportato molti miglioramenti al codice C# (vedi l'Appendice) sulla base del feedback di tobi (che ha portato a questa domanda sulla revisione del codice) e dell'ottima assistenza del collega Brooke Philpott (@Macromullet). Questi includevano:
- eliminando la procedura memorizzata per generare nomi/stipendi casuali e facendolo invece in C#
- usando
Stopwatch
invece di scadenti stringhe di data/ora - uso più coerente di
using()
ed eliminazione di.Close()
- convenzioni di denominazione (e commenti!) leggermente migliori
- modifica
while
passa afor
loop - utilizzando un
StringBuilder
invece della concatenazione ingenua (che inizialmente avevo scelto intenzionalmente) - consolidare le stringhe di connessione (anche se sto ancora creando intenzionalmente una nuova connessione all'interno di ogni iterazione del ciclo)
Quindi ho creato un semplice file batch che avrebbe eseguito ogni test 5 volte (e l'ho ripetuto sia sul computer locale che su quello remoto):
for /l %%x in (1,1,5) do ( ^ AEDemoConsole "Normal" & ^ AEDemoConsole "Encrypt" & ^ AEDemoConsole "NormalCompress" & ^ AEDemoConsole "EncryptCompress" & ^ )
Dopo che i test sono stati completati, misurare le durate e lo spazio utilizzato sarebbe banale (e costruire grafici dai risultati richiederebbe solo una piccola manipolazione in Excel):
-- duration SELECT HostName, Test, AvgInsertTime = AVG(1.0*InsertTime), AvgSelectTime = AVG(1.0*SelectTime) FROM Utility.dbo.Timings GROUP BY HostName, Test ORDER BY HostName, Test; -- space USE Normal; -- NormalCompress; Encrypt; EncryptCompress; SELECT COUNT(*)*8.192 FROM sys.dm_db_database_page_allocations(DB_ID(), OBJECT_ID(N'dbo.Employees'), NULL, NULL, N'LIMITED');
Risultati della durata
Ecco i risultati grezzi della query sulla durata sopra (CANUCK
è il nome della macchina che ospita l'istanza di SQL Server e HOSER
è la macchina che ha eseguito la versione remota del codice):
Risultati non elaborati della query sulla durata
Ovviamente sarà più facile visualizzare in un'altra forma. Come mostrato nel primo grafico, l'accesso remoto ha avuto un impatto significativo sulla durata degli inserti (aumento di oltre il 40%), ma la compressione ha avuto un impatto minimo. La sola crittografia ha approssimativamente raddoppiato la durata di qualsiasi categoria di test:
Durata (millisecondi) per inserire 100.000 righe
Per le letture, la compressione ha avuto un impatto molto maggiore sulle prestazioni rispetto alla crittografia o alla lettura dei dati in remoto:
Durata (millisecondi) per leggere 100 righe casuali 1000 volte
Risultati spaziali
Come avrai previsto, la compressione può ridurre significativamente la quantità di spazio richiesta per archiviare questi dati (circa della metà), mentre la crittografia può influire sulla dimensione dei dati nella direzione opposta (quasi triplicandola). E, naturalmente, la compressione dei valori crittografati non dà i suoi frutti:
Spazio utilizzato (KB) per memorizzare 100.000 righe con o senza compressione e con o senza crittografia
Riepilogo
Questo dovrebbe darti un'idea approssimativa di cosa aspettarsi dall'impatto quando si implementa Always Encrypted. Tieni presente, tuttavia, che questo è stato un test molto particolare e che stavo usando una prima build CTP. I tuoi dati e i tuoi modelli di accesso potrebbero produrre risultati molto diversi e ulteriori progressi nei futuri CTP e aggiornamenti a .NET Framework potrebbero ridurre alcune di queste differenze anche in questo stesso test.
Noterai anche che i risultati qui erano leggermente diversi su tutta la linea rispetto al mio post precedente. Questo può essere spiegato:
- I tempi di inserimento sono stati in tutti i casi più rapidi perché non devo più sostenere un viaggio di andata e ritorno aggiuntivo nel database per generare il nome e lo stipendio casuali.
- I tempi di selezione sono stati in tutti i casi più rapidi perché non utilizzo più un metodo sciatto di concatenazione delle stringhe (che era incluso come parte della metrica della durata).
- Lo spazio utilizzato era leggermente maggiore in entrambi i casi, sospetto a causa di una diversa distribuzione delle stringhe casuali generate.
Appendice A – Codice dell'applicazione console C#
using System; using System.Configuration; using System.Text; using System.Data; using System.Data.SqlClient; namespace AEDemo { class AEDemo { static void Main(string[] args) { // set up a stopwatch to time each portion of the code var timer = System.Diagnostics.Stopwatch.StartNew(); // random object to furnish random names/salaries var random = new Random(); // connect based on command-line argument var connectionString = ConfigurationManager.ConnectionStrings[args[0]].ToString(); using (var sqlConnection = new SqlConnection(connectionString)) { // this simply truncates the table, which I was previously doing manually using (var sqlCommand = new SqlCommand("dbo.Cleanup", sqlConnection)) { sqlConnection.Open(); sqlCommand.ExecuteNonQuery(); } } // first, generate 100,000 name/salary pairs and insert them for (int i = 1; i <= 100000; i++) { // random salary between 32750 and 197500 var randomSalary = random.Next(32750, 197500); // random string of random number of characters var length = random.Next(1, 32); char[] randomCharArray = new char[length]; for (int byteOffset = 0; byteOffset < length; byteOffset++) { randomCharArray[byteOffset] = (char)random.Next(65, 90); // A-Z } var randomName = new string(randomCharArray); // this stored procedure accepts name and salary and writes them to table // in the databases with encryption enabled, SqlClient encrypts here // so in a trace you would see @LastName = 0xAE4C12..., @Salary = 0x12EA32... using (var sqlConnection = new SqlConnection(connectionString)) { using (var sqlCommand = new SqlCommand("dbo.AddEmployee", sqlConnection)) { sqlCommand.CommandType = CommandType.StoredProcedure; sqlCommand.Parameters.Add("@LastName", SqlDbType.NVarChar, 32).Value = randomName; sqlCommand.Parameters.Add("@Salary", SqlDbType.Int).Value = randomSalary; sqlConnection.Open(); sqlCommand.ExecuteNonQuery(); } } } // capture the timings timer.Stop(); var timeInsert = timer.ElapsedMilliseconds; timer.Reset(); timer.Start(); var placeHolder = new StringBuilder(); for (int i = 1; i <= 1000; i++) { using (var sqlConnection = new SqlConnection(connectionString)) { // loop through and pull 100 rows, 1,000 times using (var sqlCommand = new SqlCommand("dbo.RetrieveRandomEmployees", sqlConnection)) { sqlCommand.CommandType = CommandType.StoredProcedure; sqlConnection.Open(); using (var sqlDataReader = sqlCommand.ExecuteReader()) { while (sqlDataReader.Read()) { // do something tangible with the output placeHolder.Append(sqlDataReader[0].ToString()); } } } } } // capture timings again, write both to db timer.Stop(); var timeSelect = timer.ElapsedMilliseconds; using (var sqlConnection = new SqlConnection(connectionString)) { using (var sqlCommand = new SqlCommand("Utility.dbo.AddTiming", sqlConnection)) { sqlCommand.CommandType = CommandType.StoredProcedure; sqlCommand.Parameters.Add("@Test", SqlDbType.NVarChar, 32).Value = args[0]; sqlCommand.Parameters.Add("@InsertTime", SqlDbType.Int).Value = timeInsert; sqlCommand.Parameters.Add("@SelectTime", SqlDbType.Int).Value = timeSelect; sqlConnection.Open(); sqlCommand.ExecuteNonQuery(); } } } } }