Cachés, concurrencia e Hibernate
Cachés, concurrencia e Hibernate | Martín Pérez Mariñán | |||||||||||||||
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. | ||||||||||||||||
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:
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. 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.
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 ):
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. 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.
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:
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.
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:
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:
Los siguientes atributos son opcionales:
Este rango de opciones nos ofrece una amplia posibilidad de variantes. Un ejemplo de configuración sería el siguiente:
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:
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"); 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? 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