La semana pasada, escribí sobre las limitaciones de Always Encrypted y el impacto en el rendimiento. Quería publicar un seguimiento después de realizar más pruebas, principalmente debido a los siguientes cambios:
- Agregué una prueba local para ver si la sobrecarga de la red era significativa (anteriormente, la prueba solo era remota). Sin embargo, debería poner "sobrecarga de la red" entre comillas, porque se trata de dos máquinas virtuales en el mismo host físico, por lo que no es realmente un verdadero análisis completo.
- Agregué algunas columnas adicionales (no encriptadas) a la tabla para hacerla más realista (pero no tanto).
DateCreated DATETIME NOT NULL DEFAULT SYSUTCDATETIME(), DateModified DATETIME NOT NULL DEFAULT SYSUTCDATETIME(), IsActive BIT NOT NULL DEFAULT 1
Luego modificó el procedimiento de recuperación en consecuencia:
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
- Se agregó un procedimiento para truncar la tabla (anteriormente lo hacía manualmente entre pruebas):
CREATE PROCEDURE dbo.Cleanup AS BEGIN SET NOCOUNT ON; TRUNCATE TABLE dbo.Employees; END GO
- Se agregó un procedimiento para registrar tiempos (anteriormente estaba analizando manualmente la salida de la consola):
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
- Agregué un par de bases de datos que usaban compresión de página; todos sabemos que los valores cifrados no se comprimen bien, pero esta es una característica polarizadora que se puede usar unilateralmente incluso en tablas con columnas cifradas, así que pensé en simplemente perfila estos también. (Y agregó dos cadenas de conexión más 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>
- Realicé muchas mejoras en el código de C# (consulte el Apéndice) en función de los comentarios de tobi (lo que llevó a esta pregunta de revisión de código) y una gran ayuda de mi compañero de trabajo Brooke Philpott (@Macromullet). Estos incluyeron:
- eliminar el procedimiento almacenado para generar nombres/salarios aleatorios y hacerlo en C# en su lugar
- usando
Stopwatch
en lugar de torpes cadenas de fecha/hora - uso más consistente de
using()
y eliminación de.Close()
- Convenciones de nomenclatura ligeramente mejores (¡y comentarios!)
- cambiando
while
bucles afor
bucles - utilizando un
StringBuilder
en lugar de una concatenación ingenua (que inicialmente había elegido intencionalmente) - consolidando las cadenas de conexión (aunque sigo haciendo intencionalmente una nueva conexión dentro de cada iteración de bucle)
Luego creé un archivo por lotes simple que ejecutaría cada prueba 5 veces (y repetí esto en las computadoras locales y remotas):
for /l %%x in (1,1,5) do ( ^ AEDemoConsole "Normal" & ^ AEDemoConsole "Encrypt" & ^ AEDemoConsole "NormalCompress" & ^ AEDemoConsole "EncryptCompress" & ^ )
Una vez completadas las pruebas, medir las duraciones y el espacio utilizado sería trivial (y crear gráficos a partir de los resultados solo requeriría una pequeña manipulación en 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');
Resultados de duración
Estos son los resultados sin procesar de la consulta de duración anterior (CANUCK
es el nombre de la máquina que aloja la instancia de SQL Server y HOSER
es la máquina que ejecutó la versión remota del código):
Resultados sin procesar de la consulta de duración
Obviamente será más fácil visualizarlo de otra forma. Como se muestra en el primer gráfico, el acceso remoto tuvo un impacto significativo en la duración de las inserciones (aumento de más del 40 %), pero la compresión tuvo poco impacto. Solo el cifrado duplicó aproximadamente la duración de cualquier categoría de prueba:
Duración (milisegundos) para insertar 100 000 filas
Para las lecturas, la compresión tuvo un impacto mucho mayor en el rendimiento que el cifrado o la lectura de datos de forma remota:
Duración (milisegundos) para leer 100 filas aleatorias 1000 veces
Resultados espaciales
Como podría haber predicho, la compresión puede reducir significativamente la cantidad de espacio requerido para almacenar estos datos (aproximadamente a la mitad), mientras que el cifrado puede afectar el tamaño de los datos en la dirección opuesta (casi triplicándolo). Y, por supuesto, comprimir valores cifrados no da resultado:
Espacio utilizado (KB) para almacenar 100 000 filas con o sin compresión y con o sin cifrado
Resumen
Esto debería darle una idea aproximada del impacto esperado al implementar Always Encrypted. Sin embargo, tenga en cuenta que esta fue una prueba muy particular y que estaba usando una compilación temprana de CTP. Sus datos y patrones de acceso pueden arrojar resultados muy diferentes, y los avances adicionales en futuros CTP y las actualizaciones de .NET Framework pueden reducir algunas de estas diferencias incluso en esta misma prueba.
También notará que los resultados aquí fueron ligeramente diferentes en todos los ámbitos que en mi publicación anterior. Esto se puede explicar:
- Los tiempos de inserción fueron más rápidos en todos los casos porque ya no estoy incurriendo en un viaje de ida y vuelta adicional a la base de datos para generar el nombre y el salario aleatorios.
- Los tiempos seleccionados fueron más rápidos en todos los casos porque ya no uso un método descuidado de concatenación de cadenas (que se incluyó como parte de la métrica de duración).
- El espacio utilizado fue un poco más grande en ambos casos, sospecho que se debió a una distribución diferente de cadenas aleatorias que se generaron.
Apéndice A:código de aplicación de la consola 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(); } } } } }