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:

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:

.

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:

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

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

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:

Los resultados que hemos obtenido son los siguientes:

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:

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:

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.

Rubén Garrigós

Rubén Garrigós is an expert in high-availability enterprise solutions based on SQL Server design, tuning, and troubleshooting. Over the past fifteen years, he has worked with Microsoft data access technologies in leading companies around the world. He currently is a Microsoft SQL Server and .NET applications architect with SolidQ. Ruben is certified by Microsoft as a Solution Expert on the Microsoft Data Platform (MSCE: Data Platform) and as a Solution Expert on the Microsoft Private Cloud (MSCE: Private Cloud). As a Microsoft Certified Trainer (MCT), Ruben has taught multiple official Microsoft courses as well as other courses specializing in SQL Server. He has also presented sessions at official events for various Microsoft technologies user groups.