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:
Related Posts Plugin for WordPress, Blogger...
cookieassistant.com