paint-brush
Bloqueo optimista y pesimista en JPApor@yaf
57,441 lecturas
57,441 lecturas

Bloqueo optimista y pesimista en JPA

por Fedor Yaremenko2022/03/01
Read on Terminal Reader
Read this story w/o Javascript

Demasiado Largo; Para Leer

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 simultáneamente, 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. Probaremos diferentes tipos de bloqueo en una aplicación Spring Data JPA con código transaccional concurrente.

Companies Mentioned

Mention Thumbnail
Mention Thumbnail
featured image - Bloqueo optimista y pesimista en JPA
Fedor Yaremenko HackerNoon profile picture

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.

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> { }


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.

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 @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 campos
  • DIRTY : realice el bloqueo basado solo en campos de campos modificados
  • VERSION : realice el bloqueo utilizando una columna de versión dedicada
  • NONE - 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 .

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 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.

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.