La llegada de SQL Server 2016 SP1 ha “democratizado” el acceso a la tecnología In-Memory OLTP pasando a estar disponible en la versión Standard y Express. Al facilitarse el acceso normalmente aumenta la cantidad de preguntas respecto a las posibilidades que ofrece una tecnología. Un posible caso de uso para In-Memory OLTP es utilizarla como caché de datos de forma que datos que son pesados de obtener/calcular puedan estar disponibles de forma más rápida. Además existen múltiples casos de éxito asociados a esta tecnología en empresas de renombre como bwin.

Personalmente consideramos que debemos evitar situaciones donde acabemos realizando un mal uso de la tecnología. Vemos muy habitualmente este tipo de problemas cuando se intenta utilizar SQL Server para fines distintos para los que está destinado. Como suele decirse, “para un martillo todo son clavos”, así que vamos a comparar el rendimiento que podemos obtener cacheando datos con In-Memory OLTP con el rendimiento que obtendríamos cacheando con un software específico de caché bastante popular, Redis.

Comenzaremos con la configuración del lado de SQL Server. El primer paso será añadir un filegroup y un fichero (carpeta en realidad) para In-Memory OLTP a nuestra base de datos:

Cache Redis vs SQL Server In-Memory OLTP Cache Redis vs SQL Server In-Memory OLTP

A continuación crearemos una tabla volátil (SCHEMA_ONLY) que contendrá los datos a cachear. En nuestro caso vamos a cachear una cadena de texto y utilizaremos como clave un GUID:

CREATE TABLE [dbo].[Cache]
(
  [id] [uniqueidentifier] NOT NULL,
  [payload] [varchar](8000) COLLATE Modern_Spanish_CI_AS NOT NULL,

 CONSTRAINT [PK_sample_memoryoptimizedtable]  PRIMARY KEY NONCLUSTERED HASH 
(
  [id]
)WITH ( BUCKET_COUNT = 1048576)
)WITH ( MEMORY_OPTIMIZED = ON , DURABILITY = SCHEMA_ONLY )

Para el acceso a dicha caché tendremos dos procedimientos almacenados compilados muy sencillos que simplemente obtendrán el valor correspondiente a una clave o insertarán un nuevo valor:

USE [InMemoryCache]
GO

CREATE PROCEDURE [dbo].[GetData]
  @id uniqueidentifier ,
  @payload varchar(8000) output
WITH NATIVE_COMPILATION, SCHEMABINDING
AS BEGIN ATOMIC WITH
(
 TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english'
)
  SELECT @payload=payload from dbo.Cache where id=@id

END

GO

CREATE PROCEDURE [dbo].[InsertData]
  @id uniqueidentifier, 
  @payload varchar(8000)
WITH NATIVE_COMPILATION, SCHEMABINDING
AS BEGIN ATOMIC WITH
(
 TRANSACTION ISOLATION LEVEL = SNAPSHOT, LANGUAGE = N'us_english'
)

insert into dbo.cache(id,payload) values (@id,@payload)
 
END

GO

El objetivo es poder testear el rendimiento a la hora de recuperar objetos de caché (GetData) y también simular cierto porcentaje de escrituras (InsertData). Para lanzar la carga de SQL Server podríamos utilizar el comando ostress incluido en las RML utilities. Utilizaríamos los parámetros para configurar la herramienta en modo stress y también para controlar el número de conexiones concurrentes:

.Cache Redis vs SQL Server In-Memory OLTP

Sin embargo ya que para el caso de Redis ibamos a necesitar una pequeña aplicación decidimos utilizar esa misma aplicación para lanzar ambas cargas. En la aplicación podemos configurar el ratio de escrituras (inserciones), el número de operaciones a lanzar en cada test, así como el número de threads a utilizar:

Cache Redis vs SQL Server In-Memory OLTP

Para poder conectar con Redis utilizaremos una de las librerías existentes. Concretamente utilizaremos StackExchange ya que se caracteriza por su alto rendimiento:

Cache Redis vs SQL Server In-Memory OLTP

Redis puede ser fácilmente instaladomediante paquete Nuget y arrancado directamente en modo consola, sin necesidad de instalarse como servicio:

Cache Redis vs SQL Server In-Memory OLTP

El código para utilizarlo es muy sencillo, básicamente obtendremos un acceso a la “base de datos” de Redis en local:

redis = ConnectionMultiplexer.Connect("localhost, resolvedns = 1");
db = redis.GetDatabase();

Y realizaremos las operaciones Set/Get con los métodos de la librería:

// Set
id = Guid.NewGuid().ToString();
db.StringSet(id, payload);
// Get
value = db.StringGet(id);

Para la ejecución en paralelo nos apoyaremos en los bucles paralelos de .NET, más concretamente en la estructura Parallel.For:

//Set paralelo
Parallel.For(-1, writes, new ParallelOptions { MaxDegreeOfParallelism = (int) maxthreads.Value } ,
 i => {
 id = Guid.NewGuid().ToString();
 db.StringSet(id, payload);
 });

// Get paralelo
Parallel.For(0, reads, new ParallelOptions { MaxDegreeOfParallelism = (int)maxthreads.Value },
 i =>
 {
 value = db.StringGet(id);
 });

Para el caso de SQL Server utilizaremos la librería por defecto creando comandos para las llamadas a los procedimientos almacenados con los parámetros que reutilizaremos en las ejecuciones:

SqlConnection con = new SqlConnection("Persist Security Info=False;Integrated Security=true;Initial Catalog=InMemoryCache;server=(local)");
SqlCommand cmdGet = new SqlCommand();
SqlCommand cmdInsert = new SqlCommand();

cmdGet.CommandText = "dbo.GetData";
cmdGet.CommandType = CommandType.StoredProcedure;
cmdGet.Parameters.Add("@id", SqlDbType.UniqueIdentifier);
SqlParameter par_payload = new SqlParameter("@payload", SqlDbType.VarChar);
par_payload.Direction = ParameterDirection.Output;
par_payload.Size = 8000;
cmdGet.Parameters.Add(par_payload);

cmdInsert.CommandText = "dbo.InsertData";
cmdInsert.CommandType = CommandType.StoredProcedure;
cmdInsert.Parameters.Add("@id", SqlDbType.UniqueIdentifier);
cmdInsert.Parameters.Add("@payload", SqlDbType.VarChar);

Para la ejecución de las operaciones Set/Get simplemente lanzaremos los procedimientos:

//Set
id = Guid.NewGuid();
cmdInsert.Parameters["@ID"].Value = id;
cmdInsert.ExecuteNonQuery();

//Get
cmdGet.Parameters["@ID"].Value = id;
cmdGet.ExecuteNonQuery();
value=cmdGet.Parameters["@payload"].Value.ToString();

El abanico de pruebas que decidimos realizar era el de cargas al 100% de lecturas, para casos donde cacheamos datos maestros donde el ratio de escritura es inexistente o insignificante, carga al 50% de escrituras y carga al 100% de escrituras. Respecto a la concurrencia probaremos desde el comportamiento monothread hasta 512 peticiones concurrentes. Para poder soportar este tipo de cargas sin saturar el equipo de prueba optamos por testear con una de las máquinas más potentes disponibles en Azure, una G5, con 32 cores y hardware dedicado para la máquina virtual:

Cache Redis vs SQL Server In-Memory OLTP

Los resultados que hemos obtenido son los siguientes:

Cache Redis vs SQL Server In-Memory OLTP

Cache Redis vs SQL Server In-Memory OLTP

Como podemos ver en SQL Server no hemos conseguido escalar más allá de unas 50000 peticiones por segundo mientras que con Redis hemos llegado a las 300000 por segundo. A continuación mostramos los consumos de CPU tanto del cliente como del servidor en cada caso:

Cache Redis vs SQL Server In-Memory OLTP Cache Redis vs SQL Server In-Memory OLTP Cache Redis vs SQL Server In-Memory OLTP Cache Redis vs SQL Server In-Memory OLTP

Podemos ver como el consumo de CPU en los clientes es mucho más elevado en el caso de SQL Server, lo cual nos hace pensar que existe algún problema en el código utilizado para los tests que producen un alto consumo de CPU cuando el paralelismo entra en juego. Esta situación nos recuerda a situaciones similares en SQL Server cuando hablamos de contención por spinlocks. Quizás alguna de las primitivas de sincronización involucradas en la reutilización de las conexiones o en el acceso a las colecciones de comandos, etc. son las causantes de dicho problema. Posiblemente en las pruebas y en el escenario de bwin esta problemática no ocurria al generarse la carga total desde una gran cantidad de clientes externos a SQL Server:

Cache Redis vs SQL Server In-Memory OLTP

Este problema no ocurre en el caso de Redis donde vemos que la CPU escala más o menos linealmente con el aumento en el número de threads. También vemos que al tratarse de una configuración sencilla de Redis, sin maestros y esclavos el consumo de CPU se limita al 100% de un core. Esto ha hecho que a partir de 32 threads concurrentes Redis no escalara más allá de las 300000 operaciones por segundo. Por tanto será recomendable elegir aquellas CPUs que tengan una mayor frecuencia de trabajo por core aunque no tengan una gran cantidad de cores para este rol. Con una configuración con múltiples esclavos o múltiples instancias de Redis muy probablemente habríamos podido escalar a un número mucho mayor de peticiones por segundo con el mismo hardware.

Las conclusiones que sacamos de esta prueba es que no parece que In-Memory OLTP en SQL Server sea la mejor opción para cachear datos en formato clave-valor. Al menos en comparación con soluciones que nativamente se han diseñado para funcionar bien en estos escenarios, como es el caso de Redis. Donde sí creemos que puede existir una ventaja es cuando utilicemos esta tecnología de SQL Server para acelerar aplicaciones ya existentes, basadas en SQL Server, donde podamos por ejemplo sustituir tablas y procedimientos almacenados tradicionales con tablas en memoria y procedimientos almacenados compilados. Probablemente cada caso de uso tenga sus peculiaridades y los rendimientos puedan ser variables. En este caso Redis parece que encaja mucho mejor, en otros casos será SQL Server y en otros VoltDB (por poner otro ejemplo de bbdd en memoria). Recomendamos mantener la mente abierta a las distintas soluciones existentes para evitar usar siempre el mismo “martillo” sin verificar antes si es la herramienta más apropiada.

0 Shares:
Deja una respuesta

Tu dirección de correo electrónico no será publicada.

You May Also Like
Leer más

Expresiones, parámetros y funciones en Azure Data Factory

Hay ocasiones, cuando estamos construyendo pipelines con Azure Data Factory, que queremos repetir patrones para extraer y procesar la información cambiando de manera dinámica, en tiempo de ejecución, valores, orígenes/destinos de los datasets, incluso los mismos linked services. Esto es posible mediante el uso de parámetros, expresiones y funciones. Vamos a ver cómo implementarlo con un ejemplo práctico en el que se nos plantea el siguiente supuesto. Se nos ha pedido que extraigamos todos los días los datos del día anterior de distintas tablas del DW a ficheros en un blob storage que además se nombre como la tabla de origen. Si no pudiéramos utilizar contenido dinámico tendríamos que crear dos datasets (uno de origen y otro de destino) y añadir una actividad de copia por cada tabla a exportar.