sábado, 16 de octubre de 2010

Control del nivel de aislamiento transaccional en JPA

La única ventaja de jugar con fuego es que aprende uno a no quemarse.

- Oscar Wilde (1854-1900)

Breve introducción al aislamiento

El aislamiento es una de las propiedades fundamentales ACID que definen las transacciones como tales en sistemas de gestión de bases de datos. El aislamiento define cómo y cuándo se ven los cambios realizados por una determinada operación por otras operaciones concurrentes.

El estándar ANSI/ISO SQL define cuatro niveles de aislamiento transaccional en función de tres hechos problemáticos que deben ser tenidos en cuenta entre transacciones concurrentes. Estos hechos no deseados son:

lectura "sucia"
Una transacción lee datos escritos por una transacción no confirmada [1]. Es decir, se leen datos temporales que no existirán finalmente porque la transacción que los creó se canceló finalmente tras la lectura.
lectura no repetible
Una transacción vuelve a leer datos que previamente había leído y encuentra que han sido modificados por una transacción confirmada.
lectura "fantasma"
Una transacción vuelve a ejecutar una consulta, devolviendo un conjunto de filas que satisfacen una condición de búsqueda y encuentra que otras filas que satisfacen la condición han sido insertadas o borradas por otra transacción cursada. La inconsistencia ocurre cuando una segunda transacción accede repetidas veces a una fila y lee datos diferentes cada vez.

Como comentaba, los niveles de aislamiento se definen en función de los efectos no deseados que evita. Así, el estándar ANSI/SQL define los siguientes cuatro niveles de aislamiento, de menor aislamiento a mayor:


lectura sucia
(dirty reads)
lectura no repetible / doble actualización
(non-repeatable reads / lost-update)
lectura fantasma
(phantom reads)
lectura no confirmada
READ_UNCOMMITTED
Se produce efecto
Se produce efecto
Se produce efecto
lectura confirmada
READ_COMMITTED
No se produce efecto
Se produce efecto
Se produce efecto
Iectura repetible
REPEATABLE_READ
No se produce efecto
No se produce efecto
Se produce efecto
secuenciable
SERIALIZABLE
No se produce efecto
No se produce efecto
No se produce efecto

Las transacciones tienen aislamiento cuando no interfieren entre sí, especialmente aquellas que aún no han concluído y están incompletas. El nivel de aislamiento es inversamente proporcional al rendimiento final en la medida en que que cuanto mayor es el aislamiento mayores son los recursos del sistema utilizados para garantizarlo. Esto se hace patente con un alto grado de concurrencia: a un nivel de aislamiento mayor, menor rendimiento.

La mayoría de las bases de datos usan un el nivel de aislamiento READ_COMMITED por defecto. Para accesos clásicos a la base de datos usando un patrón DAO y gestionando las conexiones directamente (sin usar JPA) esto es prácticamente lo único que necesitamos, ya que podemos seleccionar un nivel de aislamiento mayor en caso que lo necesitemos en la conexión usando setTransactionIsolation().


Con JPA las cosas son bastante diferentes

Hay que tener en cuenta que las implementaciones de JPA incluyen una caché de objetos que introduce elementos nuevos y desconcertantes frente las aplicaciones clásicas.

Otras aplicaciones acceden a los datos
Nuestra aplicación puede convivir con otras aplicaciones JPA que no usen la misma Unidad o Contexto de Persistencia (otro módulo, otro servidor) o que accedan a los datos directamente sin un framework JPA de por medio. Esto causará lecturas no repetibles, es decir, que ambas aplicaciones sobreescriban los mismos datos simultáneamente. En definitiva, el framework no realiza un refresco automático de un objeto antes de realizar un merge() (si lo hiciese, sería costoso ya que ejecutaría una sentencia SELECT anterior a cualquier otra). El tema del "refresco" de la caché es un tema aparte que merecerá un articulo aparte, en su momento.

Objetos caducados
Incluso aunque todas las aplicaciones usen el mismo contexto de persistencia y la misma caché de objetos, es frecuente que dos threads modifiquen el mismo objeto (incluso aunque sean propiedades diferentes) y realicen una transacción simultánea sobreescribiendo los cambios de otra, con lo que podemos tener que "se pierden" cambios de una propiedad. Por ejemplo:
  1. La transacción A lee la fila x.
  2. La transacción B lee la misma fila x.
  3. La transacción A escribe la fila x.
  4. La transacción B escribe la fila x (y sobreescribe los cambios realizados por A).
  5. Ambos confirman la transacción con éxito.
Este efecto se conoce típicamente como actualización perdida (Lost update). Con un nivel de aislamiento SERIALIZABLE esto no ocurriría, ya que la transacción B se quedaría bloqueada esperando hasta que la transacción A realizara la confirmación (commit) o no esperaría y simplemente fallaría (dependiendo de si se emplea un NO_WAIT en el tipo de lock). Sin embargo, en una aplicación web típica, esto seguiría generando un conflicto aún con un nivel SERIALIZABLE, ya que una aplicación web leería (y presentaría) primero los datos en una transacción y los actualizaría en otra).

Otro casos parecidos a éste y con consecuencias similares son:
  • un thread borra un objeto (delete()) y otro realiza un persist() sobre él. En ambos casos tendremos información inconsistente: o no se ha borrado, y tenemos dos, o no se ha grabado y no tenemos ninguno.
  • un objeto tiene relaciones con otros objetos hijos. Las modificaciones en las listas de sus hijos relacionados puede causar igualmente información inconsistente con respecto a los hijos.

Mecanismos de control de concurrencia
Para evitar estos problemas existen los mecanismos de control de concurrencia, que se encargan de mantener el aislamiento transaccional. Existen múltiples métodos de control de concurrencia que se pueden agrupar en tres categorías:
  • Optimistic. No realiza bloqueos, cancelando la confirmación y revertiendo los cambios (rollback) en caso de que se detecte un conflicto con otras transacciones. Como su propio nombre indica, asume que las transacciones pueden avanzar y realiza las comprobaciones de modificación al final, justo antes de realizar la confirmación de la transacción, asegurándose que los datos no han cambiado desde que se leyeron. Previene "lost updates".
  • Pessimistic. Se adquieren bloqueos sobre los objetos que se van a editar (típicamente las implementaciones lo realizan meidante sentencias SELECT ... FOR UPDATE). Este enfoque equivale a un nivel de aislamiento SERIALIZABLE.
  • Semi-optimistic. Es una mezcla de ambos realizando bloqueos sólo en determinadas situaciones.

Mecanismos de control de concurrencia en JPA

Por defecto, las implementaciones (persistence provider) de JPA asumen que la aplicación es responsable de la consistencia de datos y, por tanto, no realizan ningún comportamiento por defecto relativo a bloqueos. Como he comentado, trabajando directamente con la conexión de base de datos, podemos establecer un nivel de aislamiento y controlar la concurrencia, pero en JPA no manejamos directamente la conexión, sino que trabajamos con un gestor de entidades (EntityManager). Entonces, ¿cómo hacemos para gestionar la concurrencia si no podemos establecer un nivel de aislamiento?. Y aunque pudiéramos, ¿como resolvemos los problemas añadidos inherentes a JPA como los objetos caducados?. Vamos a ello.

Optimistic locking
JPA soporta optimistic locking mediante un campo versionado de bloqueo, definido por la anotación @Version. Dicho campo se actualiza automáticamente por la implementación JPA en cada actualización y debe ser conservado tal cual por la aplicación. En el momento de la realización del merge(), si se detecta un bloqueo o cambio, se lanza una excepción OptimisticLockException.
@Entity
public class Debt {
    @Id
    private long id;
    @Version
    private long version;
    //...
}

Bloqueos específicos de lectura y escritura
Algunas veces es deseable bloquear algo que no vas a cambiar. Normalmente se hace cuando vas a realizar un cambio sobre un objeto que se base en el estado de otro, y deseas asegurar que éste último no cambia mientras dura la transacción. JPA soporta bloqueos de lectura y escritura a través del método EntityManager.lock(entity, lockMode). El argumento lockMode puede ser READ o WRITE.

Si una transacción llama a lock(entity,LockModeType.READ) sobre un objeto versionado nos aseguraremos de que no se realizará ninguna lectura sucia ni lectura no repetible. Es decir, se asegura de que el objeto no ha cambiado antes de hacer el commit. En caso contrario, se lanzará una OptimisticLockException. Por ejemplo, en un método evaluamos en una condición un atributo de un
objeto y, en función del valor, realizamos una modificación de otro objeto.
    ut.begin();
    Debt d = em.find(Debt.class,5);
    em.lock(d,LockModeType.READ);
    if ( d.getAmount() < 10 ) 
        throw new MinAmountExceedException();
    ut.commit();

Si una transacción llama a lock(entity,LockModeType.WRITE) nos aseguraremos de que no se realizará ninguna lectura sucia ni lectura no repetible, ni otro objeto está realizando un lock. En caso contrario, se lanzará una OptimisticLockException. El bloqueo WRITE puede usarse también para proporcionar bloqueos a nivel de objeto y sus objetos dependientes, es decir, bloquear (aunque deberíamos decir "detectar") cambios en relaciones, de forma que un cambio en una lista de objetos hijos fuerce el incremento del número de versión del objeto padre.


En definitiva, el bloqueo READ comprueba el optimistic version field (campo de versionado), y el bloqueo WRITE lo comprueba y lo incrementa. Este tipo de bloqueos es justo lo que se obtiene con un nivel de aislamiento serializable pero de forma optimista, es decir, sin riesgos de deadlock o bloqueos abiertos, ya que estamos hablando de comprobaciones" no de bloqueos efectivos como tales.


Pessimistic locking
Pessimistic locking significa adquirir un bloqueo sobre el objeto antes de comenzar a editarlo y equivale a un nivel de aislamiento SERIALIZABLE. Es realmente bloquear, no es una simple comprobación como en el bloqueo optimista. Se implementa típicamente con una sentencia SELECT ... FOR UPDATE. El bloqueo pesimista no está incluido en JPA 1.0, aunque algunas implementaciones sí lo hacen. Si usamos JPA 1.0 (por ejemplo usando la implementación por defecto de Glassfish 2.x), las alternativas para realizar este bloqueo serían las siguientes:
Por ejemplo:
@Entity
@Table(name="mailing_package")
@NamedQueries( {
    @NamedQuery(name = "MailingPackage.findByIdLocked",
        query = "SELECT s FROM MailingPackage s WHERE s.id = :id",
        hints={ @QueryHint(name = "toplink.pessimistic-lock", value = "LockNoWait")} })
public class MailingPackage implements Serializable {
...
}

El bloqueo pesimista hay que manejarlo con cuidado porque puede causar problemas de concurrencia, rendimiento o bloqueos de la aplicación por deadlocks. Típicamente no es deseable para aplicaciones web interactivas, ya que requiere mantener la transacción (y por tanto la conexión) activa durante la edición. El uso típico es cuando se quiere que la edición tendrá éxito en un momento que sabemos que la transacción durará lo menos posible.


Bloqueos en JPA 2.0
JPA 2.0 añade soporte específico para bloqueo pesimista además de otras opciones de bloqueo en el propio API. Un bloqueo se puede adquirir usando adquiere usando el método EntityManager.lock(entity, lockMode), pasando un argumento LockModeType a los nuevos métodos sobrecargados find() y refresh(), o estableciendo un lockMode en una Query ( setLockMode() ) o NamedQuery (lockMode).

JPA 2.0 amplia/redefine los modos de bloqueo de JPA 1.0 en el enum LockModeType:
  • OPTIMISTIC: Es el READ de JPA 1.0
  • OPTIMISTIC_FORCE_INCREMENT: Es el WRITE de JPA 1.0
  • PESSIMISTIC_READ: Bloquea y evita que otra transacción adquiera un bloqueo PESSIMISTIC_WRITE.
  • PESSIMISTIC_WRITE: Bloquea y evita que otra transacción adquiera bloqueos PESSIMISTIC_READ o PESSIMISTIC_WRITE.
  • PESSIMISTIC_FORCE_INCREMENT: es una suma de PESSIMISTIC_WRITE y OPTIMISTIC_FORCE_INCREMENT.
  • NONE: No hay bloqueo ni comprobación. Equivalente a omitir cualquier lockMode.
Adicionalmente, JPA 2.0 añade dos hits estándar que pueden pasarse a qualquier Query o NamedQuery y a qualquier operación find(), lock() o refresh():
  • "javax.persistence.lock.timeout": Número de milisegundos a esperar la liberación del bloqueo antes de lanzar una PessimisticLockException.
  • "javax.persistence.lock.scope": Los alcances válidos se definen en PessimisticLockScope (NORMAL or EXTENDED).  EXTENDED bloqueará adicionalmente las tablas relacionadas.

Conclusiones

Con el nivel por defecto de la base de datos en READ_COMMITED y usando un bloqueo optimista para detectar lost-updates, sólo necesitaríamos realizar un bloqueo pesimista en muy pocos casos. No obstante, estos casos existen: casos como un objeto que deba tener una numeración "sin huecos" (como el clásico ejemplo de los números de factura) o un repartidor de objetos que no deba dar el mismo objeto a dos threads para su proceso, podría requierir un bloqueo pesimista o secuenciable que impida de dos threads lean simultáneamente el mismo valor y lo incrementen.

Recomiendo echar un vistazo a las referencias al final del artículo para profundizar más en el tema. Aprovecho la ocasión para felicitar aquí a los autores de "Java Persistence", de en.wikibooks.org, que han hecho un trabajo impecable el cual me ha sido de enorme ayuda para comprender el complejo mundo de JPA.


NOTAS:
[1] Creo que la traducción de "commited" como "cursado" o "confirmado" es más correcta en este contexto.


Referencias y más información:

12 comentarios:

  1. Me surge una duda: por lo que dices en el artículo, tanto con "optimistic locking" como con "pessimistic locking" podemos conseguir sincronización SERIALIZABLE, pero... ¿de verdad nos protegerían de phantom reads?

    Si hacemos dos búsquedas en base de datos, y entre ambas se ha colado otra transacción que crea nuevos registros, entiendo que la segunda búsqueda devolvería más resultados.

    ResponderEliminar
    Respuestas
    1. Hola Aritz:
      Primero una aclaración para evitar malentendidos: los niveles de aislamiento y los mecanismos de control de concurrencia son mecanismos totalmente diferentes. El "pessimistic locking" equivale a una transacción SERIALIZABLE en la medida que "podría evitar" lecturas fantasmas, pero no "se consigue" dicho nivel de aislamiento. Con los mecanismos de control de concurrencia no hay bloqueos como tal en la base de datos, sino cerrojos a nivel de aplicación en la capa JPA.

      Con respecto a tu pregunta, en efecto, una transacción SERIALIZABLE evita lecturas fantasmas bloqueando el rango. En el caso que comentas, la otra transacción que crea nuevos registros no puede "colarse": deberá esperar a que termine la transacción que realiza las dos búsquedas. Por eso es fundamental establecer un timeout (como comenté en otro artículo) a las transacciones (especialmente a las serializables) para evitar deadlocks. Con JPA, esto sólo se podría conseguir realizando "Cascaded Locking" sobre la entidad padre (en el caso que dichas búsquedas sean hijas de una entidad padre) y se realiza usando un "write lock" sobre dicha entidad padre. Si no es así, deberías realizar una "Native Query" SERIALIZABLE.

      Espero que esto solvente tu duda.

      Eliminar
  2. Buen articulo para trabajar con transacciones.

    ResponderEliminar
  3. Fenomenal artículo, enhorabuena!

    Lo que yo no termino de entender es una cosa: Podemos controlar cuándo comienza y cuándo termina una transacción a nivel de aplicación, pero, ¿es imposible desde JPA invocar un begin, commit o rollback de una transacción de la Base de Datos subyacente?

    ResponderEliminar
    Respuestas
    1. Por supuesto. Puedes tomar el control:
      1) Si es una transacción JPA local: tomando el contexto transaccional del EntityManager con EntityManager.getTransaction().
      2) Si es una transacción JPA JTA, usando UserTransaction si la transacción es manejada por el bean o EntityManager.joinTransaction() si la transacción es manejada por el contenedor.

      Como comento en el artículo el "libro" online Java Persistence es excelente. Dedica una parte específica a tu pregunta en http://en.wikibooks.org/wiki/Java_Persistence/Transactions#JTA_Transactions.

      Eliminar
    2. Muchas gracias por tu respuesta, y por la recomendación del libro.

      En ese caso, no termino de entender lo siguiente: si podemos manejar las transacciones a nivel de Base de Datos, de qué nos sirve manejarlas también a nivel de Bean? De hecho, de esa manera se acabaría el problema que tú comentas de otras aplicaciones accediendo también a los datos por ejemplo, no?

      A lo mejor se me está escapando algo. Mejor dicho, SEGURO que se me escapa algo, pero si puedo manejar el nivel de aislamiento de la BD y la transacción en la BD desde el programa, no termino de ver la utilidad de establecer bloqueos a nivel de entidad, por ejemplo

      Eliminar
    3. Bueno, a lo mejor aquí deberíamos acordar e igualar conceptos y definiciones para evitar malentendidos. Las transacciones JPA son siempre "a nivel de base de datos" en tanto en cuanto estamos hablando de transacciones de órdenes contra la base de datos (no estamos hablando de transacciones distribuidas, JMS u otro tipo). Es decir, un commit() es una orden COMMIT contra la base de datos.

      Con respecto al nivel de aislamiento:
      Cuando usamos JDBC directamente, estamos "hablando" con la base de datos "directamente", sin intermediarios (a bajo nivel, digamos). Podemos lanzar las órdenes que queramos directamente usando el dialecto SQL de cada fabricante (PostgreSQL, Oracle, etc) y por tanto, el control de aislamiento lo hacemos "a mano" directamente con órdenes de la base de datos concreta. JPA es una capa de abstracción que nos aísla de la base de datos y del SQL (y por tanto, del fabricante). Nosotros no enviamos órdenes (INSERT, UPDATES...) sino que modificamos miembros de objetos llamando a sus métodos setter/getter o los relacionamos con otros. JPA se encarga de generar el SQL contra nuestra base de datos particular. Al usar JPA ya no podemos controlar el nivel de aislamiento "a mano" ya que no somos nosotros los que enviamos el SQL.

      Con respecto al control transaccional:
      En JPA el control transaccional es parecido a JDBC. Es decir, controlamos la transacción con los típicos commit/rollback con métodos similares, pero de clases JPA de javax.persistence en lugar de clases java.sql.

      Es decir, el código es parecido en el control transaccional pero muy diferente en el control de aislamiento. Si por ejemplo la modificación de un objeto al que modificamos un miembro y añadimos dos objetos relacionados sería:

      con JDBC:
      connection.setTransactionIsolation(...);
      connection.begin();
      stmt.execute("update...");
      stmt.execute("insert...");
      stmt.execute("insert...");
      connection.commit();

      con JPA:
      ut.begin() // UserTransaction
      em.persist(objA);
      ut.commit();

      Espero que esto te aclare un poco más.
      Saludos.

      Eliminar
    4. Pues me aclara mucho, de verdad, pero a nivel de Locks veo claramente la utilidad de estos de cara a los lost updates, pero no de cara a accesos de otras aplicaciones...no usarán éstas una transacción del motor de BD igual que lo emplea la JPA?

      Eliminar
    5. JPA crea una caché llamada "caché de entidades". Lo que sucede en el contexto de JPA (los cambios de los objetos) lo hace primero sobre dicha caché y JPA usa su propio mecanismo diferido (atención, DIFERIDO) de persistencia de dichos cambios. Los cambios que realicen otras aplicaciones externas que no usen esta capa JPA (y por tanto su cache) no están reflejados en esta caché y JPA no tiene constancia de ellos. Así, si por ejemplo tu aplicación web que usa JPA lee y presenta un objeto Direccion para posteriormente cambiarlo y otra aplicación cambia directamente dicho registro en la base de datos, tendrás una lectura no repetible.

      Ten en cuenta que, en JPA, cuando realizas un find() o ejecutas una NamedQuery, no necesariamente se accede a la base de datos. Si el dato existe en la caché, se recupera de la misma. Si mientras estaba en la caché otra aplicación (aparte de JPA) ha modificado el dato, éste no será consistente en la caché, pero JPA no lo sabe.

      La utilidad de cara a accesos de otras es esa: es el hecho de que las transacciones externas a JPA pueden producir lecturas no repetibles o fantasmas. Para ello, los bloqueos optimistas son válidos: tu aplicación web daría error en la modificación y podría re-leer el dato y volverlo a presentar al usuario (en lugar de ignorar la modificación de la otra aplicación y sobreescribir la modificación perdiéndola de manera irremediable.

      También se puede, en estos casos, desactivar la caché (de la tabla, de toda la aplicación...) para forzar una re-lectura de los datos antes de modificarlos. Más info: http://wiki.eclipse.org/EclipseLink/Examples/JPA/Caching (en este caso de EclipseLink).

      Eliminar
    6. Vale, entendido 100 % ahora, muchísimas gracias por tu ayuda y enhorabuena por el blog!

      Eliminar

Related Posts Plugin for WordPress, Blogger...