Comprendiendo los Bloqueos Pesimistas en JPA: Asegurando la Integridad de los Datos en JAVA
1. Overview
Existen numerosas situaciones en las que necesitamos recuperar datos de una base de datos. En ocasiones, queremos bloquear esos datos para nuestro propio procesamiento, de modo que nadie más pueda interrumpir nuestras acciones. Podemos pensar en dos mecanismos de control de concurrencia que nos permiten hacer esto: definir el nivel de aislamiento de transacciones adecuado o bloquear los datos que necesitamos en un momento dado.
El nivel de aislamiento de transacciones se define para las conexiones a la base de datos y podemos configurarlo para mantener diferentes grados de bloqueo de datos. Sin embargo, el nivel de aislamiento se establece una vez que se crea la conexión y afecta a cada declaración dentro de esa conexión. Afortunadamente, podemos utilizar el bloqueo pesimista, que utiliza mecanismos de base de datos para reservar un acceso exclusivo más granular a los datos. Un bloqueo pesimista asegura que ninguna otra transacción pueda modificar o eliminar datos reservados.
2. Lock Modes
La especificación de JPA define tres modos de bloqueo pesimista que discutiremos a continuación:
- PESSIMISTIC_READ: permite obtener un bloqueo compartido y evitar que los datos sean actualizados o eliminados.
- PESSIMISTIC_WRITE: permite obtener un bloqueo exclusivo y evitar que los datos sean leídos, actualizados o eliminados.
- PESSIMISTIC_FORCE_INCREMENT: funciona como PESSIMISTIC_WRITE, pero además incrementa un atributo de versión de una entidad versionada.
Todos ellos son miembros estáticos de la clase LockModeType y permiten que las transacciones obtengan un bloqueo en la base de datos hasta que la transacción se confirme o se retroceda. Es importante notar que solo se puede obtener un bloqueo a la vez; si esto es imposible, se lanzará una PersistenceException.
2.1. PESSIMISTIC_READ
Cuando deseamos simplemente leer datos y no encontrar lecturas sucias, podemos usar PESSIMISTIC_READ (bloqueo compartido). No podremos hacer actualizaciones ni eliminaciones. En ocasiones, es posible que la base de datos que utilizamos no soporte el bloqueo PESSIMISTIC_READ, por lo que podríamos obtener el bloqueo PESSIMISTIC_WRITE en su lugar.
2.2. PESSIMISTIC_WRITE
Cualquier transacción que necesite adquirir un bloqueo sobre datos y realizar cambios en ellos debe obtener el bloqueo PESSIMISTIC_WRITE. Según la especificación JPA, mantener un bloqueo PESSIMISTIC_WRITE impedirá que otras transacciones lean, actualicen o eliminen los datos.
Nota: Algunos sistemas de bases de datos implementan control de concurrencia multiversión que permite a los lectores obtener datos que ya han sido bloqueados.
2.3. PESSIMISTIC_FORCE_INCREMENT
Este bloqueo funciona de manera similar a PESSIMISTIC_WRITE, pero fue introducido para trabajar con entidades versionadas, que tienen un atributo anotado con @Version
. Cualquier actualización de entidades versionadas podría precederse con la obtención del bloqueo PESSIMISTIC_FORCE_INCREMENT. Adquirir ese bloqueo resulta en actualizar la columna de versión.
La decisión sobre si un proveedor de persistencia admite PESSIMISTIC_FORCE_INCREMENT para entidades no versionadas depende del mismo; si no lo admite, lanzará una PersistenceException.
2.4. Excepciones
Es útil saber qué excepciones pueden ocurrir al trabajar con bloqueos pesimistas. La especificación JPA proporciona diferentes tipos de excepciones:
- PessimisticLockException: indica que obtener un bloqueo o convertir un bloqueo compartido a exclusivo falla y resulta en un retroceso a nivel de transacción.
- LockTimeoutException: indica que obtener un bloqueo o convertir un bloqueo compartido a exclusivo ha alcanzado un tiempo de espera y resulta en un retroceso a nivel de declaración.
- PersistenceException: indica que ocurrió un problema de persistencia. PersistenceException y sus subtipos, excepto NoResultException, NonUniqueResultException, LockTimeoutException y QueryTimeoutException, marcan la transacción activa para ser retrocedida.
3. Using Pessimistic Locks
Hay varias maneras de configurar un bloqueo pesimista en un solo registro o grupo de registros. Veamos cómo hacerlo en JPA.
3.1. Find
Este método es probablemente el más sencillo. Solo necesitamos pasar un objeto LockModeType
como parámetro al método find
:
Student resultStudent = entityManager.find(Student.class, studentId, LockModeType.PESSIMISTIC_READ);
3.2. Query
También podemos utilizar un objeto Query
y llamar al método setLockMode
con un modo de bloqueo como parámetro:
Query query = entityManager.createQuery("from Student where studentId = :studentId");
query.setParameter("studentId", studentId);
query.setLockMode(LockModeType.PESSIMISTIC_WRITE);
query.getResultList();
3.3. Explicit Locking
Es posible bloquear manualmente los resultados recuperados por el método find
:
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.lock(resultStudent, LockModeType.PESSIMISTIC_WRITE);
3.4. Refresh
Si queremos sobrescribir el estado de la entidad utilizando el método refresh
, también podemos establecer un bloqueo:
Student resultStudent = entityManager.find(Student.class, studentId);
entityManager.refresh(resultStudent, LockModeType.PESSIMISTIC_FORCE_INCREMENT);
3.5. NamedQuery
La anotación @NamedQuery
también permite establecer un modo de bloqueo:
@NamedQuery(name="lockStudent",
query="SELECT s FROM Student s WHERE s.id LIKE :studentId",
lockMode = PESSIMISTIC_READ)
4. Lock Scope
El parámetro de alcance de bloqueo define cómo tratar los bloqueos de relaciones de la entidad bloqueada. Es posible obtener un bloqueo solo en una entidad única definida en una consulta o bloquear adicionalmente sus relaciones. Para configurar el alcance, podemos usar la enumeración PessimisticLockScope. Esta contiene dos valores: NORMAL y EXTENDED.
Podemos establecer el alcance pasando un parámetro ‘jakarta.persistence’
con el valor PessimisticLockScope como argumento al método apropiado de EntityManager
, Query
, TypedQuery
o NamedQuery
:
Map<String, Object> properties = new HashMap<>();
properties.put("jakarta.persistence", PessimisticLockScope.EXTENDED);
entityManager.find(
Student.class, 1L, LockModeType.PESSIMISTIC_WRITE, properties);
4.1. PessimisticLockScope.NORMAL
PessimisticLockScope.NORMAL es el alcance predeterminado. Con este ámbito, bloqueamos la entidad misma. Al usarse con herencia unida, también bloquea a los antepasados. Veamos un ejemplo con dos entidades:
@Entity
@Inheritance(strategy = InheritanceType.JOINED)
public class Person {
@Id
private Long id;
private String name;
private String lastName;
// getters and setters
}
@Entity
public class Employee extends Person {
private BigDecimal salary;
// getters and setters
}
Cuando queremos obtener un bloqueo sobre el Employee, podemos observar la consulta SQL que abarca esas dos entidades:
SELECT t0.ID, t0.DTYPE, t0.LASTNAME, t0.NAME, t1.ID, t1.SALARY
FROM PERSON t0, EMPLOYEE t1
WHERE ((t0.ID = ?) AND ((t1.ID = t0.ID) AND (t0.DTYPE = ?))) FOR UPDATE
4.2. PessimisticLockScope.EXTENDED
El alcance EXTENDED abarca la misma funcionalidad que NORMAL. Además, puede bloquear entidades relacionadas en una tabla de unión. Funciona con entidades anotadas con @ElementCollection
o @OneToOne
, @OneToMany
, etc. con @JoinTable
.
Analicemos el siguiente código que utiliza la anotación @ElementCollection
:
@Entity
public class Customer {
@Id
private Long customerId;
private String name;
private String lastName;
@ElementCollection
@CollectionTable(name = "customer_address")
private List<Address> addressList;
// getters and setters
}
@Embeddable
public class Address {
private String country;
private String city;
// getters and setters
}
Al realizar consultas para buscar la entidad Customer, se generan dos consultas FOR UPDATE que bloquean una fila en la tabla de clientes, así como una fila en la tabla de unión:
SELECT CUSTOMERID, LASTNAME, NAME
FROM CUSTOMER WHERE (CUSTOMERID = ?) FOR UPDATE
SELECT CITY, COUNTRY, Customer_CUSTOMERID
FROM customer_address
WHERE (Customer_CUSTOMERID = ?) FOR UPDATE
Es importante notar que no todos los proveedores de persistencia admiten alcances de bloqueo.
5. Setting Lock Timeout
Además de establecer alcances de bloqueos, también podemos ajustar otro parámetro de bloqueo: el tiempo de espera. El valor de tiempo de espera es el número de milisegundos que queremos esperar para obtener un bloqueo hasta que ocurra una LockTimeoutException.
Podemos cambiar el valor de tiempo de espera de manera similar a los alcances de bloqueo, utilizando la propiedad ‘jakarta.persistence.lock.timeout’
con el número adecuado de milisegundos.
También es posible especificar un bloqueo de “sin esperar” cambiando el valor de tiempo de espera a cero. Sin embargo, deberíamos tener en cuenta que hay controladores de bases de datos que no soportan establecer un valor de tiempo de espera de esta manera:
Map<String, Object> properties = new HashMap<>();
properties.put("jakarta.persistence.lock.timeout", 1000L);
entityManager.find(
Student.class, 1L, LockModeType.PESSIMISTIC_READ, properties);
6. Conclusion
Cuando establecer el nivel de aislamiento adecuado no es suficiente para hacer frente a transacciones concurrentes, JPA nos ofrece el bloqueo pesimista. Esto nos permite aislar y orquestar diferentes transacciones para que no accedan a los mismos recursos al mismo tiempo.
Para lograr esto, podemos elegir entre los tipos de bloqueos discutidos y modificar parámetros como sus alcances o tiempos de espera. Por otro lado, debemos recordar que comprender los bloqueos de base de datos es tan importante como entender los mecanismos de los sistemas de bases de datos subyacentes. Además, es fundamental tener en cuenta que el comportamiento de los bloqueos pesimistas depende del proveedor de persistencia con el que trabajemos.