El bloqueo es un mecanismo que permite trabajar en paralelo con los mismos datos en la base de datos. Cuando más de una transacción intenta acceder a los mismos datos al mismo tiempo, entran en juego los bloqueos, lo que garantiza que solo una de estas transacciones cambiará los datos. JPA admite dos tipos de mecanismos de bloqueo: modelo optimista y modelo pesimista.
Consideremos la base de datos de la aerolínea como ejemplo. La tabla de flights
almacena información sobre vuelos y los tickets
almacenan información sobre boletos reservados. Cada vuelo tiene su propia capacidad, que se almacena en la columna de flights.capacity
. Nuestra aplicación debe controlar la cantidad de boletos vendidos y no debe permitir comprar un boleto para un vuelo completo. Para ello, al momento de reservar un boleto, necesitamos obtener de la base de datos la capacidad del vuelo y el número de boletos vendidos, y si hay asientos vacíos en el vuelo, vender el boleto, de lo contrario, informar al usuario que los asientos se han agotado Si cada solicitud de usuario se procesa en un subproceso independiente, es posible que se produzcan incoherencias en los datos. Suponga que hay un asiento vacío en el vuelo y dos usuarios reservan boletos al mismo tiempo. En este caso, dos subprocesos leen simultáneamente el número de entradas vendidas de la base de datos, comprueban que aún queda un asiento y venden la entrada al cliente. Para evitar tales colisiones, se aplican bloqueos.
Usaremos Spring Data JPA y Spring Boot. Vamos a crear entidades, repositorios y otras clases:
@Entity @Table(name = "flights") public class Flight { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String number; private LocalDateTime departureTime; private Integer capacity; @OneToMany(mappedBy = "flight") private Set<Ticket> tickets; // ... // getters and setters // ... public void addTicket(Ticket ticket) { ticket.setFlight(this); getTickets().add(ticket); } }
public interface FlightRepository extends CrudRepository<Flight, Long> { }
@Entity @Table(name = "tickets") public class Ticket { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; @ManyToOne(fetch = FetchType.LAZY) @JoinColumn(name = "flight_id") private Flight flight; private String firstName; private String lastName; // ... // getters and setters // ... }
public interface TicketRepository extends CrudRepository<Ticket, Long> { }
DbService
realiza cambios transaccionales:
@Service public class DbService { private final FlightRepository flightRepository; private final TicketRepository ticketRepository; public DbService(FlightRepository flightRepository, TicketRepository ticketRepository) { this.flightRepository = flightRepository; this.ticketRepository = ticketRepository; } @Transactional public void changeFlight1() throws Exception { // the code of the first thread } @Transactional public void changeFlight2() throws Exception { // the code of the second thread } }
Una clase de aplicación:
import org.apache.commons.lang3.function.FailableRunnable; @SpringBootApplication public class JpaLockApplication implements CommandLineRunner { @Resource private DbService dbService; public static void main(String[] args) { SpringApplication.run(JpaLockApplication.class, args); } @Override public void run(String... args) { ExecutorService executor = Executors.newFixedThreadPool(2); executor.execute(safeRunnable(dbService::changeFlight1)); executor.execute(safeRunnable(dbService::changeFlight2)); executor.shutdown(); } private Runnable safeRunnable(FailableRunnable<Exception> runnable) { return () -> { try { runnable.run(); } catch (Exception e) { e.printStackTrace(); } }; } }
Usaremos este estado de la base de datos en cada ejecución siguiente de la aplicación.
tabla de flights
:
identificación | número | hora de salida | capacidad |
---|---|---|---|
1 | FLT123 | 2022-04-01 09:00:00+03 | 2 |
2 | FLT234 | 2022-04-10 10:30:00+03 | 50 |
mesa de tickets
:
identificación | id_vuelo | primer nombre | apellido |
---|---|---|---|
1 | 1 | Pablo | Sotavento |
Escribamos un código que simule la compra simultánea de entradas sin bloqueo.
@Service public class DbService { // ... // autowiring // ... private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception { if (flight.getCapacity() <= flight.getTickets().size()) { throw new ExceededCapacityException(); } var ticket = new Ticket(); ticket.setFirstName(firstName); ticket.setLastName(lastName); flight.addTicket(ticket); ticketRepository.save(ticket); } @Transactional public void changeFlight1() throws Exception { var flight = flightRepository.findById(1L).get(); saveNewTicket("Robert", "Smith", flight); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { var flight = flightRepository.findById(1L).get(); saveNewTicket("Kate", "Brown", flight); Thread.sleep(1_000); } }
public class ExceededCapacityException extends Exception { }
Llamando Thread.sleep(1_000);
se asegura de que las transacciones iniciadas por ambos subprocesos se superpongan en el tiempo. El resultado de ejecutar este ejemplo en la base de datos:
identificación | id_vuelo | primer nombre | apellido |
---|---|---|---|
1 | 1 | Pablo | Sotavento |
2 | 1 | Kate | Marrón |
3 | 1 | Roberto | Herrero |
Como puede ver, se reservaron tres boletos, aunque la capacidad del vuelo FLT123 es de dos pasajeros.
Ahora, mira cómo funciona el bloqueo optimista. Comencemos con un ejemplo más sencillo: una capacidad simultánea del cambio de vuelo. Para usar el bloqueo optimista, se debe agregar una propiedad persistente con una anotación @Version
a la clase de entidad. Esta propiedad puede ser de tipo int
, Integer
, short
, Short
, long
, Long
o java.sql.Timestamp
. La propiedad de versión es administrada por el proveedor de persistencia, no necesita cambiar su valor manualmente. Si se cambia la entidad, el número de versión aumenta en 1 (o la marca de tiempo se actualiza si el campo con la anotación @Version
tiene el tipo java.sql.Timestamp). Y si la versión original no coincide con la versión en la base de datos al guardar la entidad, se lanza una excepción.
Agregue la propiedad de version
a la entidad Flight
@Entity @Table(name = "flights") public class Flight { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String number; private LocalDateTime departureTime; private Integer capacity; @OneToMany(mappedBy = "flight") private Set<Ticket> tickets; @Version private Long version; // ... // getters and setters // public void addTicket(Ticket ticket) { ticket.setFlight(this); getTickets().add(ticket); } }
Agregar la columna de version
a la tabla de flights
identificación | nombre | hora de salida | capacidad | versión |
---|---|---|---|---|
1 | FLT123 | 2022-04-01 09:00:00+03 | 2 | 0 |
2 | FLT234 | 2022-04-10 10:30:00+03 | 50 | 0 |
Ahora cambiamos la capacidad de vuelo en ambos hilos:
@Service public class DbService { // ... // autowiring // ... @Transactional public void changeFlight1() throws Exception { var flight = flightRepository.findById(1L).get(); flight.setCapacity(10); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { var flight = flightRepository.findById(1L).get(); flight.setCapacity(20); Thread.sleep(1_000); } }
Ahora al ejecutar nuestra aplicación obtendremos una excepción
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=?, departure_time=?, number=?, version=? where id=? and version=?
Por lo tanto, en nuestro ejemplo, un subproceso guardó los cambios y el otro subproceso no pudo guardar los cambios porque ya hay cambios en la base de datos. Gracias a esto se evitan cambios simultáneos de un mismo vuelo. En el mensaje de excepción, vemos que las columnas id
y version
se usan en la cláusula where
.
Tenga en cuenta que el número de versión no cambia al cambiar las @OneToMany
y @ManyToMany
con el atributo mappedBy
. Restauremos el código original de DbService y compruébelo:
@Service public class DbService { // ... // autowiring // ... private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception { if (flight.getCapacity() <= flight.getTickets().size()) { throw new ExceededCapacityException(); } var ticket = new Ticket(); ticket.setFirstName(firstName); ticket.setLastName(lastName); flight.addTicket(ticket); ticketRepository.save(ticket); } @Transactional public void changeFlight1() throws Exception { var flight = flightRepository.findById(1L).get(); saveNewTicket("Robert", "Smith", flight); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { var flight = flightRepository.findById(1L).get(); saveNewTicket("Kate", "Brown", flight); Thread.sleep(1_000); } }
La aplicación se ejecutará con éxito y el resultado en la tabla de tickets
será el siguiente
identificación | id_vuelo | primer nombre | apellido |
---|---|---|---|
1 | 1 | Pablo | Sotavento |
2 | 1 | Roberto | Herrero |
3 | 1 | Kate | Marrón |
De nuevo, el número de billetes supera la capacidad de vuelo.
JPA permite aumentar por la fuerza el número de versión al cargar una entidad utilizando la anotación @Lock
con el valor OPTIMISTIC_FORCE_INCREMENT
. Agreguemos el método findWithLockingById
a la clase FlightRepository
. En Spring Data JPA, se puede agregar cualquier texto entre find
y By
al nombre del método, y si no contiene palabras clave como Distinct
, el texto es descriptivo y el método se ejecuta como un find…By…
normal:
public interface FlightRepository extends CrudRepository<Flight, Long> { @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) Optional<Flight> findWithLockingById(Long id); }
Use el método findWithLockingById
en DbService
@Service public class DbService { // ... // autowiring // ... private void saveNewTicket(String firstName, String lastName, Flight flight) throws Exception { // ... } @Transactional public void changeFlight1() throws Exception { var flight = flightRepository.findWithLockingById(1L).get(); saveNewTicket("Robert", "Smith", flight); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { var flight = flightRepository.findWithLockingById(1L).get(); saveNewTicket("Kate", "Brown", flight); Thread.sleep(1_000); } }
Cuando se inicia la aplicación, uno de los dos subprocesos ObjectOptimisticLockingFailureException
. El estado de la mesa de tickets
es
identificación | id_vuelo | primer nombre | apellido |
---|---|---|---|
1 | 1 | Pablo | Sotavento |
2 | 1 | Roberto | Herrero |
Vemos que esta vez solo se ha guardado un Ticket en la base de datos.
Si es imposible agregar una nueva columna a la tabla, pero es necesario usar el bloqueo optimista, puede aplicar las anotaciones de Hibernate OptimisticLocking
y DynamicUpdate
. El valor de tipo en la anotación OptimisticLocking puede tomar los siguientes valores:
ALL
: realiza el bloqueo en función de todos los camposDIRTY
: realice el bloqueo basado solo en campos de campos modificadosVERSION
: realice el bloqueo utilizando una columna de versión dedicadaNONE
- no realizar bloqueo
Probaremos el tipo de bloqueo optimista DIRTY
en el ejemplo de cambio de capacidad de vuelo.
@Entity @Table(name = "flights") @OptimisticLocking(type = OptimisticLockType.DIRTY) @DynamicUpdate public class Flight { @Id @GeneratedValue(strategy = GenerationType.IDENTITY) private Long id; private String number; private LocalDateTime departureTime; private Integer capacity; @OneToMany(mappedBy = "flight") private Set<Ticket> tickets; // ... // getters and setters // ... public void addTicket(Ticket ticket) { ticket.setFlight(this); getTickets().add(ticket); } }
@Service public class DbService { // ... // autowiring // ... @Transactional public void changeFlight1() throws Exception { var flight = flightRepository.findById(1L).get(); flight.setCapacity(10); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { var flight = flightRepository.findById(1L).get(); flight.setCapacity(20); Thread.sleep(1_000); } }
Se lanzará una excepción.
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=? where id=? and capacity=?
Ahora las columnas id
y cpacity
se utilizan en la cláusula where
. Si cambia el tipo de bloqueo a ALL
, se lanzará una excepción de este tipo
org.springframework.orm.ObjectOptimisticLockingFailureException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1; statement executed: update flights set capacity=? where id=? and capacity=? and departure_time=? and number=?
Ahora todas las columnas se usan en la cláusula where
.
Con el bloqueo pesimista, las filas de la tabla se bloquean en el nivel de la base de datos. Cambiemos el tipo de bloqueo del método FlightRepository#findWithLockingById
a PESSIMISTIC_WRITE
public interface FlightRepository extends CrudRepository<Flight, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) Optional<Flight> findWithLockingById(Long id); }
y vuelva a ejecutar el ejemplo de reserva de entradas. Uno de los subprocesos arrojará ExceededCapacityException
y solo habrá dos boletos en la tabla de tickets
.
identificación | id_vuelo | primer nombre | apellido |
---|---|---|---|
1 | 1 | Pablo | Sotavento |
2 | 1 | Kate | Marrón |
Ahora el subproceso que primero cargó el vuelo tiene acceso exclusivo a la fila en la tabla de flights
, por lo que el segundo subproceso suspende su trabajo hasta que se libera el bloqueo. Después de que el primer subproceso confirme la transacción y libere el bloqueo, el segundo subproceso obtendrá acceso monopolar a la fila, pero en este punto, la capacidad de vuelo ya estará agotada, porque los cambios realizados por el primer subproceso entrarán en la base de datos. Como resultado, se lanzará la excepción ExceededCapacityException
controlada.
Hay tres tipos de bloqueo pesimista en JPA:
PESSIMISTIC_READ
: adquiere un bloqueo compartido y la entidad bloqueada no se puede cambiar antes de confirmar una transacción.PESSIMISTIC_WRITE
: adquiere un bloqueo exclusivo y la entidad bloqueada se puede cambiar.PESSIMISTIC_FORCE_INCREMENT
: adquiera un bloqueo exclusivo y actualice la columna de versión, la entidad bloqueada se puede cambiar
Si muchos subprocesos bloquean la misma fila en la base de datos, puede llevar mucho tiempo obtener el bloqueo. Puede establecer un tiempo de espera para recibir un bloqueo:
public interface FlightRepository extends CrudRepository<Flight, Long> { @Lock(LockModeType.PESSIMISTIC_WRITE) @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")}) Optional<Flight> findWithLockingById(Long id); }
Si el tiempo de espera expira, se generará una CannotAcquireLockException
. Es importante tener en cuenta que no todos los proveedores de persistencia admiten la sugerencia javax.persistence.lock.timeout
. Por ejemplo, el proveedor de persistencia de Oracle admite esta sugerencia, mientras que no lo hace para PostgreSQL, MS SQL Server, MySQL y H2.
Ahora consideramos una situación de punto muerto.
@Service public class DbService { // ... // autowiring // ... private void fetchAndChangeFlight(long flightId) throws Exception { var flight = flightRepository.findWithLockingById(flightId).get(); flight.setCapacity(flight.getCapacity() + 1); Thread.sleep(1_000); } @Transactional public void changeFlight1() throws Exception { fetchAndChangeFlight(1L); fetchAndChangeFlight(2L); Thread.sleep(1_000); } @Transactional public void changeFlight2() throws Exception { fetchAndChangeFlight(2L); fetchAndChangeFlight(1L); Thread.sleep(1_000); } }
Obtendremos el siguiente seguimiento de pila de uno de los subprocesos
org.springframework.dao.CannotAcquireLockException: could not extract ResultSet; SQL [n/a]; nested exception is org.hibernate.exception.LockAcquisitionException: could not extract ResultSet ... Caused by: org.postgresql.util.PSQLException: ERROR: deadlock detected ...
La base de datos detectó que este código conduce a un interbloqueo. Sin embargo, puede haber situaciones en las que la base de datos no pueda hacer esto y los subprocesos suspendan su ejecución hasta que finalice el tiempo de espera.
El bloqueo optimista y pesimista son dos enfoques diferentes. Los bloqueos optimistas son adecuados para situaciones en las que se puede manejar fácilmente una excepción que se ha producido y notificar al usuario o volver a intentarlo. Al mismo tiempo, las filas a nivel de base de datos no se bloquean, lo que no ralentiza el funcionamiento de la aplicación. Si fuera posible obtener un bloque, los bloqueos pesimistas dan grandes garantías para la ejecución de consultas a la base de datos. Sin embargo, al usar el bloqueo pesimista, debe escribir y verificar cuidadosamente el código porque existe la posibilidad de interbloqueos, que pueden convertirse en errores flotantes que son difíciles de encontrar y corregir.