Anti Prácticas .NET: Lectura de Datos con ADO.NET

Estoy inaugurando sección en mi Blog, con la intención de demostrar el costo que implica una mala codificación o error de diseño en una aplicación .NET. Conversando sobre el tema con Adolfo Wiernik me sugirió el nombre “Anti Prácticas .NET“. Seguramente en cada una de estas entregas, intentaré confirmar o desmentir alguna de las leyendas urbanas respecto de .NET, lo cual tendremos merodeando por aquí al “Cazador de Mitos .NET“.


En esta primera entrega quisiera demostrar cuál es el costo al momento de seleccionar la forma de leer datos con ADO.NET.Habremos escuchado infinidad de veces decir: Un “DataReader es más rápido que un DataSet“, o que “el DataTable es más liviano que el DataSet“.


Pues vamos entonces a la caza del mito: “El DataReader es el método más eficiente de lectura de datos”.


La intención de este artículo es cuantificar el uso de una u otra forma de acceder a los datos.  ¿Ahora bien, porque nos interesaría conocer esto? ¿Qué tienen que ver estos resultados con mi aplicación? ¿Cuándo debo prestarle atención al desempeño de mi aplicación?  ¿Porque es tan importante hacerlo? Te recomiendo la lectura del Blog de Daniel Seara.


Presentación del Escenario


Este es el contexto en el que estoy haciendo las mediciones:


Una aplicación ASP.NET 2.0, que utiliza 3 mecanismos para recuperar datos “de solo lectura” de la base de datos AdvertureWorks alojada en SQL Server 2005:


  • DataReader
  • DataSet
  • DataTable

Aquí subrayo “solo lectura” porque, justamente solo quiero mostrar los datos.


El Código


La versión completa del código podrás bajarla de aquí.  De todas formas démosle un vistazo:


Hay una clase DataAccess que ejecuta la misma consulta y que devuelve el resultado en 3 formatos distintos: DataSet, DataReader o DataTable.


public class DataAccess
{
    static readonly string _connString;
   
static readonly string _sqlCmd;
   
static DataAccess()
    {
        _connString =
“Password=permiso;User ID=sa;Initial Catalog=AdventureWorks;Data Source=WALZER3″;
        _sqlCmd =
“SELECT HumanResources.Employee.EmployeeID, Person.Contact.FirstName, “ +
        
“Person.Contact.MiddleName, Person.Contact.LastName, “ +
        
“HumanResources.Employee.Title, HumanResources.Employee.BirthDate, “ +
       
“Person.Address.AddressLine1, Person.Address.AddressLine2, “ +
       
“Person.Address.City, Person.Address.PostalCode, Person.Contact.EmailAddress, “ +
       
“Person.Contact.Phone, HumanResources.Employee.MaritalStatus, HumanResources.Employee.Gender “ +
       
“FROM HumanResources.Employee INNER JOIN “ +
       
“Person.Contact ON HumanResources.Employee.ContactID = Person.Contact.ContactID INNER JOIN “ +
       
“HumanResources.EmployeeAddress ON “ +
       
“HumanResources.Employee.EmployeeID = HumanResources.EmployeeAddress.EmployeeID INNER JOIN “ +
       
“Person.Address ON HumanResources.EmployeeAddress.AddressID = Person.Address.AddressID AND “ +
       
“HumanResources.EmployeeAddress.AddressID = Person.Address.AddressID”;
    }
   
   
static public DataSet TraerDataSet()
    {
       
DataSet ds = null;
       
try
       
{
           
using (SqlConnection conn = new SqlConnection(_connString))
            {
                conn.Open();
               
SqlCommand cmd = new SqlCommand();
                cmd.CommandText = _sqlCmd;
                cmd.Connection = conn;
                cmd.CommandType =
CommandType.Text;
               
SqlDataAdapter da = new SqlDataAdapter(cmd);
                ds =
new DataSet();
                da.Fill(ds);
            }
        }
       
catch
       
{
        }
       
return ds;
    }


    static public IDataReader TraerDataReader()
    {
       
SqlDataReader dr = null; SqlConnection conn = null;
       
try
        
{
            conn =
new SqlConnection(_connString);
            conn.Open();
           
SqlCommand cmd = new SqlCommand();
            cmd.CommandText = _sqlCmd;
            cmd.Connection = conn;
            cmd.CommandType =
CommandType.Text;
           
dr = cmd.ExecuteReader(CommandBehavior.CloseConnection);
        }
       
catch
        
{
           
if (conn != null)
                conn.Close();
           
throw;
        }
       
return dr;
    }


    static public DataTable TraerDataTable()
    {
       
DataTable dt = null;
       
try
       
{
           
using (SqlConnection conn = new SqlConnection(_connString))
            {
                conn.Open();
SqlCommand cmd = new SqlCommand();
                cmd.CommandText = _sqlCmd;
                cmd.Connection = conn;cmd.CommandType =
CommandType.Text;
               
using (SqlDataReader dr = cmd.ExecuteReader())
               
{
                    dt =
new DataTable();
                    dt.Load(dr);
                }
            }
        }
        catch
        {
        }
        return dt;
    }
}


Luego hay 3 páginas aspx que muestran los datos obtenidos enlazando los datos con un DataRepeater:


<asp:Repeater ID=”dataReapeater” runat=”server”>
   
<HeaderTemplate>
       
<table border=”1″>
           
<tr bgcolor=”crimson”>
               
<th>EmployeeID</th>
               
<th>First Name</th>
               
<th>Middle Name</th>
               
<th>Last Name</th>
               
<th>Title</th>
               
<th>Birthdate</th>
               
<th>Adress Line 1</th>
               
<th>Adress Line 2</th>
               
<th>City</th>
               
<th>Zip</th>
               
<th>Email</th>
               
<th>Phone</th>
               
<th>MaritalStatus</th>
               
<th>Gender</th>
           
</tr>
           
</HeaderTemplate>
               
<ItemTemplate>
                   
<tr>
                       
<td><%# DataBinder.Eval(Container.DataItem, “EmployeeId”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “FirstName”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “MiddleName”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “LastName”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “Title”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “Birthdate”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “AddressLine1″) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “AddressLine2″)%></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “City”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “PostalCode”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “EmailAddress”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “Phone”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “MaritalStatus”) %></td>
                       
<td><%# DataBinder.Eval(Container.DataItem, “Gender”) %></td>
                   
</tr>
               
</ItemTemplate>
           
<FooterTemplate>
      
</table>
    </FooterTemplate>
</
asp:Repeater>

 

 

Y este es el código correspondiente al PageLoad de cada página aspx:


TraerDataSet.aspx


 

 

public partial class TraerDataSet : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        MostrarDataSet();
    }

    private void MostrarDataSet()
    {
        dataReapeater.DataSource =
DataAccess.TraerDataSet().Tables[0];
        dataReapeater.DataBind();
    }

}


TraerDataReader.aspx


 

 

public partial class TraerDataReader : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        MostrarDataReader();
    }

    private void MostrarDataReader()
    {
        using (IDataReader dr = DataAccess.TraerDataReader())
        {
            dataReapeater.DataSource = dr;
            dataReapeater.DataBind();
        }
    }
}

TraerDataTable.aspx

public
partial class TraerDataTable : System.Web.UI.Page
{
    protected void Page_Load(object sender, EventArgs e)
    {
        MostrarDataTable();
    }

    private void MostrarDataTable()
   
{
        dataReapeater.DataSource = DataAccess.TraerDataTable();
        dataReapeater.DataBind();
    }
}

Como verás la prueba es sencilla y el código está centrado en la recuperación de los datos, y no en la forma en que esos datos se muestran.  Claro que es una parte muy importante también, para conocer cómo hacerlo en ASP.NET te recomiendo el Blog de Eugenio Serrano.


Primeras Lecturas


Lo que primero vamos a medir es el tiempo que se toma cada una estas operaciones para mostrar los datos.  Para ello utilicé la herramienta JetBrains DotTrace 3.0. Esta herramienta te permite mediante la técnica de “code profiling” conocer el tiempo y cantidad de veces de cada una de las llamadas a los métodos de tu aplicación, incluido el código de .NET.


Observemos entonces el tiempo consumido durante 5 llamadas de cada una de los métodos que acceden y muestran los datos. A propósito, la consulta devuelve 232 registros.



Mirando los recuadros rojos podremos observar que recuperar los datos con DataReader es lo más rápido: 24% más rápido que hacerlo con DataSet y 33% más rápido que con DataTable.


Sin embargo si miramos los números de la clase DataAccess (recuadro verde), no guardan relación con la vista anterior.  El método TraerDataReader consume un tiempo irrisorio frente a los otros.  Como bien me apuntó Javier Loría, hasta ese momento (en el cual el DataReader es devuelto por el método), solo hemos abierto la conexión con las base de datos y enviado el comando sql, y hasta tanto no hayamos mostrado los datos, el cursor no se ha movido del BOF. Distinto es el caso del TraerDataSet o el TraerDataTable, en el cual, para poder pasarse entre capas levantan los datos de la base de datos y se desconectan.


Con lo cual podemos sacar 2 conclusiones:


  • El DataReader no es un objeto para pasar entre capas.
  • Si solo queremos mostrar los datos con el DataSet o el DataTable, los habremos recorridos 2 veces, una vez para cargarlos en memoria y la segunda para mostrarlos.

Claro que no siempre podemos decidir solo en función del mejor desempeño.  ¿Cuál es la forma apropiada de utilizar ADO.NET en una capa de acceso a datos?, lo puedes leer en el Blog de Adolfo Wiernik.


¡Mito cazado! El DataReader es el método más eficiente de lectura de datos.


Pero lo del DataTable no tiene una explicación muy lógica. Recuperar el DataTable, en este caso, es 2 veces más lento que el DataSet. Pongámonos a investigarlo.
Miremos ahora el enlace a los datos:



Esta vista muestra el tiempo consumido por el método System.Web.UI.WebControls.Repeater.DataBind() y quien lo ha invocado. Si observamos veremos que los tiempos son similares, las diferencias son despreciables. Aquí no está la diferencia…


Estudio interno de las formas de acceso a los datos


Veamos ahora como se consume el tiempo en cada uno de nuestros métodos de acceso a datos:


MostrarDataReader()



En esta vista podemos observar el tiempo de las invocaciones realizadas dentro el método y el tiempo consumido por cada una de ellas.
El 97% del tiempo lo ha consumido System.Web.UI.WebControls.Repeater.DataBind(), o sea recorrer los datos con el cursor de solo lectura y avance y mostrarlos. El resto se va en System.Data.SqlClient.SqlCommand.ExecuteReader(), que comprende la apertura de la conexión y el envío del comando a la base de datos.


MostrarDataSet()


Observemos ahora el método que devuelve el DataSet, que nos servirá para entender cómo trabaja ADO.NET por dentro.



En el recuadro verde está resaltado System.Data.Common.DataAdapter.Fill(), método privado de la clase DataAdapter que realiza básicamente 2 tareas: leer los datos y cargarlos en la colección de DataTables del DataSet.
¿Como los lee?, mediante System.Data.Common.DbCommand.ExecuteReader() (recuadro rojo). Sí, internamente utiliza un cursor de solo lectura y avance (DataReader) para recuperar los datos. Y de paso vale la pena destacar que esa llamada consumió lo mismo (87 ms) que la llamada realizada en TraerDataReader() (87 ms). Entonces, ¿donde está la diferencia en tiempo?, en el método System.Data.Common.DataAdapter.FillFromReader() que consumió 1164 ms. Este método es el que se encarga de cargar los datos en memoria (DataSet).


El recuadro azul viene a colación del estudio que hagamos de TraerDataTable(), que si recuerdas tardaba el doble que TraerDataSet().


MostrarDataTable()


Trataremos de develar la incógnita de porque, si aparentemente no debería haber diferencia en cargar un DataTable y un DataSet, el primero es 2 veces más lento que el segundo, cuando de hecho un DataSet contiene una colección de DataTable.



Antes que nada veamos que System.Data.Common.DbCommand.ExecuteReader() (recuadro rojo) es el mecanismo interno nuevamente utilizado para recuperar los datos, cuya llamada consumió 82 ms al igual que antes. También encontramos el mismo método System.Data.Common.DataAdapter.FillFromReader() (recuadro azul), pero que esta vez consumió 2986 ms, casi el triple del tiempo que en el caso del DataSet. Si revisamos las invocaciones realizadas por FillFromReader() vemos que System.Data.Common.DataAdapter.FillLoadDataRow() está presente con el mismo tiempo consumido (1157 ms) en la carga del DataSet y el DataTable. La gran diferencia está en las llamadas internas que realiza System.Data.DataTable.EndLoadData() (1607 ms), que como su nombre lo sugiere, realiza unas operaciones luego de que los datos ya están memoria.  Veamos de que operaciones estamos hablando (para ello utilicé la herramienta Reflector que permite desensamblar el código .NET):


public void EndLoadData()
{
     IntPtr ptr;
     Bid.ScopeEnter(out ptr, “<ds.DataTable.EndLoadData|API> %d#\n”, this.ObjectID);
     try
   
 {
         if (this.inDataLoad)
         {
             if (this.loadIndex != null)
             {
                 this.loadIndex.RemoveRef();
             }
             if (this.loadIndexwithOriginalAdded != null)
             {
                 this.loadIndexwithOriginalAdded.RemoveRef();
             }
             if (this.loadIndexwithCurrentDeleted != null)
             {
                 this.loadIndexwithCurrentDeleted.RemoveRef();
             }
             this.loadIndex = null;
             this.loadIndexwithOriginalAdded = null;
             this.loadIndexwithCurrentDeleted = null;
             
this.inDataLoad = false; this.RestoreIndexEvents(false);
             if (this.DataSet != null)
             {
                 this.DataSet.EnforceConstraints = this.savedEnforceConstraints;
             }
             else
             {
                 this.EnforceConstraints = true;
             }
        }
    }
    finally { Bid.ScopeLeave(ref ptr); }
}


Analizando esta sección del código:


            if (this.DataSet != null)
            {
                this.DataSet.EnforceConstraints = this.savedEnforceConstraints;
            }
            else
            {
                this.EnforceConstraints = true;
            }


vemos que si el objeto DataTable forma parte de un DataSet (this.DataSet) se verifican las restricciones (constraints) si es que la variable privada this.savedEnforceConstraints así lo indica, y sin embargo si el DataTable es un objeto aislado (standalone)  que no depende de un DataSet las restricciones se verifican siempre.  Es así que cuando se asigna true a this.DataSet.EnforceConstraints, se realiza el proceso de verificación de las restricciones.  Volviendo al gráfico podrás ver las llamadas a System.Data.DataTable.set_EnforceConstrains y las invocaciones internas a System.Data.DataColumn.IsNotAllowDbNullViolated() y System.Data.DataColumn.IsMaxLengthViolated() que ambas consumen el 52% del tiempo total.


La página VerPropiedadesColumnas.aspx del ejemplo nos da un indicio. Esta nos muestra las propiedades de los DataColumns del DataSet y del DataTable.
Allí observamos que la propiedad AllowDbNull está marcada según las propiedades de la tabla en la base de datos solo para el DataTable.
 
 

De ahí se explica entonces las llamadas a System.Data.DataColumn.IsNotAllowDbNullViolated() por ejemplo:


internal void EnableConstraints()
{
    IntPtr ptr;
    Bid.ScopeEnter(out ptr, “<ds.DataSet.EnableConstraints|INFO> %d#\n”, this.ObjectID);
    try
   
{
        bool flag = false;
        ConstraintEnumerator enumerator3 = new ConstraintEnumerator(this);
        while (enumerator3.GetNext())
        {
            Constraint constraint = enumerator3.GetConstraint();
            flag |= constraint.IsConstraintViolated();
        }
        foreach (DataTable table in this.Tables)
        {
            foreach (DataColumn column in table.Columns)
            {
                if (!column.AllowDBNull)
                {
                    flag |= column.IsNotAllowDBNullViolated();
                }
                if (column.MaxLength >= 0)
                {
                    flag |= column.IsMaxLengthViolated();
                }
            }
         }
         if (flag)
         {
             this.FailedEnableConstraints();
         }
      }
      finally { Bid.ScopeLeave(ref ptr);
    }
}


Ahora que conocemos que es lo que realmente sucede tratemos de encontrar una explicación lógica de porque se verifican las restricciones en el caso del
DataTable aislado y no así en el DataSet. La respuesta está documentada:


El método DataAdapter.Fill no establece todas las propiedades de los objetos DataTable y DataColumn


Síntomas


Después de que utiliza el método DataAdapter.Fill, no se establecen varias propiedades del DataTable y objetos DataColumn (como clave principal, campos automáticos de incremento, campos anulables, índice único y etc.)


Causa


El objeto DataAdapter está optimizado para sólo lectura de manera predeterminada. El método Fill sólo recupera la cantidad de esquema que es necesario mostrar los datos. Debe seguir pasos adicionales que obtienen el esquema adicional que es necesario actualizar o validar un objeto.


Solución


Para obtener información adicional acerca del objeto DataSet, utilice uno de los métodos siguientes:


  • Llame al método DataAdapter.FillSchema para obtener información de esquema ampliado.
  • Establezca el valor de DataAdapter.MissingSchemaAction a MissingSchemaAction.AddWithKey antes de llamar al método Fill

Optimización de lectura de datos con DataTable


Pues hagamos caso de lo que el artículo dice: el que está optimizado para lectura de datos es el método DataAdapter.Fill(). Veamos que sucede si cargamos el DataTable con este mecanismo.


static

 

public DataTable TraerDataTableOptimizado()
{
   
//Este método está optimizado para cargar un DataTable con datos de SOLO LECTURA
   
DataTable dt = null;
   
try
   
{
       
using (SqlConnection conn = new SqlConnection(_connString))
        {
          conn.Open();
          
SqlCommand cmd = new SqlCommand();
          cmd.CommandText = _sqlCmd;
          cmd.Connection = conn;
          cmd.CommandType =
CommandType.Text;
         
SqlDataAdapter da = new SqlDataAdapter(cmd);
          dt =
new DataTable();
          da.Fill(dt);
       }
    }
   
catch
   
{
    }
   
return dt;
}



¡Ahora sí! el tiempo consumido por la recuperación del DataTable se asemeja al del DataSet.


Ahora que ya sabes interpretar los informes del profiler de código, revisa las llamadas internas de TraerDataTableOptimizado().



Podrás apreciar que las llamadas a EndLoadData bajaron del 1607 ms a 0.4. Y este número podría haber sido más grande si la cantidad de datos registros y columnas fuese mayor.


Conclusión


Hemos comprobado que el uso correcto de las técnicas de acceso a datos en ADO.NET nos permite lograr un mayor rendimiento en nuestras aplicaciones.  También hemos aprendido algo de cómo funciona internamente ADO.NET, ejercicio que nos va a servir para tomar buenas decisiones al momento de elegir nuestra estrategia de acceso a datos.


Continuación 


Parte II
Parte III




5 thoughts on “Anti Prácticas .NET: Lectura de Datos con ADO.NET”

  1. Muy bueno el articulo !
    Has probado hacer las pruebas con las herramientas de rendimiento que vienen en el VS 2005 ?
    Ya que estoy buscando alguna tool para hacer este el tipo de pruebas.
    El area en la que me deseo enfocar, es la perdida de rendimiento por la “incorporacion de Patrones” en las aplicaciones.
    Saludos.

  2. Carlos, un gran artículo!

    Yo le sumaría a la prueba usar Listas Genericas llenadas con un dataReader, para pasarlo entre capas, y verificar si tiene más rendimiento que pasar un DataSet o un DataTable.

    Saludos,

Leave a Reply

Your email address will not be published. Required fields are marked *

You may use these HTML tags and attributes: <a href="" title=""> <abbr title=""> <acronym title=""> <b> <blockquote cite=""> <cite> <code> <del datetime=""> <em> <i> <q cite=""> <strike> <strong>