Buscar
Social
Ofertas laborales ES
« Sun obtuvo beneficios en el segundo semestre fiscal | Main | Wiki J2EE, una "wikipedia" sobre J2EE »
martes
ene182005

Cachés, concurrencia e Hibernate



Untitled Document






















Cachés, concurrencia e Hibernate


Martín Pérez Mariñán

martin@javahispano.or
g


 

La arquitectura de caché de Hibernate es realmente potente. Su caché de dos niveles ofrece una gran flexibilidad al tiempo que un gran rendimiento tanto en sistemas en solitario como en cluster. Este artículo, trata de explicar el funcionamiento de ambas cachés, para que de este modo el lector pueda exprimir al máximo todas las posibilidades que ofrece Hibernate, aumentando así el rendimiento y escalabilidad de sus aplicaciones y evitando problemas de concurrencia.



1. Antes de empezar. Algo sobre Hibernate.


Sería realmente una aventura comenzar a leer este artículo sin tener unas nociones básicas sobre Hibernate. Hibernate es una herramienta de asociación entre modelos jerárquicos de objetos y esquemas relacionales de bases de datos, es decir, lo que se denomina comúnmente como herramienta de ORM ( Object Relational Mapping ). Este tipo de herramientas permiten manejar la persistencia de nuestros objetos de una manera muy sencilla, a la vez que potente. Este artículo parte del supuesto de unos conocimientos básicos sobre Hibernate. No es necesario ser un gurú para asimilar los contenidos aquí presentes. Si tuviese que situarlo en algún rango de experiencia, diría que es un artículo de nivel básico-medio.


Si no disponéis de los conocimientos mínimos sobre Hibernate necesarios para afrontar la lectura de este artículo, os recomiendo los enlaces que aparecen al final para así poder obtener más información.


1.1 La arquitectura de caché de Hibernate


La arquitectura de caché de Hibernate es muy potente. Hibernate, ofrece dos niveles de caché, orientados a tareas muy diferentes, y de los cuales, sólo el primero de ellos es obligatorio. Por si fuese poco, Hibernate incluye una caché de consultas que nos da la posibilidad de obtener rápidamente resultados que ya habían sido consultados previamente.


El siguiente diagrama muestra un esquema de la caché de Hibernate, tal como se describe en su documentación.



Figura 1: Esquema de la caché de Hibernate.


2. Cachés y concurrencia


Una caché es un mecanismo que nos permite en muchas ocasiones aumentar el rendimiento de nuestras aplicaciones. Al almacenar nuestras estructuras de objetos en memoria, nos evita volver a buscar dichos objetos en base de datos, ahorrándonos multitud de accesos, y por consiguiente grandes cantidades de tiempo. Además, otros usuarios, podrán compartir la caché de nuestro sistema, viendo incrementado todavía más el rendimiento de sus aplicaciones, al aprovecharse las estructuras de datos cargadas por otros usuarios.


Pero cualquier caché tiene asociados una serie de problemas que afloran especialmente al aumentar la concurrencia de las aplicaciones. Estos problemas, pueden llegar incluso a hacer que la caché se vuelva en nuestra contra, y que el rendimiento de las aplicaciones decrezca alarmántemente. Los no habituados a estos problemas supongo que ahora mismo se verán tentados a parar de leer este artículo.¡Cómo puede un sistema que tenga una caché funcionar peor que otro que no lo tengo!. Si es así, ruego tranquilidad. Espero que al finalizar este apartado esté mucho más clara esta cuestión.


He dicho ya que una caché almacena objetos en memoria para acelerar accesos posteriores. El procedimiento habitual sería el siguiente:



  1. Un usuario realiza una consulta.

  2. Se comprueba si los objetos están en la caché. Si los objetos están en la caché se devuelven directamente.

  3. Si los objetos no están en la caché, se recuperan de algún recurso externo ( base de datos, servicio web, servidor de apliacaciones ) y se almacenan en la caché para en un futuro nos ahorremos este paso, que es sin duda el más costoso.


Pero a poco que nos pongamos a pensar, se nos ocurrirán una serie de problemas.


2.1 Problema 1 : ¿Qué sucede si nos descargamos el objeto A de la caché y otro usuario ( que lo tiene también en su propia caché ) lo modifica?


La modificación concurrente es el problema más habitual en las cachés. Tal es la magnitud de este problema, que normalmente se recomienda el uso de cachés tan sólo para datos de sólo lectura, o para datos que se actualicen casualmente.


En nuestro ejemplo, si el segundo usuario utilizase la misma caché, no tendríamos ningún tipo de problema, ya que dicha caché ya reflejaría los cambios realizados por el primer usuario. Sin embargo los problemas surgen cuando las cachés son diferentes (algo común en Hibernate como se explicará posteriormente), ya que en este caso el primer usuario encontraría que el objeto A ya está en su caché local y no lo recargaría de la base de datos, perdiendo la actualización realizada por el segundo usuario. Asimismo nos encontrariamos con la engorrosa situación de tener dos usuarios trabajando con objetos diferentes, y por si fuese poco, uno de los objetos está desincronizado con respecto a la base de datos y seguramente causará problemas.


Las soluciones a este problema son muy variopintas. El mecanismo de caché puede intentar realizar un chequeo cada cierto tiempo para comprobar el estado de los objetos en base de datos; también se podría tratar de sincronizar las cachés; otra solución sería que el primer usuario eliminase los objetos de la caché antes de realizar la consulta, de modo que se fuerze siempre un fallo en la caché y se acceda al recurso externo; otra solución sería realizar un acceso directo al recurso externo y evitar la caché en los casos en que sospechemos que pueden producirse actualizaciones concurrentes. Sea como sea, lo que está claro es que necesitamos algún mecanismo para controlar este molesto problema.


2.2 Problema 2 : ¿Qué sucede en el caso anterior si nuestra caché es distribuida?


Otro problema importante es el que sucede cuando nuestra caché está distribuida en diferentes servidores. Un ejemplo de caché distribuida es la librería Open Source JbossCache, incluida dentro del servidor de aplicaciones Jboss, pero que puede ser utilizada por separado en cualquier otra aplicación.


Las cachés distribuidas no sólo tienen la carga asociada a la búsqueda de un objeto dentro de sus estructuras internas, sino que es necesario tener en cuenta también el tiempo empleado para sincronizar los diferentes nodos de la caché en las diferentes operaciones realizadas con la misma. Analizar el funcionamiento de las cachés distribuidas queda un poco fuera de este artículo, pero es necesario ser conscientes que el coste es mucho mayor que el presente en una caché con un único nodo. Los sistemas de cache distribuida pueden ser muy variados. Podemos tener una caché replicada en todos los nodos, en cuyo caso tendríamos un coste asociado de sincronización de los datos; podemos tener una caché repartida entre los nodos, en cuyo caso tendríamos un coste asociado a la petición de objetos a otros nodos; o quizás simplemente cachés independientes que se sincronizen periódicamente con una base de datos común. Sea como sea, tenemos asociados una serie de costes de comunicación que antes no estaban presentes.


El peor coste al trabajar con las cachés distribuidas es el asociado a las actualizaciones. En muchos casos, cada actualización realizada en un nodo, implica el comunicarse con el resto de nodos para que actualicen sus estructuras de datos. Evidentemente, esta actualización no será trivial, y traerá asociada una serie de transacciones entre los diferentes nodos; quizás a través de un protocolo de comunicación similar al commit en dos fases, y resumiendo, habrá una serie de operaciones que incrementan considerablemente la latencia de nuestro sistema.


Como se puede apreciar, las cachés distribuidas tienen una serie de problemas graves, tanto si son redundantes como si no, que pueden ocasionar efectos contraproducentes en nuestro sistema. Muchas personas, podrían creer que simplemente por el hecho de implantar una caché distribuida en su sistema, todo va a ir más rápido. Sin embargo, si implantamos una caché en un sistema donde predominan las actualizaciones, seguramente nos encontraremos con el efecto contrario, y la latencia originada por dichas actualizaciones hará que el rendimiento no sólo no sea mayor, sino que se vea gravemente afectado.


2.3 Problema 3 : ¿Qué sucede si el usuario que ha modificado el objeto A lo ha hecho desde una aplicación que no usa la caché?


Nuevamente retomo aquí el problema de las actualizaciones remotas. Antes hablaba sobre lo que sucedía si un segundo usuario modifica un objeto en su caché local. En el caso de que el segundo usuario no utilice una caché, algo que será muy habitual ya que pocas veces tendremos el beneficio de un coto de caza privado sobre los datos de una empresa, los problemas que surgen son muy similares a los que planteaba en el primer caso. Aquí, ahora ya no podemos compartir la caché entre los dos usuarios, ya que las aplicaciones son diferentes, y probablemente hasta estén escritas en diferentes lenguajes o funcionen sobre diferentes plataformas.


El primer problema, tenía varias soluciones, bastante sencillas de aplicar, pero parte del supuesto de que estamos trabajando con la misma aplicación. Bajo este supuesto, todo es más simple, ya que siempre podremos encontrar algún mecanismo de cooperación entre las cachés. Siempre tendremos algún tipo de acuerdo. Pero en el caso de que aplicaciones diferentes, y que no controlamos, puedan acceder a los datos que hemos almacenado en la caché, la cosa se agrava, hasta el punto de que no se recomienda utilizar cachés para acceder a este tipo de datos compartidos. ¿Por qué? Pensemos en ejemplo: Imaginaros que tenemos una caché que accede a algún listado histórico de datos que se suponía que nunca cambiaban, pero de pronto, llega una empresa diferente que le vende una aplicación a nuestro cliente y resulta que dicha aplicación permite la modificación a mes vencido de dichos datos que suponíamos eran invariables. Ese cambio seguramente le interesaba al cliente por la razón que sea, pero lo importante es que nosotros no nos hemos enterado, y a partir de ese momento nuestra aplicación pasará a ofrecer datos potencialmente erróneos, con los graves problemas que nos puede acarrear esto.


Básicamente, debemos recordar la regla de que si no somos dueños de los datos, entonces es mejor no guardarlos en la caché. Guardar en caché datos que no controlamos puede ser muy peligroso, por mucha confianza que tengamos en que no cambien. No debemos olvidar nunca que nosotros no somos los que mandamos en esos datos. Realizar un enorme esfuerzo en crear un fantástico mecanismo de caché, podría venirse al traste por algún problema de acceso concurrente a estos datos, y seguramente nos costaría muchísimo más esfuerzo después el rehacer el trabajo, reescribiendo todo lo que hacía la caché con otro sistema de acceso que no la utilice.


 

3. La caché de primer nivel


Aunque en un primer momento pueda parecer extraño, en Hibernate la caché de primer nivel se corresponde con el objeto de sesión ( net.sf.hibernate.Session ) que se obtiene de una factoría de sesiones ( net.sf.hibernate.SessionFactory ). Esta caché de primer nivel guarda todos los objetos que vamos recuperando de la base de datos, de modo que si los volvemos a pedir, nos ahorramos un acceso a dicha base de datos. Además de esto, esta caché de primer nivel tiene otras ventajas ya que representa un único punto de acceso a los objetos, lo que evita representaciones conflictivas de los mismos, o que los cambios realizados en dichos objetos no sean visibles. Además, como bien se comenta en la documentación, esta sesión actua como fachada y situaciones como errores de accesos, lazos circulares en las referencias, etc., no afectarán a la totalidad del motor de persistencia sino que sólo afectarán a la sesión involucrada, manteniendo a salvo el resto de sesiones abiertas.


Cuando realizamos operaciones find(), update(), save(), saveOrUpdate(), get(), delete(), y en general todas las operaciones ofrecidas por la interfaz Session, estamos interactuando de manera transparente con la caché de primer nivel, ya sea realizando consultas, actualizando objetos de la caché, eliminando objetos de la caché, etc.


Cuando realizamos operaciones de actualización, inserción o eliminación de registros, estas operaciones no se realizan directamente, sino que se almacenan en la caché de primer nivel. Para volcar estas operaciones a base de datos es necesario realizar una llamada al método flush() de la interfaz Session, o al método commit() de la transacción abierta para esa sesión( session.beginTransaction() ). En caso de haber colocado objetos en la caché que ya no queremos que estén ahí, se puede utilizar el método evict() que se elimine de la caché el objeto pasado como parámetro. Existe también un método clear() que permite eliminar todos los objetos que se encuentren en ese momento en la caché, limpiándola así por completo. Ambos métodos, son útiles para políticas de sincronización entre cachés.


Cada vez que abrimos una sesión, se obtiene una conexión a base de datos del pool de conexiones de Hibernate. Esta conexión se libera cuando se cierra la sesión con el método close(). Evidentemente, cuantas más sesiones mantengamos abiertas, más conexiones estaremos consumiendo del pool pudiendo dejarlo exhausto. Para solucionar esto, Hibernate permite desconectar una sesión momentáneamente utilizando el método disconnect(). Esta operación, libera la conexión con la base de datos, aumentando efectivamente la escalabilidad de nuestro sistema al evitar el agotamiento del pool de conexiones . Evidentemente, existe un método reconnect() que permite a la sesión volver a obtener otra conexión a base de datos del pool de conexiones.


Una vez explicados someramente los métodos más interesantes de la caché de primer nivel de Hibernate, es el turno de hablar sobre como utilizar la caché en nuestras aplicaciones. Existen diferentes variantes, según el escenario en el que nos encontremos. Antes de explicarlas, convendría definir lo que en la documentación de Hibernate se denomina unidad de trabajo, transacción de aplicación y aplicación.


En Hibernate, una unidad de trabajo viene a representar una operación con el servidor. Por ejemplo una operación de actualizar el saldo de una cuenta corriente sería una unidad de trabajo.Por otra parte, una transacción de aplicación está formada por varias unidades de trabajo. Representa una operación a mayor nivel realizada por el usuario. Por ejemplo, mover dinero de una cuenta corriente a otra, sería una transacción de aplicación, que constaría de al menos dos unidades de trabajo de actualización de saldo. Para finalizar, en el contexto de una aplicación se ejecutarán múltiples transacciones de aplicación.


Una vez explicado esto, nos encontraríamos con los siguientes modelos de gestión de la sesión, es decir, de la caché del primer nivel: sesión global, sesión por aplicación, sesión por transacción y sesión por proceso. He escogido este órden de presentación de estos patrones porque me ha parecido el más didáctico. Se comienza con los patrones más sencillos, y menos recomendados en cuanto a escalabilidad, y se termina con los patrones más laboriosos, pero también más escalables y utilizados comúnmente. Todos tienen sus ventajas y desventajas, y espero que mis explicaciones os sirvan para escoger el más apropiado para vuestras aplicaciones.


3.1 Sesión global



Figura 2: En una sesión global, las aplicaciones usan la misma sesión


Este es el escenario más sencillo. Simplemente se crea una única sesión en el servidor y esa sesión se encarga de recibir todas las peticiones y de realizar todas las operaciones con la base de datos. Esta aproximación tiene la ventaja de que nunca se producirán errores de actualización concurrente entre cachés, ya que sólo hay una y siempre estará actualizada. En caso de que alguna operación produjese errores, o inconsistencias, se invalidaría la sesión y se obtendría una nueva. Además, es importante que se realicen periódicamente operaciones de flush() o commit(), ya que al tener una única sesión, estamos manejando una única transacción, y una excepción ocasionaría un rollback() y perderíamos todas las modificaciones hechas hasta el momento.



Evidentemente, esta sencillez tiene un precio. Esta solución es la menos escalable de todas. A medida que crezca la carga del sistema, la situación se volverá inmanejable. Por ejemplo, si un usuario realizase una operación costosa, que durase varios segundos, y llegasen varias peticiones de otros usuarios, éstas tendrían que esperar a que la operación del primer usuario terminase. Evidentemente, tampoco se aprovecha el pool de conexiones que nos ofrece Hibernate ya que sólo estamos utilizando una única conexión como máximo, y además ésta conexión está siempre abierta.



Esta es una solución que únicamente será interesante para aplicaciones que tengan poquísimos usuarios, y en las que no se prevean muchos más. Además es preferible que estos usuarios no trabajen simultáneamente o, si lo hacen, que sea realizando operaciones en las que se consulten datos y el usuario permanezca un tiempo ocupado con esos datos, es decir, que no haya demasiado trabajo concurrente. En caso de que sea muy posible que la carga se incremente en un momento futuro, o que la concurrencia de la aplicasión sea moderada, lo mejor es utilizar directamente otra alternativa desde el principio, ya que así evitaremos el coste de una migración. Únicamente si se cumplen las premisas anteriormente citadas, esta solución puede ser buena por simplicidad, pero antes de lanzarse a usarla, continuad leyendo el artículo porque hay algunos problemillas con esta implementación de los que no he hablado y que le han otorgado el adjetivo de antipatrón.


3.2 Sesión por usuario o aplicación



Figura 3: En una sesión por usuario, cada usuario tiene su propia sesión.


Este modelo, evolución natural del anterior, propone la idea de asignar una sesión por cada usuario o por cada aplicación cliente que se conecte con el servidor, al estilo de lo que un contenedor web hace con las sesiones HTTP. Una vez que se inicia la aplicación se asocia una sesión con el usuario que se ha conectado al servidor. En el momento en el que se cierra la aplicación, la sesión se cierra también. En estos sistemas siempre resulta interesante implementar algún tipo de control de timeout para evitar el problema de que se cierren aplicaciones cliente sin que el servidor se entere por problemas como caídas de tensión, reseteos, etc. Incluso un sistema de timeout como este nos puede ayudar a solucionar el problema de la inactividad en los clientes.



Esta aproximación, exige una manejo inteligente de las operaciones disconnect() y reconnect() ya que de otro modo sería nuevamente muy sencillo que se agotase el pool de conexiones, o que se llegase al número máximo de conexiones permitido por nuestro servidor de base de datos. Ojo con las excepciones y errores. Recordad liberar las sesiones aunque se produzcan excepciones ya que si no quedarán latentes y se podría tener problemas de agotamiento en el pool.



La principal ventaja de esta aproximación, es que mantenemos la caché de cada usuario activa en todo momento. La documentación de Hibernate, recomienda cerrar la sesión una vez utilizada, después de una transacción de aplicación o de una unidad de trabajo. Pero yo os pregunto: si, por ejemplo, me he descargado una jerarquía de diez mil objetos que ha tenido un coste de cien accesos a base de datos ( algo no muy raro ), ¿que sucede si cierro la sesión y después vuelvo a consultar la raiz de esa jerarquía? La respuesta es fácil: otros cien accesos a base de datos, y otros diez mil objetos nuevos. Imaginaros pues lo que sucedería con nuestra base de datos si muchos usuarios hiciesen eso múltiples veces. Sería muy probable que el rendimiento del servidor disminuyese.



Pero entonces, ¿por qué en Hibernate recomiendan cerrar la sesión? La respuesta es que Hibernate, como muchos otros frameworks, está pensado para el mundo de las aplicaciones web, un mundo en el que nadie va a cargar jerarquías de diez mil objetos para mostrarlos en árboles y tablas, y un mundo en el que esperar diez segundos por una respuesta es algo habitual, por lo tanto la carga de volver a realizar consultas es asumible. En las aplicaciones de escritorio, las cosas son diferentes. ¿Por qué diablos voy a tener que recargar los diez mil objetos que tenía en la caché si ya los tenía allí guardados? Esa es la principal ventaja de esta aproximación. Cada usuario tiene su caché de primer nivel, a la cual nos conectamos y reconectamos cuando sea conveniente, pero que siempre guarda lo que el usuario ha cargado y que permite que éste obtenga de forma rapidísima la información a la que ya ha accedido. ¡Esta aproximación nos permite crear aplicaciones realmente veloces!



Pero no todo podía ser tan bonito. El gran problema de esta aproximación lo vimos antes: las actualizaciones concurrentes. Si cada usuario tiene su propia caché, entonces ignorará por completo los cambios realizados por otros usuarios concurrentes, resultando ello en graves riesgos potenciales. Existen diferentes soluciones para este problema, cada una con mayor o menor complejidad:



  • No hacer nada. Sin duda es la opción más sencilla. Sin embargo, sólo es recomendable si nuestra aplicación accede únicamente a datos de sólo lectura, o que realmente no van a cambiar frecuentemente ( semanas, meses, etc. ). En este caso, no tiene demasiado sentido implementar un mecanismo de sincronización ya que no habrá actualizaciones concurrentes. En caso de que haya datos de lectura/escritura frecuente, lo mejor es que olvidemos está solución si no queremos perder nuestro empleo.




  • Realizar un mecanismo de sincronización entre sesiones. Esta quizás sea la solución más compleja. Cada vez que se realizase una operación con una sesión (inserción, modificación o eliminación) habría que actualizar el objeto en el resto de sesiones, o simplemente eliminarlo de las mismas con una operación evict(). Hibernate ofrece la posibilidad de crear interceptores cuyos métodos se ejecutarían justo antes de guardar, eliminar, actualizar objetos, o antes y después de una operación de flush(), lo que da una oportunidad para sincronizar las cachés.



    Esta es la solución que nos ofrecería una mayor fiabilidad en los datos, aunque realmente es compleja, sobre todo cuando hay que manejar jerarquías de objetos. Así, que si os disponéis a aplicar esta solución, prepararos para un trabajo laborioso. Una alternativa similar, y mucho más recomendable, es utilizar una caché de segundo nivel y nos puede ahorrar bastante trabajo, ya que ella misma realizará esta sincronización.




  • Ofrecer operaciones de refresco o limpieza . Otra posibilidad es que la aplicación pueda asumir que haya usuarios que trabajan concurrentemente, y que además el usuario sea consciente de ello. En este caso, en ciertas operaciones invocables por el usuario, se eliminaría el objeto de memoria, se vaciaría la caché, o se refrescaría el estado de los objetos solicitados. Cualquiera de las operaciones desemboca en que cuando el usuario consulte de nuevo el objeto eliminado de la caché o refrescado, se accederá a base de datos, y por lo tanto se recuperará el valor correcto del mismo. Una variante similar sería cerrar la sesión en lugar de realizar una llamada a clear() o refresh().



    Operaciones como refrescar o cargar de nuevo son muy habituales en los interfaces de usuario. Un ejemplo sería los interfaces de usuario de acceso a repositorios de CVS, en las que todos estamos acostumbrados a refrescar para obtener los cambios. Una alternativa a que sea el usuario el que explícitamente realice estas operaciones, es que sea la aplicación cliente la que cada cierto tiempo vacie la caché, recargue ciertas estructuras de datos, etc. Si el márgen de tiempo que nos da las actualizaciones realizadas es suficiente, nuestro sistema se comportará de manera excelente de modo transparente al usuario.



    Esta solución es ideal para aplicaciones, como un cliente CVS por ejemplo, en las que el usuario sea consciente de que se está realizando un trabajo concurrente y en el que la concurrencia no sea demasiado habitual. Asumir esto, nos evita tener que gestionar nosotros la concurrencia o añadir una caché de segundo nivel, y nos evita cualquier tipo de problema de sincronización que pueda aparecer, así que es una solución muy atractiva. Pero ojo, recordemos que los usuarios cambian mucho de opinión. Pensad siempre, que si de pronto la aplicación tiene mucho éxito y en vez de diez usuarios pasamos a tener mil, la concurrencia sería mucho mayor, y todo lo que hemos hecho puede irse al traste. Así, que si intuís una situación así, recordad que más vale prevenir.




  • Utilizar una caché de segundo nivel. Las cachés de primer nivel pueden utilizar una caché de segundo nivel como apoyo. Una caché de segundo nivel sirve como caché global para las cachés de primer nivel. Esta alternativa, nos permite cerrar las sesiones como recomienda la documentación de Hibernate, ya que ahora aquellos cientos de consultas a base de datos de las que hablabamos en el ejemplo de este apartado ya no se producirían, sino que se cargarían los datos directamente de la caché de segundo nivel. Evidentemente, volvemos a tener un coste mayor que si obtuviésemos los datos directamente desde la caché de primer nivel, pero sin duda es un coste mucho más asumible que el de acceder a base de datos constántemente.



    Personalmente, esta opción es la que yo recomendaría, siempre que dispongamos de una caché de segundo nivel que se adapte a nuestras necesidades, ya que es muy sencilla de implementar, nos mantiene dentro de los patrones recomendados y que veremos en los siguientes apartados, el coste de los fallos de caché de primer nivel se ve amortiguado considerablemente, y mantenemos un sistema escalable en previsión de aumentos posteriores de carga en el sistema.


3.3 Demitificando antipatrones y resolviendo problemas


Voy a hacer un lapsus en la explicación de los diferentes modelos, para romper una lanza en favor de las dos aproximaciones anteriores, que no son del agrado de los desarrolladores de Hibernate. Antes he comentado que esto es porque el mundo web tiene unos requisitos diferentes que el de las aplicaciones de escritorio, así que la documentación de Hibernate, claramente orientada a la web, utiliza un punto de vista no demasiado imparcial. Bueno, esto es así, pero también sería justo reconocer una serie de limitaciones en estas aproximaciones, de las que no he hablado y que han servido para colgarle el apodo de antipatrones a estos modelos. Más concretamente, estos dos modelos se corresponden con el antipatrón session-per-application y session-per-user-application.


Antes de que me colguéis por haber explicado dos modelos que Hibernate considera como antipatrones, explicaré cuales son sus razones, y veremos que realmente es un apodo absolutamente injusto, y derivado de un modelo de desarrollo de aplicaciones el web, que proporciona una visión muy limitada del espectro real de las aplicaciones en las que se puede utilizar Hibernate. Las razones principales por las que estos modelos se consideran antipatrones son las siguientes ( extraidas de la documentación de Hibernate ):



  1. Una sesión de Hibernate no es thread-safe. Esto hace que si dos usuarios acceden concurrentemente a la misma sesión, o un usuario accede a su sesión mientras está ejecutando otro método ( por ejemplo, al pulsar rápidamente varias veces el botón de submit de un formulario ), se podrían originar graves inconsistencias. Realmente esta razón se cae por su propio peso. Con un planteamiento similar podríamos decir que no se deben utilizar Servlets porque no son thread-safe ( SingleThreadModel sí que es un verdadero antipatrón ), algo que sería totalmente absurdo.



    ¿Alguién ha dicho que crear un servidor fuera algo sencillo y que no necesitemos establecer mecanismos de sincronización? Resulta bastante claro, que en cualquier servidor, utilicemos Hibernate o cualquier otra herramienta, tendremos que establecer una serie de controles sobre el acceso concurrente, como por ejemplo utilizar bloques de sincronización en las partes críticas. Pero esto es algo que deberíamos hacer de todos modos, tengamos una sesión por aplicación, por usuario, por transacción o por unidad de trabajo. Por otra parte, la ejecución de acciones repetidas por pulsar dos veces un botón, es algo que claramente debemos controlar en el cliente.




  2. Desincronización en caso de excepciones. Esto, más que una razón para declarar algo un antipatrón, es simplemente un problema. El problema de la desincronización sucede cuando se produce alguna excepción. Si se da este caso, se producirá un rollback en la transacción de Hibernate y se cerrará la sesión. Lo peor es que el estado de los objetos no coincidirá con el estado de la base de datos, y nos encontramos ante una desincronización. Esto hace que después no podamos reasociar (attach) estos objetos con la sesión abierta, probablemente debido a una desincronización en las jerarquías.



    Pero la solución a esto no es tan traumática, y es un ejercicio básico de recuperación de errores para cualquier sistema. Por una parte, en caso de una excepción, deberíamos crear una nueva sesión totalmente limpia. A continuación, y sólo en el caso de que estuviésemos trabajando con algún objeto en el momento de producirse la excepción, lo volveríamos a sincronizar con una operación update().




  3. La sesión crece indefinidamente. Nuevamente estamos ante un problema claro de las aplicaciones web. Las aplicaciones web, por diversas razones, suelen tener una carga de usuarios y una concurrencia mucho mayor que las aplicaciones de escritorio. Esto hace que lleguemos a situaciones en algunos casos de miles o decenas de miles de usuarios accediendo concurrentemente a un servidor. En estos casos, utilizar una caché por aplicación o por usuario, es realmente un error gravísimo. Hacer esto, ocasionaría que no parásemos de almacenar datos y datos en las diferentes cachés, y la memoria crecería sin parar hasta lanzar la siempre temible OutOfMemoryException. Incluso, aunque añadiésemos un listener a la HttpSession de modo que se liberase la sesión de un usuario al caducar su sesión web, tendríamos los mismos problemas si el número de accesos concurrente es lo suficientemente grande.



    Pero nuevamente
    estamos ante un escenario entre los muchos que existen. En aplicaciones con veinte o treinta usuarios, no hay unos riesgos tan grandes. Lo que si que es cierto es que aún así hay que tener cuidado. Hemos de ser conscientes de que si cargamos un histórico de una nómina con cientos de miles de registros, Hibernate guardará esos objetos en la sesión, y ahí se quedarán hasta que se cierre la sesión. Ante estos casos, lo mejor es coger y eliminar el objeto de la caché con una operación evict(), o simplemente limpiar la caché.



    Una norma fundamental cuando se utilizan cachés, es almacenar sólo lo que estamos seguros de que se reutilizará. En otro caso, lo mejor es no utilizar la caché. Cuando trabajéis con una sesión y la mantengáis activa en memoria, recordad bien esto. Recordad que es una caché de primer nivel, y eliminad de ella todo lo que creáis que no vale la pena que esté ahí. Por ejemplo, los datos de un empleado que se fue de la empresa hace diez años, puede que hagan falta una vez, pero es poco probable que estemos continuamente consultándolos.


Resumiendo. En mi opinión, estos dos modelos no son antipatrones, son simplemente alternativas. Alternativas que poseen unas ventajas y unas desventajas, y que debemos meditar bien su utilización en base a las características concretas de nuestro escenario de trabajo. Los dos siguientes modelos se denominan en Hibernate session-per-application-transaction y session-per-request (nuevamente una referencia a la naturaleza web de Hibernate), y ofrecen una escalabilidad mucho mayor que los dos que hemos visto hasta ahora, pero que sin embargo, no aprovechan tan bien la caché de primer nivel, a no ser que dispongamos de una caché de segundo nivel respaldándolos.


3.4 Sesión por unidad de trabajo



Figura 4: Cada unidad de trabajo utiliza una sesión.


Este modelo de gestión de la sesión es el que tiene una granularidad más fina. En una sesión por unidad de trabajo, cada vez que se ejecuta un método, se abre una nueva sesión de Hibernate. La sesión abierta, se cierra al finalizar la unidad de trabajo, momento en el que finaliza la transacción abierta. Idealmente, una unidad de trabajo constaría de varios accesos a base de datos, ya que en otro caso ya entraríamos dentro de lo que en la documentación de Hibernate denominan como el antipatrón session-per-operation; entaríamos en el caso de abrir y cerrar una sesión por cada acceso a base de datos, que obviamente no nos aportaría absolutamente ningún beneficio en cuanto a caché de objetos.


Es bastante claro que esta aproximación es la que ofrece una mayor escalabilidad y una mayor tolerancia a fallos. Por una parte, las conexiones se están recogiendo y devolviendo en intervalos cortos al pool de conexiones, por lo que es difícil que éste quede exhausto. Por otra parte, las transacciones con la base de datos son cortas, y esto disminuye bastante la contención de nuestro sistema, ya que las necesidades de sincronización, bloqueos, etc., disminuyen considerablemente. Por último, una excepción no provoca problemas tan graves como en los casos anteriores. Ahora, en caso de un fallo, eliminaríamos la sesión y recuperaríamos otra nueva. Una operación de rollback() sólo afectaría a la unidad de trabajo, lo que nos permite recuperarnos de manera más sencilla del fallo y provoca menores desincronizaciones.


La gran desventaja de esta aproximación es que no se aprovecha para nada la caché de primer nivel. Nuestra caché durará lo que duré una unidad de trabajo, es decir, unas cuantas operaciones. Y cuando termine dicha unidad, nada de lo que hayamos obtenido estará disponible de nuevo para un acceso rápido, teniendo que volver a acceder a base de datos para recuperar los datos anteriormente cargados. En una aplicación web, esto no es algo demasiado grave, ya que se asume ya un tiempo de latencia importante. Sin embargo, en aplicaciones de escritorio podemos tener problemas ya que los usuarios esperan una latencia mucho menor.

Esta solución es ideal para entornos que necesiten una gran escalabilidad y que no muevan grandes jerarquías de datos que merezcan permanecer en caché. En tal caso, estamos ante la elección correcta. Aplicaciones web, o de aplicaciones escritorio en las que cada unidad de trabajo suponga tan sólo unos cuantos accesos a base de datos, son candidatas perfectas a utilizar este patrón, ya que los beneficios son importantes. Otra gran ventaja de este modelo es que los problemas de actualización concurrente disminuyen enormemente. Si las unidades de trabajo son lo suficientemente cortas, será difícil que haya inconsistencias, e incluso las podríamos eliminar completamente con un esquema de sincronización, ya que ahora al trabajar con unidades de trabajo cortas, no causaríamos demasiada contención con los bloqueos.



Ahora bien, si tenemos aplicaciones que acceden continuamente a las mismas jerarquías de datos complejas, las cuáles requieran decenas, cientos o incluso miles de consultas, entonces debemos considerar seriamente el utilizar uno de los modelos anteriores o, implantar una caché de segundo nivel. En dichos casos, no podemos permitirnos tantos fallos en la caché de primer nivel. En el caso contrario los fallos en la caché de primer nivel son aceptables y se ven compensados por el aumento en la escalabilidad y la tolerancia a fallos.


3.5 Sesión por transacción de aplicación



Figura 5: Cada transacción de aplicación utiliza una sesión.


Este modelo es una combinación de los que hemos visto hasta ahora. En una sesión por transacción de aplicación, la sesión de Hibernate se mantiene abierta mientras dura la transacción. Entre las diferentes unidades de trabajo, se utilizan los métodos disconnect() y reconnect(), para que las conexiones vuelvan al pool y mantener la escalabilidad. Una vez que termina la transacción de aplicación, se cierra definitivamente la sesión. Se trata de un modelo claramente orientado a un entorno web ( raramente se encuentran este tipo de transacciones en las aplicaciones de escritorio, salvo en los típicos wizards ) , donde a menudo muchas operaciones requieren secuencias de múltiples pasos, formularios, sistemas de compra online, etc. Un ejemplo podría ser el realizar una transacción bancaria de una cuenta de un banco a otra, en donde primero se seleccionaría la cuenta órigen, después la cantidad, después el destino, y finalmente se realizáse la transacción bancaria. Esto se representaría como una transacción de aplicación que constaría de cuatro unidades de trabajo. Mientras durase el proceso, los diferentes objetos como la cuenta, los importes, los clientes, etc., estarían en la sesión.


Al ser una mezcla de varios modelos, esta aproximación aprovecha muchas de sus ventajas. Por una parte, la escalabilidad es mayor que en las dos primeras aproximaciones que tratamos, ya que la sesión permanece únicamente abierta durante el tiempo que dura la transacción de aplicación. Por otra parte, lo más normal es que las diferentes unidades de trabajo de la transacción de aplicación estén relacionadas y manejen los mismos objetos. Como esos objetos están en la caché de primer nivel mientras la transacción de aplicación se ejecuta, estamos aprovechando mucho mejor la caché de lo que se hacía en el modelo de sesión por unidad de trabajo.


Pero este modelo de mezcla, presenta también algunas desventajas. Si nuestras transacciones de aplicación son cortas, volveremos a estar desaprovechando nuestra caché de primer nivel. Por otra parte, si nuestras transacciones son muy largas, podemos estar incurriendo en el problema de las actualizaciones concurrentes que tenían los primeros modelos. Esto hace que, si alguno de estos problemas es realmente muy importante, puede ser mejor el irnos directamente a alguno de los otros modelos para eliminar el problema, y evitar trabajar de este modo híbrido.


 

4. La caché de segundo nivel


Como ya hemos visto, el problema de las actualizaciones concurrentes supone un grave quebradero de cabeza para cualquier sistema de caché. Sea cual sea el modelo que uilicemos, siempre tendremos problemas con las actualizaciones concurrentes, en mayor o menor medida. La gran ventaja de una caché de segundo nivel, es que nos ayuda a mitigar estos problemas, al tiempo que alivia de la responsabilidad de almacenar datos temporales, a la caché de primer nivel; responsabilidad que, por otra parte, era la causa de que los modelos de sesión por transacción de aplicación y de sesión por unidad de trabajo pudiesen enlentecer nuestras aplicaciones.


Una caché de segundo nivel se acoplará a la sesión de Hibernate absorbiendo todos los fallos que se produzcan en ésta. Como ya he comentado, la gran ventaja de utilizar una caché de segundo nivel es que desaparecen los problemas de actualizaciones concurrentes entre sesiones, es decir, el primer problema que se nos planteaba hace ya unos cuantos apartados. La caché de segundo nivel se situa al mismo nivel que el objeto SessionFactory de Hibernate, recogiendo y coordinando los objetos con los que trabajan las diferentes sesiones.


La recomendación general con una caché de segundo nivel es utilizar una aproximación de sesión por transacción de aplicación o de sesión por unidad de trabajo, según nos convenga. Con una caché de segundo nivel, el grave problema de no aprovechamiento de la caché de primer nivel del que adelolecían estos modelos, desaparece parcialmente. Ahora, si un objeto no se encuentra en la caché de primer nivel, Hibernate tratará de obtenerlo de la caché de segundo nivel, de modo que en caso de encontrarse ahí nos habremos ahorrado un hit en la base de datos. Cerrar las sesiones pues, ya no es un problema tan grave, ya que aunque tengamos que realizar cientos y cientos de consultas recurrentes, los datos estarán en la caché de segundo nivel, y la perdida de rendimiento ya no será tan grande.



Figura 6: La caché de segundo nivel absorbe la mayoría de fallos de las cachés de primer nivel


Después del sufrimiento que parecía que nos deparaba el uso de la caché de primer nivel, al no solucionarnos apenas problemas, aparentemente la caché de segundo nivel es la panacea. Aún así, es muy importante tener en cuenta que es necesario tener una serie de precauciones con esta caché. Por una parte está el tema de las cachés distribuidas de las que hablaré posterioremente. Por otra parte, hemos de tener siempre en mente que no todos nuestros objetos se beneficiarán del uso de una caché de segundo nivel. Normalmente, para cualquier tipo de caché se recomienda almacenar en su interior datos que no cambien con demasiada frecuencia. Asimismo, determinados datos históricos puede que no nos valga la pena almacenarlos en la caché por el espacio que puedan llegar a consumir. Por ejemplo, ¿tendría sentido almacenar un listado de artículos del año 1991 con cientos de miles de registros en la caché de segundo nivel? Probablemente no. Afortunadamente, los sistemas de caché de segundo nivel suelen ofrecer mecanismos de configuración que permiten filtrar la información que se almacenará en la caché, su caducidad, el límite máximo de memoria ocupada, etc.


4.1 La caché en hibernate y estrategias de concurrencia


Hibernate permite habilitar individualmente la caché para cada una de nuestras entidades. De este modo, podemos decidir que clases se beneficiarán del uso de una caché, ya que como explicaba anteriormente, puede que no todas las clases de nuestro sistema se beneficien. Además de esto, al habilitar la caché es necesario establecer la estrategia de concurrencia que Hibernate utilizará para sincronizar la caché de primer nivel con la caché de segundo nivel, y ésta última con la base de datos. Hay cuatro estrategias de concurrencia predefinidas. A continuación aparecen listadas por órden de restricciones en términos de aislamiento transaccional.



  • transactional: Garantiza un nivel de aislamiento hasta repeatable read, si se necesita. Es el nivel más estricto. Se recomienda su uso cuando no podamos permitirnos datos que queden desfasados. Esta estrategia sólo se puede utilizar en clusters, es decir, con cachés distribuidas.




  • read-write: Mantiene un aislamiento hasta el nivel de commited, utilizando un sistema de marcas de tiempo (timestamps). El caso de uso recomendable es el mismo que para la estrategia transactional pero con la diferencia de que esta estrategia no se puede utilizar en clusters.




  • nonstrict read-write: No ofrece ninguna garantía de consistencia entre la caché y la base de datos. Para sincronizar los objetos de la caché con la base de datos se utilizan timeouts, de modo que cuando caduca el timeout se recargan los datos. Con esta estrategia pués, tenemos un intervalo en el cual tenemos el riesgo de obtener objetos desfasados. Cuando Hibernate realiza una operación de flush() en una sesión, se invalidan los objetos de la caché de segundo nivel. Aún así, esta es una operación asíncrona y no tenemos nunca garantías de que otro usuario no pueda leer datos erróneos. A pesar de todo esto, esta estrategia es ideal para almacenar datos que no sean demasiado críticos.




  • read-only: Es la estrategia de concurrencia menos estricta. Ideal para datos que nunca cambian.


A medida que bajamos en esta lista, el rendimiento aumenta, ya que disminuye la necesidad de sincronización. Es posible definir una estrategia de concurrencia personalizada implementando la interfaz net.sf.hibernate.cache.CacheConcurrencyStrategy, pero la documentación advierte de que se trata de una tarea complicada y sólo apta para extraños casos de optimización.


En Hibernate, no sólo hay que marcar una clase como almacenable en caché. Además, todas las colecciones de datos que deseemos guardar las tendremos que marcar, seleccionando también para ellas una estrategia de concurrencia. No todas las colecciones soportan la etiqueta <cache>, sino que esta etiqueta es aplicable sólo a los elementos set, map, bag, idbag, array, primitive-array y list. El siguiente ejemplo muestra una clase y su definición de caché de segundo nivel:



<class name="org.jlibrary.core.entities.Repository"
table="REPOSITORIES">

<cache usage="read-write"/>

<id name="id" column="id" type="string">
<generator class="uuid.hex" />
</id>

<property name="name" type="string" />
<property name="description" type="string" />
<property name="path" type="string" />
<property name="creator" type="string" />

<many-to-one cascade="delete"
name="root"
column="root"
class="org.jlibrary.core.entities.Directory"/>

<set name="categories"
inverse="true"
cascade="delete">

<cache usage="read-write"/>

<key column="repository"/>
<one-to-many class="org.jlibrary.core.entities.Category"/>
</set>
</class>



4.2 Proveedores de caché

Después de decidir que clases vamos a guardar en la caché segundo nivel y como las vamos a guardar, nos falta decidir cual va a ser nuestro proveedor de caché y como se va a comportar. Hibernate no incluye ningún proveedor de caché de segundo nivel, pero si que soporta varios. Ni que decir tiene, que un proveedor de caché no es más que una librería donde se implementan los diferentes algoritmos de la caché de segundo nivel. Existen varios proyectos Open Source que nos ofrecen diferentes proveedores de caché de segundo nivel, y que Hibernate soporta. La tabla siguiente muestra los proveedores soportados y su descripción.




























Proveedor
Descripción
Estrategias de concurrencia soportadas
EHCacheCaché simple. No posible en cluster. Caché en memoria o disco. Soporta la caché de resultados. read-only, nonstrict-read-write, read-write
OSCacheCaché en memoria o disco. No cluster. Muy configurable. read-only, nonstrict-read-write, read-write
SwarmCacheSoporte de cluster gracias a JGroups. No soporta la caché de consultas read-only, nonstrict-read-write
JBossCacheSoporte de cluster basado en JGroups. Soporte de caché de consultas. read-only, transactional


Existe la posibilidad de adaptar otros productos de caché simplemente implementando la interfaz net.sf.hibernate.cache.CacheProvider.


Una vez escogido el producto que más nos convenga, habrá que configurarlo para que funcione en Hibernate. Como mínimo, tendremos que modificar el fichero hibernate.properties para avisar a Hibernate para que utilice el proveedor de caché escogido:


      hibernate.cache.provider_class=net.sf.ehcache.hibernate.Provider      

Además, según el proveedor de caché tendremos que realizar alguna otra tarea. Por ejemplo, con EHCache, es necesario colocar el fichero ehcache.xml en el classpath de la aplicación.


Una vez escogido y configurado el proveedor, deberíamos establecer las políticas de caché. Esta tarea ya depende de cada uno de los proveedores. Uno de los proveedores más sencillos es EHCache, que además ofrece un rendimiento bastante aceptable, así que será el que utilice para este ejemplo.


EHCache se configura mediante el fichero ehcache.xml. Este fichero de configuración es muy simple. Ahí se define:



  • El directorio de disco donde se guardará los elementos de la caché de segundo nivel que no cojan en memoria.

  • Las políticas por defecto para la caché de segundo nivel.

  • Cada una de las políticas de caché personalizadas para las clases que deseemos.


EHCache nos permite configurar diferentes parámetros dentro de las políticas de caché. Para cada política de caché se requieren los siguientes atributos:



  • name: Nombre de la caché. Ha de ser único.

  • maxElementsInMemory: Número máximo de objetos que se crearán en memoria.

  • eternal: Marca que los elementos nunca expirarán de la caché. Serán eternos y nunca se actualizarán. Ideal para datos inmutables.

  • overflowToDisk: Marca que los objetos se guarden en disco si se supera el límite de memoria establecido.


Los siguientes atributos son opcionales:



  • timeToIdleSeconds: Es el tiempo de inactividad permisible para los objetos de una clase. Si un objeto sobrepasa ese tiempo sin volver a activarse, se expulsará de la caché.

  • timeToLiveSeconds: Marca el tiempo de vida de un objeto. En el momento en que se sobrepase ese tiempo, el objeto se eliminará de la caché, independientemente de cuando se haya usado.

  • diskPersistent: Establece si los objetos se almacenarán a disco. Esto permite mantener el estado de la caché de segundo nivel cuando se apaga la JVM, es decir cuando se cae la máquina o se para el servidor o la aplicación.

  • diskExpiryThreadIntervalSeconds: Es el número de segundos tras el que se ejecuta la tarea de comprobación de si los elementos almacenados en disco han expirado.


Este rango de opciones nos ofrece una amplia posibilidad de variantes. Un ejemplo de configuración sería el siguiente:


<ehcache>

<!-- Aquí podemos utilizar una URL de disco o las propiedades user.home, user.dir o java.io.tmp.dir -->
<diskStore path="java.io.tmpdir"/>

<defaultCache

maxElementsInMemory="10000"
eternal="false"
timeToIdleSeconds="120"
timeToLiveSeconds="120"
overflowToDisk="true"
diskPersistent="false"
diskExpiryThreadIntervalSeconds="120"/>

<cache name="org.jlibrary.core.entities.User"

maxElementsInMemory="1000"
eternal="false"
overflowToDisk="true"
timeToIdleSeconds="300"
timeToLiveSeconds="1200"/>

<cache name="org.jlibrary.core.entities.HistoryRegistry"
maxElementsInMemory="100"
eternal="true"
overflowToDisk="true"
timeToIdleSeconds="180"
timeToLiveSeconds="300"/>

</ehcache>


5. Caches distribuidas


Durante todo este artículo, se ha dejado apartado el tema de las cachés distribuidas para el final. Una aplicación empesarial con decenas de miles de usuarios, es posible que necesite ejecutarse en un entorno distribuido, es decir, en un cluster. Llegado a ese momento, el único elemento de Hibernate que necesita configurarse es la caché de segundo nivel.


Hay dos modos de montar una caché de segundo nivel de Hibernate en un cluster. El más sencillo, sin ninguna duda, es colocar en cada nodo del cluster una instancia del proveedor de caché, por ejemplo de EHCache, y confiar en los timeout para la sincronización de los proveedores de caché de los diferentes nodos. Este sistema es muy simple, y no presenta retardos de sincronización entre los nodos, ya que no existe sincronización alguna. El segundo modo, es instalar un proveedor de caché más avanzado que soporte la sincronización de las cachés de los diferentes nodos. El proveedor recomendado por Hibernate para esta tarea es JBossCache, un provedor totalmente transaccional basado en la librería de multicasting, JGroups.




                 



Figura 7: Diferentes esquemas de trabajo de una caché distribuida en Hibernate.

Como en el caso de EHCache, para activar el proveedor de caché es necesario añadir una línea al fichero de configuración de hibernate:


      hibernate.cache.provider_class=net.sf.hibernate.cache.TreeCacheProvider

JBossCache se configura utilizando el fichero treecache.xml que ha de estar presente en cada uno de los nodos del cluster. Este fichero tiene diferentes partes, y es bastante más complejo que el de EHCache ya que necesita especificar la configuración del cluster, las políticas de comunicación entre los nodos, etc. El siguiente ejemplo, extraido del libro Hibernate in Action, muestra como sería la configuración de JBossCache en cuanto a la política de caché y sus regiones:


<server>

................

<attribute name="CacheMode">REPL_SYNC</attribute>
................


<attribute name="EvictionPolicyConfig">
<config>
<attribute name="wakeUpIntervalSeconds">5</attribute>
<region name="/_default_">
<attribute name="maxNodes">5000</attribute>
<attribute name="timeToIdleSeconds">1000</attribute>
</region>
<region name="/org/hibernate/auction/model/Category">
<attribute name="maxNodes">500</attribute>
<attribute name="timeToIdleSeconds">5000</attribute>
</region>
<region name="/org/hibernate/auction/model/Bid">
<attribute name="maxNodes">5000</attribute>
<attribute name="timeToIdleSeconds">1800</attribute>
</region>
</config>
</attribute>
................
</server>



Entrar en las interioridades de JBossCache queda ya fuera del ámbito de este artículo. Para obtener más información sobre el producto, podéis acceder a su página web.


6. La caché de consultas


Para finalizar con este artículo sobre técnicas de caché con Hibernate, voy a hablar un poco sobre la caché de consultas. La caché de consultas de Hibernate permite almacenar las consultas realizadas recientemente, de modo que si se vuelven a realizar posteriormente, se recuperen los resultados de un modo mucho más ágil. La caché de consultas es recomendable tan sólo para aplicaciones que hagan realmente muchas consultas y, que además estas consultas sea previsible que sean repetitivas. Si las operaciones de actualización, eliminación e inserción de entidades, son también muy frecuentes, entonces puede que no valga la pena utilizar una caché de consultas, ya que Hibernate invalida automáticamente en las sesiones todas las entidades que participen en dichas operaciones.


La caché de consultas de Hibernate no almacena directamente los objetos, si no que tan sólo almacena sus claves. De este modo, se reduce considerablemente el consumo de memoria que de otra manera sería excesivo. Esto tiene como consecuencia el que aunque hayamos hecho una consulta y ésta se haya almacenado en la caché de consultas, si repetimos la misma consulta varios minutos después, es posible que aunque la consulta siga en la caché, los objetos ya no se encuentren ni en la caché de primer nivel, ni en la caché de segundo nivel, ya que éstas dos últimas tienen la responsabilidad de gestionar sus entidades, independientemente de lo que haya en la caché de consultas. Como consecuencia de esto extraemos que aunque una consulta esté en la caché de consultas, ello no tiene por que evitar obligatoriamente que tengamos que acceder a la base de datos para recuperar los resultados.


Para habilitar la caché de segundo nivel es necesario introducir en el fichero de configuración de Hibernate la siguiente línea:


      hibernate.cache.use_query_cache=true

Una vez habilitada la caché de consultas, la única forma de aprovecharla es utilizar la interfaz Query, ya que cualquier otra forma de realizar una consulta es ignorada por Hibernate en cuanto al almacenamiento en la caché de consultas. Por ejemplo:


      Query queryUsuarios = session.createQuery("from User u where u.rol= :rol");
queryUsuarios.setString("rol","Administrador");
queryUsuarios.setCacheable(true);


Nuevamente, y para finalizar, he de recomendar utilizar la caché de consutas sólo cuando vayamos a realizar frecuentemente repetidos y similares accesos. El código anterior, no tendría ningún sentido si no se consultasen repetidas veces los usuarios que pertenecen al rol Administrador, porque si no se van a volver a consultar, ¿para qué hemos habilitado la caché de consultas y estamos almacenando esta última?



7. Para finalizar


Espero sinceramente que este artículo haya servido para demostrar que la gestión de las cachés en una aplicación empresarial no es una tarea sencilla. No debemos limitarnos a aplicar un producto sin más, o a confiar directamente en un framework, sin detenernos a analizar las interioridades de su funcionamiento. En el caso de Hibernate, espero que haya quedado claro que tener el conocimiento de la gestión que realiza sobre los diferentes niveles de caché, nos puede ayudar enormemente a mejorar el rendimiento de nuestras aplicaciones.


Los sistemas de caché pueden aumentar considerablemente el rendimiento de servidores y aplicaciones. Aún así, como hemos visto, no están exentos de problemas. La actualización concurrente, las cachés distribuidas, las políticas de sincronización, etc., son retos que tendremos que afrontar para conseguir el máximo rendimiento en nuestro sistema, pero una vez superados, tendremos grandes garantías de éxito.


Como hemos visto, en Hibernate, confiar ciegamente en la caché de primer nivel nos puede traer muchos problemas de concurrencia y rendimiento. Un uso adecuado de la caché de segundo nivel, puede solucionar todos estos problemas, aunque no debemos olvidar que en ocasionnes, cuando tratemos con aplicaciones simples, quizás no sea necesario llegar hasta este punto de detalle. Por otra parte, la caché de consultas de Hibernate, muchas veces desconocida, nos puede aportar un punto extra en el rendimiento de nuestras aplicaciones y servidores; pero nuevamente, no debemos olvidar tomar precauciones.


8. Para aprender más


Introducción a Hibernate, Francesc Rosés, /articles.article.action?id=9


Manual de Hibernate, Héctor Suárez González, /articles.article.action?id=82


Guía del autoestopista a Hibernate, Aitor García traduciendo a Glen Smith, /articles.article.action?id=80


Hibernate in Action, Gaving King y Christian Bauer, http://www.manning.com/bauer


Documentación oficial de Hibernate, http://www.hibernate.org/5.html


Sitio web de EHCache, http://ehcache.sourceforge.net


Sitio web de JBossCache, http://jboss.org/products/jbosscache




Acerca del autor


Martín Pérez Mariñán es Ingeniero Técnico en Informática de Sistemas por la Universidad de A Coruña. Además, es Sun Certified Java Programmer, Sun Certified Java Developer y Sun Certified Business Component Developer. Ahora mismo trabaja como Ingeniero de Software para la empresa DINSA Soluciones dentro del Complejo Hospitalario Universitario Juan Canalejo de A Coruña. Entre otros proyectos, durante el último año ha estado desarrollando un servidor de repositorio de colaboración utilizado en jLibrary y basado en Hibernate+EHCache.



 


 


Reader Comments

There are no comments for this journal entry. To create a new comment, use the form below.
Comentarios deshabilitados
Comentarios deshabilitados en esta noticia.