Esto no es papel mojado!!!

Otro sitio más de Geeks.ms

MythBuster: Velocidad de acceso a datos

Después de mucho tiempo de parón voy a retomar el blog que lo tenia muy abandonado, a ver si a partir de ahora le doy más continuidad Sonrisa.

Este post surge como consecuencia de una conversación con @_PedroHurtado sobre las diferencias de rendimiento entre DataTables y DataReader (dejo para otro día el acceso con Linq + EF) y la supuesta afirmación de que el DataReader es lo más rápido para la de cargar datos.

Para hacer las pruebas he creado una BD en SQLServer con el siguiente TSQL:

   1: create database Post

   2: go

   3: create table Persona(

   4:     Id int identity(1,1) primary key,

   5:     Nombre nvarchar(max) not null,

   6:     Apellidos nvarchar(max) not null,

   7:     Nacimiento datetime,

   8:     Direccion nvarchar(max) not null,

   9:     Poblacion nvarchar(max) not null

  10: )

  11: go

  12: declare @i int

  13: set @i = 1

  14: while (@i <= 10000)

  15: begin

  16:     insert Persona (Nombre, Apellidos, Nacimiento, Direccion, Poblacion) 

  17:     values ('Nombre ' + cast(@i as nvarchar), 'Apellidos' + cast(@i as nvarchar), SYSDATETIME(), 'Dirección ' + cast(@i as nvarchar), 'Poblacion ' + cast(@i as nvarchar))

  18:  

  19:     set @i = @i + 1

  20: end

Con esto hemos creado una tabla con 10000 elementos que luego cargaremos y tomaremos tiempos. La idea es cargar los 10000 elementos para ver qué es más rápido. Lógicamente el sistema que cargue 10000 instancias y no sea un reporting recomendaría encarecidamente revisar la arquitectura y diseño ya que lo más lógico sería paginar o buscar soluciones similares que limiten la carga. Simplemente lo hacemos de este tamaño para que los tiempos sean relevantes.

La forma de acceder a los datos se ha pensado como si se realizara una consulta tipo a la lógica de negocio. La idea es simular una llamada a una server API REST y generar el JSON con la información que se enviaría al cliente. La información que se devuelve normalmente es la misma que se carga de la BD sin ni siquiera tratar por lo que realmente eso es lo que replicaremos.

DataTable

Las pruebas para el uso de DataTable se ha realizado siguiendo el siguiente código:

   1: public async Task<long> Execute(string connectionString)

   2: {

   3:     var stopwath = new Stopwatch();

   4:     stopwath.Reset();

   5:     stopwath.Start();

   6:  

   7:     var result = new DataTable();

   8:     using (var connection = new SqlConnection(connectionString))

   9:     using (var cmd = new SqlCommand("select * from persona"))

  10:     {

  11:         connection.Open();

  12:         cmd.Connection = connection;

  13:         SqlDataAdapter dataAdapter = new SqlDataAdapter(cmd);

  14:         dataAdapter.Fill(result);

  15:     }

  16:     var json = Newtonsoft.Json.JsonConvert.SerializeObject(result);

  17:  

  18:     stopwath.Stop();

  19:     return stopwath.ElapsedMilliseconds;

  20: }

Este código es muy sencillo y al terminar se tiene en una variable local el string con toda la información que se devolvería al cliente.

DataReader

Por otro lado, para el uso de DataReader que hemos implementado es el siguiente que equivalente funcionalmente al anterior y es más o menos lo que cualquier persona realizaría:

   1: public async Task<long> Execute(string connectionString)

   2: {

   3:     var stopwath = new Stopwatch();

   4:     stopwath.Reset();

   5:     stopwath.Start();

   6:  

   7:     using (var connection = new SqlConnection(connectionString))

   8:     {

   9:         connection.Open();

  10:  

  11:         using (var command = connection.CreateCommand())

  12:         {

  13:             command.CommandText = "select * from persona";

  14:  

  15:             var dataReader = await command.ExecuteReaderAsync();

  16:             var numColumns = dataReader.FieldCount;

  17:             var values = new object[numColumns];

  18:             var names = new List<string>();

  19:  

  20:             var firstTime = true;

  21:             var result = new List<Dictionary<string, object>>();

  22:             while (dataReader.Read())

  23:             {

  24:                 var record = new Dictionary<string, object>();

  25:  

  26:                 if (dataReader.GetValues(values) > 0)

  27:                 {

  28:                     for (int i = 0; i < numColumns; i++)

  29:                     {

  30:                         if (firstTime)

  31:                             names.Add(dataReader.GetName(i));

  32:  

  33:                         var value = values[i];

  34:                         record.Add(names[i], value != System.DBNull.Value ? value : null);

  35:                     }

  36:                     firstTime = false;

  37:                 }

  38:                 result.Add(record);

  39:             }

  40:             var json = Newtonsoft.Json.JsonConvert.SerializeObject(result);

  41:         }

  42:     }

  43:  

  44:     stopwath.Stop();

  45:     return stopwath.ElapsedMilliseconds;

  46: }

Este código que parece complejo es así porque se está al mismo tiempo que se carga la información definiendo la estructura de datos de la información a devolver. Para devolver la información parece lógico utilizar un ExpandoObject o un DynamicObject y meter los objetos creados en una lista. Pero como el resultado es el mismo a meter un diccionario en la lista y la información que se genera también, pues utilizaremos un diccionario que simplifica mucho la lógica y evita crear objetos al vuelo.

Toma de tiempos

Para tomar tiempos lazaremos una vez cada una de las implementaciones para calentar la conexión, BD, … y luego lanzaremos 10 veces la consulta de carga de toca la información y sacaremos la media. Los tiempos son siempre en Debug.

Los resultados han sido los siguientes:

  • DataTable
    • 1a iteración: 1366ms
    • las siguientes 10: 111ms de media
  • DataReader
    • 1a iteración:165ms
    • las siguientes 10: 120 de media
    • un 8.11% más de tiempo que el datatable

Alguien puede pensar que el algoritmo para cargar utilizando DataReader se puede optimizar sobre todo utilizando tareas que paralelicen cosas.

DataReader con Tasks

Por ello, vamos a hacer la misma lógica pero utilizando tasks que permitan hacer asíncrono el proceso.

Primero crearemos una clase llamada Reader que se encargará de abstraer y cargar la información de forma asíncrona:

   1: public class Reader : IDisposable

   2: {

   3:     static string connectionString = @"Data Source=(LocalDb)v11.0;Initial Catalog=Post;Integrated Security=SSPI; MultipleActiveResultSets=True";

   4:     private SqlConnection _connection;

   5:  

   6:     public async Task<IEnumerable<dynamic>> Read()

   7:     {

   8:         _connection = new SqlConnection(connectionString);

   9:         _connection.Open();

  10:  

  11:         using (var command = _connection.CreateCommand())

  12:         {

  13:             command.CommandText = "select * from persona";

  14:  

  15:             return ReadData(await command.ExecuteReaderAsync());

  16:         }

  17:     }

  18:     private IEnumerable<dynamic> ReadData(SqlDataReader dataReader)

  19:     {

  20:         var numColumns = dataReader.FieldCount;

  21:         var values = new object[numColumns];

  22:         var names = new List<string>();

  23:  

  24:         var firstTime = true;

  25:         while (dataReader.ReadData())

  26:         {

  27:             var record = new Dictionary<string, object>();

  28:  

  29:             if (dataReader.GetValues(values) > 0)

  30:             {

  31:                 for (int i = 0; i < numColumns; i++)

  32:                 {

  33:                     if (firstTime)

  34:                         names.Add(dataReader.GetName(i));

  35:  

  36:                     var value = values[i];

  37:                     record.Add(names[i], value != System.DBNull.Value ? value : null);

  38:                 }

  39:             }

  40:             firstTime = false;

  41:  

  42:             yield return record;

  43:         }

  44:     }

  45:     public void Dispose()

  46:     {

  47:         if (_connection != null)

  48:         {

  49:             _connection.Close();

  50:             _connection.Dispose();

  51:             _connection = null;

  52:         }

  53:     }

  54: }

  55: public static class SqlDataReaderExtension

  56: {

  57:     public static bool ReadData(this SqlDataReader that)

  58:     {

  59:         //var task = that.ReadAsync();

  60:         //task.Wait();

  61:         //return task.Result;

  62:         return that.Read();

  63:     }

  64: }

y despues la algorítmica será la siguiente:

   1: public async Task<long> Execute(string connectionString)

   2: {

   3:     var stopwath = new Stopwatch();

   4:     stopwath.Reset();

   5:     stopwath.Start();

   6:  

   7:     using (var reader = new Reader())

   8:     {

   9:         var result = await reader.Read();

  10:         var json = Newtonsoft.Json.JsonConvert.SerializeObject(result);

  11:     }

  12:  

  13:     stopwath.Stop();

  14:     return stopwath.ElapsedMilliseconds;

  15: }

Con esto conseguimos que la carga de la información no bloquee el hilo principal y se paralelicen más cosas.

Toma de tiempos 2

Ahora vamos a comparar los 3 tipos de algoritmos para la resolución del problema.

  • DataTable
    • 1a iteración: 445ms
    • las siguientes 10: 114ms de media
  • DataReader
    • 1a iteración:168ms
    • las siguientes 10: 119ms de media
    • un 4.39% más de tiempo que el datatable
  • DataReader con Tasks 
    • 1a iteración:120ms
    • las siguientes 10: 117ms de media
    • un 2.63% más de tiempo que el datatable

Aun así no hemos sido capaces de ser más rápidos que un DataTable Triste.

Conclusiones

En definitiva, después de realizar y medir todas las pruebas e intentar optimizar cada uno de los casos podemos concluir:

  1. Los tiempos de DataReader suelen ser superiores al uso de DataTable, aunque usemos Tasks para no bloquear y paralelizar. A veces el DataTable es más lento que el DataReader pero no suele ser lo habitual.
  2. Faltaría probar qué sucede con Linq + EF para abarcar los principales modos de acceso a los datos, pero dudo que sea más rápido que DataTable o DataReader (dejo resto para un siguiente post).
  3. Yo con mi experiencia recomendaría el uso de DataReader con tasks porque la diferencia de tiempos no es significativa y el no bloquear el hilo principal poder paralelizar puede ser muy ventajoso.
  4. El uso de DataReader y cargar la información en colecciones y listas es algo que da más flexibilidad y utiliza estructuras más simples. Por otro lado el uso de DataTables, DataSets, … complica luego el uso si se va a realizar algo más sobre estos datos en la lógica de negocio.
  5. En resumen el mito está cazado, ya que EL DATAREADER NO ES MÁS RÁPIDO QUE EL DATATABLE, aunque tampoco es que sea mucho más lento.

DataReader directo a Json

Una vez hechas todas estas pruebas podemos decir que los tiempos no son nada concluyentes, pero aquí surge una pregunta: ¿Para qué recorrer el DataReader para cargar la información en objetos y luego recorrer los objetos para generar el JSON? ¿No sería más lógico cargar ya directamente en el json?

Viendo este post a medio implementar pero que puede ser ilustrativo: http://weblog.west-wind.com/posts/2009/Apr/24/JSON-Serialization-of-a-DataReader, hace pensar que se puede conseguir mayor productividad cargando la información directamente a JSON, a cambio de no tener los objetos cargados y no poder tratarlos de ninguna de las formas (simplemente cargar y devolver).

Por ello pasamos a implementar el siguiente código:

   1: public async Task<long> Execute(string connectionString)

   2: {

   3:     var stopwath = new Stopwatch();

   4:     stopwath.Reset();

   5:     stopwath.Start();

   6:  

   7:     using (var connection = new SqlConnection(connectionString))

   8:     {

   9:         connection.Open();

  10:  

  11:         using (var command = connection.CreateCommand())

  12:         {

  13:             command.CommandText = "select * from persona";

  14:  

  15:             var dataReader = await command.ExecuteReaderAsync();

  16:             var stringBuilder = new StringBuilder();

  17:             WriteValue(stringBuilder, dataReader);

  18:         }

  19:     }

  20:     stopwath.Stop();

  21:     return stopwath.ElapsedMilliseconds;                

  22: }

  23: private void WriteValue(StringBuilder stringBuilder, object val)

  24: {

  25:  

  26:     if (val is IDataReader)

  27:         WriteDataReader(stringBuilder, val as IDataReader);

  28:     //else if (val is IDictionary)

  29:     //    WriteDictionary(sb, val as IDictionary);

  30:     //else if (val is IEnumerable)

  31:     //    WriteEnumerable(sb, val as IEnumerable);

  32:     else if (val is DateTime)

  33:         stringBuilder.Append(((DateTime) val).ToString("o"));

  34:     else

  35:         stringBuilder.Append(val);

  36: }

  37:  

  38: private void WriteDataReader(StringBuilder stringBuilder, IDataReader dataReader)

  39: {

  40:     var numColumns = dataReader.FieldCount;

  41:     var names = new List<string>();

  42:  

  43:     var rowCount = 0;

  44:     stringBuilder.Append("{[");

  45:     while (dataReader.Read())

  46:     {

  47:         stringBuilder.Append("{");

  48:  

  49:         for (int i = 0; i < numColumns; i++)

  50:         {

  51:             if (rowCount == 0)

  52:                 names.Add(dataReader.GetName(i));

  53:  

  54:             stringBuilder.AppendFormat(""{0}":", names[i]);

  55:             this.WriteValue(stringBuilder, dataReader[i]);

  56:             stringBuilder.Append(",");

  57:         }

  58:  

  59:         if (numColumns > 0)

  60:             stringBuilder.Length -= 1;

  61:  

  62:         stringBuilder.Append("},");

  63:         rowCount++;

  64:     }

  65:     if (rowCount > 0)

  66:         stringBuilder.Length -= 1;

  67:     stringBuilder.Append("]}");

  68: }

Este código está a medio implementar, ya que habrá que terminar el método WriteValue con la serialización de IEnumerable, IDictionary e inclusive los números reales al igual que se ha hecho con los DateTimes para dejarlos en un formato estándar y no tener problemas de localización.

Al final los tiempos pasan a ser:

  • DataTable
    • 1a iteración: 373ms
    • las siguientes 10: 112ms de media
  • DataReader
    • 1a iteración:161ms
    • las siguientes 10: 115ms de media
    • un 2.68% más de tiempo que el datatable
  • DataReader con Tasks
    • 1a iteración:125ms
    • las siguientes 10: 113ms de media
    • un 0.89% más de tiempo que el datatable
  • DataReader directo a JSON
    • 1a iteración: 75ms
    • las siguientes 10: 67ms de media
    • un 40.18% menos de tiempo que el datatable

Conclusiones definitivas

Esto nos lleva a pensar: ¿Por qué cargamos la información a entidades u objetos para luego serializarla? Ahí queda la pregunta.

Y como el cargar directamente a JSON solo se puede hacer con DataReader y no con DataTable cambiamos nuestro resultado aunque condicionado a simplemente devolver el JSON:

Au

PD: Código de la prueba https://github.com/XaviPaper/DataTable_DataReader

3 Comentarios

  1. pedrohurtado

    Hola Xavi,

    Lo primero muy buen post y lo segundo es evidente y ya te lo he dicho por twitter
    que yo a este post le pondría como titulo «EL FLAUTISTA DE HAMELIN» y me explico.

    Basta que nos digan que no utilices algo y sin más yo hago como los ratones y los niños, escucho
    la flauta sonar y al precipicio :).

    Hace tiempo hice una aplicación muy gorda con DataTables y en la que participo nuestro
    gran amigo y crack @salvador_ramos y los usuarios nos llegaron a decir que iba igual por
    una vpn que en local, eso que quiere decir que un DataTable no es lento, otra cosa son
    los maléficos DataSet.

    En el ejemplo veo una cosa y es que asignas

    Dictionary a dynamic, tu sabes realmente cuando se arrastra cuando creas un
    objecto que hereda de DynamicObject.

    Respecto a lo que te planteas de EF ya te lo digo yo con este twitter que he hecho las pruebas

    https://twitter.com/_PedroHurtado/status/490057286053036032

    Así que vamos a ser sinceros, porque no les contamos a la audiencia que utilizamos DataTables
    para todas las consultas de Web Api, por que van 100 más rápidos que los demás.

    Seguro que algún ratoncito nos hace caso xDDDD.

  2. crowley

    Buen post. «La fiesta de los bocazas» es un antipatron reconocido oficialmente. 😉

    Pegadle un vistazo a esto:
    http://altplusf13.blogspot.com.es/2013/05/la-fiesta-de-los-bocazas.html

  3. jmservera

    Hola Xavi,

    DataTable utiliza internamente DataReader para rellenar sus filas, lo puedes ver en el método FillInternal de http://referencesource.microsoft.com/#System.Data/data/System/Data/Common/DbDataAdapter.cs
    Así que es bastante raro que DataReader sea más lento que DataTable, eso te debería hacer sospechar porque debería ser por lo menos igual. La diferencia está en el serializador y en cómo de optimizado y fácil lo tiene para serializar una DataTable frente a una colección de string/objects. Mira https://github.com/JamesNK/Newtonsoft.Json/blob/c545dc3f4320269b192a6be53120170524620ebe/Src/Newtonsoft.Json/Converters/DataTableConverter.cs en comparación con https://github.com/JamesNK/Newtonsoft.Json/blob/c545dc3f4320269b192a6be53120170524620ebe/Src/Newtonsoft.Json/Converters/KeyValuePairConverter.cs

    El primero accede directamente al ArrayList interno y el segundo tiene que hacer reflection y lo hace para cada fila. Por eso si escribes tu el JSON lo hace más rápido.

Deja un comentario

Tema creado por Anders Norén