DNM+ Online
dotnetmania 2.0
Visualización de grandes conjuntos de datos en ASP.NET
Estamos acostumbrados a leer ejemplos de ASP.NET con ADO.NET en los que nos enseñan las características más espectaculares de .NET Framework con sencillez y elegancia, pero en pocas ocasiones éstos hacen referencia al escalado o al uso real en una aplicación empresarial.

Un ejemplo típico de lo dicho sucede cuando  usa­mos un GridView paginado y/o ordenado que obtiene sus datos de un DataSet proveniente de una base de datos. En los ejemplos de la documentación nos encontramos con soluciones que solo funcionarían aceptablemente con un número pequeño de conjunto de datos; cuando el número de tuplas crece, el tiempo de respuesta crece exponencialmente. Haciendo un símil, es como si nos diesen un Ferrari rojo, una bala de plata, para transportar un solo kilogramo de patatas a gran velocidad.  Sin embargo, en el mundo real hay que transportar más patatas. Con 4 toneladas de tubérculo no nos vale el Ferrari; necesitamos un tráiler, que suele ser más lento aunque efectivo.

Este artículo enseña cómo podemos transportar 4, 8 ó 16 toneladas de patatas a casi la misma velocidad que si fuera un solo saco en un Ferrari ligeramente modificado. También sirve de introducción a las principales características disponibles para ASP.NET 2.0, como AJAX y la internacionalización de aplicaciones, y por otra parte hace uso de las características de Visual Studio 2005 for DB Professionals para la generación de conjuntos de datos de prueba. 
Supongamos el escenario en el que una aplicación web ASP.NET debe mostrar en un GridView un origen de datos extenso. Por ejemplo, una lista de mensajes a un usuario, unas entradas de un log, etc. Queremos que el usuario pueda acceder a los datos paginados y a la vez ordenarlos y filtrarlos por algún criterio. El requisito principal es que el usuario tenga acceso a todo el conjunto de datos desde la rejilla del modo más rápido posible. La solución más simple y la que encontramos en la documentación y tutoriales de .NET pasa por enlazar un origen de datos (ObjectDataSource, SqlDataSource) a un GridView. Las características de paginado y ordenación se pueden aplicar automáticamente. Incluso el SqlDataSource genera la consulta SQL por ti. Mientras tengamos un número reducido de usuarios concurrentes y pocos datos todo irá como la seda, pero esta solución fácil y rápida escala muy mal a poco que aumente la carga de peticiones o el número de datos a devolver.
Con el enfoque anterior, el servidor tiene que descargarse el DataSet completo desde el origen de datos para, al final, mostrar solo una parte de él. Os podéis imaginar que es algo tremendamente ineficiente en cuanto crezca el número de datos o de peticiones. Por otra parte, una solución basada en caché ASP.NET de servidor no parece apropiada por dos motivos: 1. DataSet extensos implican un gran uso de la memoria física del servidor, la afinidad suele ser por sesión de cliente. 2. Que los datos recuperados dependan de algún parámetro intrínseco al cliente y su sesión (por ejemplo, una colección de emails de un usuario) y la caché, a nivel de aplicación, no pueda aplicarse.

En este artículo voy a proponeros una solución elegante a la visualización eficiente de grandes conjuntos de datos que escala razonablemente bien. Vamos a conseguir que el acceso a los datos paginados a través de la rejilla dependa lo mínimo posible del número de tuplas de la tabla, intentando mantener los tiempos de respuesta estables dentro de la regla de los 7 segundos, que estipula que una página Web nunca debería de tardar más de ese tiempo en dar una respuesta al usuario.

Escenario Para nuestros propósitos, definiremos un escenario bastante común. Supongamos que necesitamos mostrar una bandeja de entrada de mensajes, mostrando un icono de estado por mensaje así como la fecha de envío, el remitente y el asunto. Los datos mostrados podrán ordenarse ascendente o descendentemente por cualquier columna. Por defecto, y por comodidad, los mensajes se mostrarán ordenados descendentemente por fecha de envío. A la bandeja se le podrá aplicar un filtro temporal sencillo, pudiendo ver solo los mensajes recibidos en el día, en la semana, en el mes o todos los mensajes. También ha de ser posible la selección del número de mensajes por página.

Diseño e implementación Siguiendo los requisitos del escenario, la aplicación ASP.NET "de juguete" tiene el aspecto de la figura 1. Podéis descargar el proyecto VS2005 original desde www.dotnetmania.com o desde  mi blog [1]. En la capa de vista se han empleado las tecnologías de Microsoft AJAX y las características de Temas e Internacionalización (i18n) de ASP.NET 2.0. También se ha utilizado el control ObjectDataSource. Respecto a AJAX, se empleó un UpdatePanel, que contiene los controles principales del ejemplo, incluido el GridView. También se usa un ProgressPanel para aquellos postbacks que duren más de medio segundo.
En cuanto los temas, se incluye el tema WhiteCushion, que define todo los atributos de visualización de la página en el archivo de estilo WhiteCushion.css, y el archivo de pieles WhiteCushion.Skin. Destacamos el estilo asociado con el UpdatePanel para presentar un mensaje de espera, que se muestra centrado en la página, mientras se espera la respuesta del servidor. La página está disponible para la cultura es-ES y, por defecto, para en-US. La cultura se elige en función de las preferencias del navegador. En IE7, las preferencias de idioma pueden editarse en "Herramientas" | "Opciones de Internet" | "General" | "Idiomas". Se utiliza la localización implícita, por lo que en los archivos de recursos (Default.aspx.es.resx y Default.aspx.resx) hay que incluir el atributo a localizar del control junto con la clave de localización. También se hace uso de controles <asp:Localized> para los literales de la página. Por ejemplo: Para mostrar los datos se utilizó un control GridView; pueden observarse los atributos de internacionalización implícita y el uso de DataImageUrlFormatString, en la primera columna, para mostrar un pequeño icono.
El GridView está enlazado a un ObjectDataSource llamado MessageDataSource. Esta clase de DataSource, introducido en ASP.NET 2.0, permite crear fuentes de datos de objetos empresariales, incluidos DataSet, sin necesidad de implementar una clase controladora en la interfaz que ponga de acuerdo los controles visuales con las fachadas de nuestra lógica de negocio.
En el control ObjectDataSource se puede indicar el método y el tipo que resolverá la obtención de datos y el número de columnas devueltas, así como si acepta paginado y ordenación. También define una suscripción al evento OnSelected, que servirá para actualizar el control con las estadísticas de la consulta. Los parámetros definidos en la sección SelectParameters permiten establecer una configuración extra para realizar la selección de datos; en nuestro caso, un filtro temporal sobre los datos. El parámetro timeElapsed es actualizado por el método que resuelve la sentencia SELECT y contiene información estadística sobre la consulta. Todos los parámetros definidos bajo SelectParameters son de entrada y salida. Aunque existe un atributo (Direction) para indicar la dirección de los parámetros, ASP.NET no lo usa y está reservado para futuros usos, como indica la documentación. El tipo GridViewSample.MessageBusinessLogic (figura 4) es la fachada de nuestra lógica de negocio, y gracias a ObjectDataSource podemos invocarla sin necesidad de escribir una clase intermedia, controladora de interfaz. En el control ObjectDataSource se hace referencia a los métodos públicos  Select y GetRowsCount de MessageBusinessLogic. El primero realiza la selección de los datos, mientras que el segundo devuelve el número de filas totales de la consulta para poder calcular el número de páginas. En este ejemplo, por motivos de rendimiento, se aprovecha la conexión en el método Select para obtener el número de tuplas devueltas. También se retrasa la apertura de la conexión lo máximo posible y se cierra en cuanto es posible, siguiendo así las buenas prácticas asociadas a los esquemas de acceso a datos con concurrencia optimista. El control ObjectDataSource ejecuta primero Select y luego GetRowsCount; para que timeElapsed tenga un valor válido, es necesario devolverlo en GetRowsCount y no en el método Select. En caso de tener métodos sobrecargados, ObjectDataSource siempre elegirá aquellos cuya firma coincide con los parámetros por defecto y los definidos por el desarrollador en SelectParameters. El método privado ConfigureDateFilter establece los valores de los parámetros de la consulta SQL relacionados con la fecha para obtener los mensajes de: hoy, esta semana, este mes y todos los mensajes. Hay que prestar especial atención al uso del método para definir parámetros SQL AddWithValue. Un mal uso de este método puede provocar que se creen y se almacenen en la caché de SQL Server varios planes de ejecución. Podemos usar AddWithValue siempre que el tipo de dato tenga un tamaño preestablecido, como ocurre en los casos de int, DateTime, etc. Pero nunca si es un string; en ese caso es necesario definir previamente el tamaño con el búfer máximo esperado, por ejemplo:

cmd.Parameters.Add("@param_string", SqlDbType.NVarChar, 64); cmd.Parameters[0].Value = "abc";

Una explicación más detallada del problema puede consultarse en el blog [3]. Pasemos a hablar de la consulta SQL. Puesto que nuestro GridView debe ser capaz de ordenar los datos por columnas de forma ascendente y descendente, necesitamos pasarle ese parámetro a la consulta SQL. El problema es que el orden no puede parametrizarse como se hace con los valores en una cláusula WHERE, por lo que es necesario construir una nueva consulta cuando se cambia el orden. Este hecho genera un plan de ejecución distinto para cada orden. En nuestro caso, siguiendo un escenario real, los datos son ordenados por el criterio más relevante, concretamente por el atributo Sent  (fecha de envío) de forma descendente.
Primero se realiza un SELECT utilizando  ROW_NUMBER(), una columna especial de Transact SQL que devuelve el número secuencial de una fila de una partición de un conjunto de resultados, pudiendo definir el orden de los resultados. Sobre este conjunto de resultados se aplica una restricción, usando @FirstRow y @LastRow, para devolver solo las tuplas que toca ver en la página actual.

La consulta SQL_ROWCOUNT obtiene el número de tuplas totales para el filtro temporal actual. Este valor es necesario para que el ObjectDataSource pueda indicarle al GridView cuál es el volumen de paginado y mostrar adecuadamente los enlaces a las páginas. Para mostrar todos los mensajes, abrimos el intervalo temporal entre la fecha más pequeña y la más grande. Nunca se han de utilizar las propiedades MaxDate y MinDate de System.DateTime con SQL Server, pues se lanzaría una excepción ya que la fecha máxima y mínima de SQL Server difieren de la de las del tipo DateTime definidas en .NET Framework. Es necesario utilizar SqlDateTime.MinDate y SqlDateTime.MaxDate. Esquema de datos del ejemplo Para crear el esquema y generar los datos de prueba se empleó Visual Studio 2005 Team Edition for DB Professionals.  Para crear las consultas y revisar los planes de ejecución estimados y reales, se utilizó Microsoft SQL Server Management Studio Express. El esquema de datos del ejemplo se presenta a continuación. Los índices son obligatorios. Sin ellos, SQL Server no podría realizar una planificación óptima de la consulta.
Para aquellos que no estén muy familiarizados con los gestores relacionales de base de datos, les recomiendo que ejecuten cada consulta de su desarrollo en Microsoft SQL Server Management Studio (vale la versión Express, que es gratuita para ciertos usos) y comprueben el plan de ejecución. Las operaciones principales deberían optimizarse como Index Seek o Clustered Index Seek y evitarse los Sequential Scan o Table Scan.
La edición de Visual Studio 2005 for DB Professionals permite entre otras cosas generar pruebas de unidad sobre nuestro esquema y, lo más interesante en nuestro caso, generar conjuntos de datos de prueba. Creando un proyecto para SQL Server 2005 se puede crear en la carpeta del proyecto Data Generation Plans un plan de generación de datos. En él podremos especificar el número de tuplas que deseamos y también las plantillas de generación de valores. Para cada columna se puede indicar el rango de valores, la distribución e incluso se puede aplicar una expresión regular para generar los atributos alfanuméricos o enlazar a un conjunto de datos reales.

Pruebas y resultados Para probar la aplicación de juguete se crearon tres escenarios; para los dos primeros se aplica la técnica descrita basada en ObjectDataSource, uno con índices adecuados en la tabla Message y en el otro sin índices. En el tercer escenario se utiliza un GridView con un SqlDataSource, el ejemplo más común de visualización de datos con ASP.NET. Para cada uno ellos se midieron tiempos con 65.536, 262.144, 524.288 y 1.048.576 tuplas. Se tomaron dos medidas a partir de la media de tres muestras. La primera medida toma el tiempo que le lleva mostrar páginas al GridView del principio del conjunto de datos. La segunda medida toma el tiempo en las páginas finales del GridView. El entorno de software y hardware de pruebas ha sido el siguiente: Pentium IV a 3Ghz con 2GB de RAM, disco SATA, Windows XP SP2, Visual Studio 2005 for DB Professionals y SQL Server Express 2005. Se hizo una prueba con 8.388.608 tuplas y los tiempos se incrementaron notablemente, pero ello no puede ser achacable a la capa de acceso de datos sino a la configuración, sin ningún tipo de optimización de SQL Server 2005 Express, y la carga de mi sistema junto con sus limitaciones hardware; los índices ocupaban algo más de 1 Gb, al igual que la tabla física, y el sistema estaba paginando la memoria virtual continuamente.
Respecto a la planificación de consultas, para el primer y segundo escenario se genera un plan para cada una de las dos consultas. En el tercer escenario se crea un plan para una sola consulta.  Los planes para las consultas dependientes del ObjectDataSource son del tipo Prepared, y la del SqlDataSource es Adhoc. 
Del artículo [3] se desprende que las consultas Adhoc, o de corto periodo de vida, se comportan mal en cuanto a rendimiento cuando el número de usuarios es alto. Este hecho no ocurre con las consultas parametrizadas de los dos primeros escenarios. Hay que señalar que la consulta autogenerada por el control SqlDataSource no utiliza nombres totalmente calificados a la hora de referenciar a las tablas. El uso de éstos mejoraría ligeramente el rendimiento, ya que facilitaría la resolución de la localización de la tabla en la base de datos. Para todos aquellos interesados en consultar las vistas del sistema relacionadas con el plan de ejecución de consultas y procedimientos almacenados, os remito de nuevo a los post en el blog [2]. Para un vistazo rápido a la caché de planes de ejecución podéis ejecutar la siguiente consulta:

select  text, cacheobjtype, objtype, usecounts from sys.dmexeccachedplans cp cross apply
sys.dm
execsqltext(plan_handle) Discusión Las gráficas del apartado anterior no dejan lugar a dudas. En cuanto el conjunto de datos crece, el escenario 3 es exponencialmente más lento que el 1 ó el 2. Si bien para un conjunto pequeño de tuplas (65.536) las diferencias no parecen grandes (algo menos de un segundo), en cuanto crece en tamaño la tabla (1M de tuplas) las diferencias se disparan a más de 100 segundos, un tiempo imposible de aceptar en un entorno Web e incluso en una aplicación de escritorio. Se ha encontrado una diferencia de tiempo para los escenarios 1 y 2 dependiendo de si se consultan las páginas iniciales de la tabla o las finales. Esta diferencia de tiempo parece que crece linealmente con el número de tuplas. Sin poder encontrar una explicación definitiva, intuyo que probablemente se debe a que SQL Server tarda en recorrer índices tan extensos (cerca de 512 Mb). Este retraso es aceptable en el sentido de que es extraño que el usuario acceda a las páginas finales. Porque podría invertir el orden, que es una operación muy rápida, o filtrar más los datos. Otra opción es suprimir el botón de acceso a la última página, algo bastante común cuando se necesita devolver gran cantidad de datos paginados, tal como hace el buscador Google.
El motivo de incluir dos escenarios con ObjectDataSource, uno con índices y otro sin ellos, nos sirve para demostrar la importancia del uso de índices en un gestor de base de datos relacional. Solo hay que ver cómo se incrementa exponencialmente la diferencia de tiempos a medida que crece el número de tuplas (gráfica 2) que, sin ser tan exagerada como la del escenario 3, es un tiempo considerable (unos 12 segundos de diferencia para un 1M de tuplas). Han de crearse los índices justos y necesarios, dependiendo de la frecuencia y el tipo de consulta que se hagan con los datos. Un número excesivo de índices implica un mayor mantenimiento de los mismos por parte de SQL Server, ralentizando las operaciones de inserción, modificación y borrado de tuplas. Respecto al diseño de los escenarios 1 y 2, el GridView es alimentado por un ObjectDataSource que tiene un modo elegante de comunicarse con una fachada de la lógica de negocio. La interfaz de usuario que controla el paginado y el orden de los datos mostrados está ya implementada y podemos centrarnos en lo importante, en la lógica de negocio. Siendo una funcionalidad de las más productivas y útiles de ASP.NET 2.0. He utilizado objetos DataSet sin tipar. Los que me conocen saben que soy amigo del modelo de lógica de negocio basado en objetos empresariales. Sin embargo, en este escenario, es necesario renunciar a ellos y buscar la estructura de datos más equilibrada y flexible que tenemos en .NET para que la lógica de negocio pueda enviar rápidamente datos a la capa de vista.  Esta idea está reflejada en el patrón de diseño FastLane Reader [4]. Los DataSet de ADO.NET son los contenedores ideales para implementar este patrón de forma óptima y sin necesidad de instanciar una gran cantidad de objetos, que si bien es una acción rápida en .NET Framework en comparación con Java, no es un tiempo desdeñable que evitamos en gran medida utilizando DataSet. Tened presente que el uso de FastLane Reader no excluye, sino más bien complementa, el tener un modelo conceptual de objetos con sus correspondientes DAO y fachadas como forma normal de hacer operaciones CRUD con nuestros objetos empresariales. Una lección aprendida, que debemos recordar y aplicar del mundo real, es que si nuestro cliente tiene una operación común y recurrente con un tiempo excesivo de respuesta, poco le va a importar que le razonemos que tiene el código y diseño lógico más puro que una gota de rocío; su insatisfacción será enorme. Generalmente en un software en producción el requisito principal es el rendimiento por encima de una arquitectura rígida de objetos empresariales. Equilibrar la arquitectura y el rendimiento no es fácil y tiene más arte que ingeniería. Con la debida disciplina y a través del patrón FastLane Reader conseguimos rendimiento en componentes de visualización críticos con alta tasa de accesos, aunque sintamos que sacrificamos una arquitectura homogénea.  Está totalmente justificado. En cuanto a la base de datos, deciros que también se planteó la posibilidad de utilizar procedimientos almacenados, aprovechando que se compilan y su plan de ejecución nunca se descarta de una caché. Pero en nuestro caso tendríamos que construir uno por criterio de ordenación o bien crear una consulta dinámica con EXEC 'query' dentro de un solo procedimiento, perdiendo las ventajas de la compilación. Se creó uno con orden por fecha descendente y arrojó los mismos tiempos que la consulta. Una vez consultada la documentación en el blog [2] de cómo SQL Server trata la caché de planes de consulta y preguntando a MVP de SQL Server llegamos a la conclusión de que en este escenario, de alto número de accesos, es muy probable que las consultas estén siempre en la caché de planes de ejecución y no compensa crear los procedimientos. Una crítica a lo expuesto es que ha sido probado con un alto número de tuplas pero con solo un usuario concurrente. Para tener unas conclusiones más amplias se debería probar una ejecución masiva con varios clientes lanzando peticiones concurrentes y estudiar los tiempos de respuesta. También sería adecuado probarlo con un SQL Server 2005 Standard, con el servidor de aplicaciones y de base de datos separados. Concluimos que, atendiendo a la regla que dice que una página Web debería cargar en menos de 7 segundos para que el usuario no la abandone, solo el escenario 1 serviría. De los presentados es el único escalable según aumenta el número de tuplas.  Con pequeñas variantes, debería ser el enfoque utilizado por desarrolladores que implementen aplicaciones que necesiten visualizar grandes conjuntos de datos, especialmente en aplicaciones ASP.NET. Ya tenéis un tráiler a velocidad de Ferrari. A disfrutarlo con muchas patatas.

blog comments powered by Disqus
autor
referencias