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 almacena información sobre vuelos y los almacenan información sobre boletos reservados. Cada vuelo tiene su propia capacidad, que se almacena en la columna de . 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. flights tickets flights.capacity Cambios simultáneos sin bloqueo 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> { } realiza cambios transaccionales: DbService @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 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: Thread.sleep(1_000); 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. Bloqueo optimista 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 a la clase de entidad. Esta propiedad puede ser de tipo , , , , , o . 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 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. @Version int Integer short Short long Long java.sql.Timestamp @Version Agregue la propiedad de a la entidad version 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 a la tabla de version 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 y se usan en la cláusula . id version where Tenga en cuenta que el número de versión no cambia al cambiar las y con el atributo . Restauremos el código original de DbService y compruébelo: @OneToMany @ManyToMany mappedBy @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 será el siguiente tickets 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 con el valor . Agreguemos el método a la clase . En Spring Data JPA, se puede agregar cualquier texto entre y al nombre del método, y si no contiene palabras clave como , el texto es descriptivo y el método se ejecuta como un normal: @Lock OPTIMISTIC_FORCE_INCREMENT findWithLockingById FlightRepository find By Distinct find…By… public interface FlightRepository extends CrudRepository<Flight, Long> { @Lock(LockModeType.OPTIMISTIC_FORCE_INCREMENT) Optional<Flight> findWithLockingById(Long id); } Use el método en findWithLockingById 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 . El estado de la mesa de es ObjectOptimisticLockingFailureException tickets identificación id_vuelo primer nombre apellido 1 1 Pablo Sotavento 2 1 Roberto Herrero Vemos que esta vez solo se ha guardado un en la base de datos. Ticket Si es imposible agregar una nueva columna a la tabla, pero es necesario usar el bloqueo optimista, puede aplicar las anotaciones de Hibernate y . El valor de tipo en la anotación OptimisticLocking puede tomar los siguientes valores: OptimisticLocking DynamicUpdate : realiza el bloqueo en función de todos los campos ALL : realice el bloqueo basado solo en campos de campos modificados DIRTY : realice el bloqueo utilizando una columna de versión dedicada VERSION - no realizar bloqueo NONE Probaremos el tipo de bloqueo optimista en el ejemplo de cambio de capacidad de vuelo. DIRTY @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 y se utilizan en la cláusula . Si cambia el tipo de bloqueo a , se lanzará una excepción de este tipo id cpacity where ALL 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 Bloqueo pesimista 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 a FlightRepository#findWithLockingById 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á y solo habrá dos boletos en la tabla de . ExceededCapacityException 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 , 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 controlada. flights ExceededCapacityException Hay tres tipos de bloqueo pesimista en JPA: : adquiere un bloqueo compartido y la entidad bloqueada no se puede cambiar antes de confirmar una transacción. PESSIMISTIC_READ : adquiere un bloqueo exclusivo y la entidad bloqueada se puede cambiar. PESSIMISTIC_WRITE : adquiera un bloqueo exclusivo y actualice la columna de versión, la entidad bloqueada se puede cambiar PESSIMISTIC_FORCE_INCREMENT 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 . Es importante tener en cuenta que no todos los proveedores de persistencia admiten la sugerencia . Por ejemplo, el proveedor de persistencia de Oracle admite esta sugerencia, mientras que no lo hace para PostgreSQL, MS SQL Server, MySQL y H2. CannotAcquireLockException javax.persistence.lock.timeout 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. Conclusión 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.