Pessimistic Locking con JPA en Spring Boot

pessimistic locking con JPA

pessimistic locking con JPA


Cuando nos encontramos trabajando con aplicaciones conectadas a Base de Datos, vamos a querer en algunos casos realizar un bloqueo de la información para que nadie más puede utilizarla y/o alterar su contenido. Para realizar los bloqueos sobre los datos que vamos a tratar podemos utilizar mecanismos y herramientas de la Base de Datos para implementar un pessimistic locking con JPA en Spring Boot.

Para realizar bloqueos sobre la BBDD podemos realizar dos aproximaciones, un bloqueo compartido y un bloqueo exclusivo.

Pessimistic Locking con JPA en Spring Boot
Pess

Pessimistic Locking Vs Optimistic Locking

En un artículo anterior ya hablamos sobre el Optimistic Locking, ahora vamos a ver cuales son las principales diferencias entre Pessimistic Locking y Optimistic Locking.

Tanto el pessimistic locking como el pessimistic son herramientas que nos van a permitir tratar con los problemas de accesos concurrentes. Vamos a usar Optimistic Locking cuando nos convenga o el coste de reintentar transacciones sea bajo, es decir, no tengamos muchísimas peticiones concurrentes.

En cambio usaremos Pessimistic Locking cuando el coste de hacer reintentos para guardar la información sea demasiado elevado, o tengamos muchas peticiones concurrentes.

Pessimistic Locking se va a basar en bloqueos en las columnas de base de datos indicando un tiempo, en cambio el Optimistic Locking no va a bloquear la transacción y permitirá los guardados, para evitar las lecturas sucias, cuando un campo version sea el mismo con el que comenzó la transacción.

A continuación vamos a ver los modos que tenemos cuando trabajamos con Pessimistic Locking atendiendo a la naturaleza del mismo.

Tipos de bloqueo en Pessimistic Locking

Cuando trabajamos con JPA se definen tres tipos diferentes de bloqueos para el pessimistic locking:

  • Pessimistic_read, evita que la información sea borrada o actualizada haciendo un bloqueo compartido. Evita las lecturas sucias (dirty reads), siempre y cuando nuestra Base de Datos lo permita.
  • Pessimistic_write, con este tipo hacemos un bloqueo exclusivo y evitamos que la información sea borrada, leída o actualizada.
  • Pessimistic_force_increment, funciona como pessimistic_write y lo que hace es incrementar la versión del campo de la entidad que lleva el valor de la versión.

Los valores de arriba pertenecen a LockModeType y permite activar los bloqueos sobre la Base de Datos hasta que se realiza el commit o el rollback.

Vamos a describir un poco mejor los diferentes bloqueos.

Excepciones con Pessimistic Locking

Cuando aplicamos Pessimistic Locking se nos puede producir las siguientes excepciones cuando hay algún problema durante su ejecución:

  • PessimisticLockException: La obtención de un bloqueo o un bloqueo compartido exclusivo falla y se produce un rollback a nivel de transacción.
  • PersistenceException : Nos indica un problema de persistencia en la Base de Datos.
  • LockTimeoutException: La obtención de un bloque o bloqueo compartido exclusivo provoca un timeout y se produce un rollback a nivel de sentencia.
  • CannotLockAcquireLockException: Esta es una situación en la cual dos o más peticiones luchan al mismo tiempo por una acción esperando una por otra.

Como usar Pessimistic Locking con Spring y JPA

Como hemos comentado con Pessimistic Locking bloquea a nivel de Base de Datos las columnas de las tablas.

Podemos usar Optimistic Locking de diferentes maneras, a través de anotaciones o haciendo uso del EntityManager, vamos a ver ambas aproximaciones a continuación.

Usar Pessimistic Locking con @Lock

Para hacer uso de @Lock podemos aplicarlo en nuestra capa repository de la siguiente manera:

@Lock(LockModeType.PESSIMISTIC_READ)
Optional<Savings> findWithLockingById(Long id);

En el caso anterior hemos aplicado PESSIMISTIC_READ para la lectura, pero como se puede observar, hacemos uso de LockModeType por lo que podemos aplicar cualquiera de los otros dos.

Hacer uso de @NamedQuery para establecer un bloqueo en Pessimistic Locking

@NamedQuery(name="lockUser",
  query="SELECT u FROM User u WHERE u.id LIKE :userId",
  lockMode = PESSIMISTIC_READ)

Pessimistic Locking con EntityManager

Podemos hacer queries con entityManager de las siguientes maneras:

entityManager.find(Savings.class, savingsId, LockModeType.PESSIMISTIC_READ);

O podemos pasar el tipo de bloqueo como parámetro:

Query query = entityManager.createQuery("from Savings where savingsId = :savingsId");
query.setParameter("savingsId", savingId);
query.setLockMode(LockModeType.PESSIMISTIC_WRITE);
query.getResultList()

También podemos pasar el bloqueo directamente al entityManager:

entityManager.lock(savingsResult, LockModeType.PESSIMISTIC_WRITE);

TimeOut en Pessimistic Locking

Uno de los problemas que podemos tener cuando hacemos un bloqueo es que muchos threads se bloqueen para la misma columna de la Base de Datos, por lo que para estos casos lo mejor es establecer un timeout que podemos hacer de las siguientes maneras:

Haciendo uso de anotación:

    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
    Optional<Savings> findWithLockingById(Long id);

O pasárselo como parámetro al entityManager:

Map<String, Object> properties = new HashMap<>(); 
map.put("javax.persistence.lock.timeout", 1000L); 

entityManager.find(
  Savings.class, 1L, LockModeType.PESSIMISTIC_READ, properties);

Alcance de los bloqueos en Pessimistic Locking

El alcance de los bloqueos o Lock scope se encarga de analizar como tratar el bloqueo de las relaciones hijas. Es decir, ver si bloqueamos las relaciones o una única entidad.

El alcance de los bloqueos viene dentro de la clase PessimisticLockScope, y tenemos dos tipos NORMAL y EXTENDED.

Para asignar y utilizar el PessimisticLockScope se pasa como parámetro haciendo uso de ‘javax.persistence.lock.scope’.

  • SCOPE NORMAL: El Scope Normal es el que se aplica por defecto. No hace falta indicar nada. Y se aplica a todo.
  • SCOPE EXTENDED: Si queremos aplicar Scope Extended habrá que indicarlo en la relación hija a través de las anotaciones, @ElementCollection o @OneToOne, @OneToMany …. etc.
Map<String, Object> properties = new HashMap<>();
map.put("javax.persistence.lock.scope", PessimisticLockScope.EXTENDED);
    
entityManager.find(
  Savings.class, 1L, LockModeType.PESSIMISTIC_WRITE, properties);

Ejemplo de Pessimistic Locking con JPA en Spring Boot

A continuación vamos a ver un ejemplo en el que aplicamos pessimistic locking haciendo uso de anotaciones.

El código entero se puede descargar de este enlace de github.

Vamos a comenzar en la capa repository añadiendo un Lock para write y otro para Read estableciendo el timeout para cuando se produce un bloqueo:

public interface SavingsRepository extends CrudRepository<Savings, UUID> {

  @Lock(LockModeType.PESSIMISTIC_WRITE)
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
  @Query("select a from Savings a where a.id = :id")
  Savings findSavingToWriteById(@Param("id") UUID id);

  @Lock(LockModeType.PESSIMISTIC_READ)
  @QueryHints({@QueryHint(name = "javax.persistence.lock.timeout", value ="10000")})
  @Query("select a from Savings a where a.id = :id")
  Savings findSavingToReadById(@Param("id") UUID id);

}

A continuación vamos a ver el servicio que hacemos que llama directamente al repository. Para ello vamos a intentar producir un deadlock (CannotLockAcquireLockException) en la base de datos, para ello jugaremos con los update y los tiempos, una vez obtenemos el bloqueo capturamos la excepción para volver a realizar el update. Es mejor verlo para entenderlo:

@RequiredArgsConstructor
@Service
public class SavingsPessimisticService {

  private final SavingsRepository savingsRepository;

  @Transactional
  public void updateSavings(UUID id, int saving) throws Exception {

    try {
      var savings = savingsRepository.findSavingToWriteById(id);
      savings.setAmount(savings.getAmount().add(new BigDecimal(saving)));

    } catch (CannotAcquireLockException ex) {
      Thread.sleep(1_000);
      var savings = savingsRepository.findSavingToWriteById(id);
      savings.setAmount(savings.getAmount().add(new BigDecimal(saving)));
    }


  }

  @Transactional
  public void updateTwoSavings(UUID id1, UUID id2, int saving) throws Exception {

    updateSavings(id1, saving);
    updateSavings(id2, saving);
    Thread.sleep(1_000);


  }

  @Transactional
  public void updateTwoSavingsReverse(UUID id1, UUID id2, int saving) throws Exception {

    updateSavings(id2, saving);
    updateSavings(id1, saving);
    Thread.sleep(1_000);

  }


  public Savings getSavings(UUID id) {
    return savingsRepository.findSavingToReadById(id);

  }
}

El objetivo de la clase anterior es para poder probar un deadlock y el uso concurrente de actualizaciones, lo cual lo veremos mejor haciendo uso de una clase de test.

Y a continuación vamos a ver la parte de los test.

@SpringBootTest
class SavingsPessimisticServiceTest {

  private final List<Integer> savingsData = Arrays.asList(2, 2);

  @Autowired
  private SavingsPessimisticService savingsPessimisticService;

  @Autowired
  private SavingsRepository savingsRepository;

  @Test
  void given_savings_when_updated_savings_then_savings_is_updated() throws Exception{

    final Savings savings = savingsRepository.save(new Savings());
    assertEquals(new BigDecimal(0), savings.getAmount());

    savingsData.forEach(saving -> {
      try {
        savingsPessimisticService.updateSavings(savings.getId(), saving);
      } catch (Exception e) {
        throw new RuntimeException(e);
      }
    });

    final Savings savingUpdate = savingsRepository.findById(savings.getId())
        .orElseThrow(() -> new IllegalArgumentException("No savings found!"));

    assertAll(
        () -> assertEquals(new BigInteger("4"), savingUpdate.getAmount().toBigInteger())
    );
  }

  @Test
  void given_savings_when_updated_user_in_a_concurrent_way_then_version_is_incremented()
      throws InterruptedException {

    final var savings = savingsRepository.save(new Savings());
    assertEquals(new BigDecimal(0), savings.getAmount());

    final ExecutorService executor = Executors.newFixedThreadPool(savingsData.size());

    savingsData.forEach(saving -> {
      executor.execute(() -> {
        try {
          savingsPessimisticService.updateSavings(savings.getId(), saving);
        } catch (Exception e) {
          throw new RuntimeException(e);
        }
      });
    });

    executor.shutdown();
    assertTrue(executor.awaitTermination(1, TimeUnit.MINUTES));

    final Savings savingUpdate = savingsRepository.findById(savings.getId())
        .orElseThrow(() -> new IllegalArgumentException("No savings found!"));

    assertAll(
        () -> assertEquals(new BigInteger("4"), savingUpdate.getAmount().toBigInteger())
    );
  }

  @Test
  void given_two_savings_when_updated_savings_in_a_concurrent_way_then_savings_are_incremented()
      throws InterruptedException {

    final var savings = savingsRepository.save(new Savings());
    assertEquals(new BigDecimal(0), savings.getAmount());

    final var savings2 = savingsRepository.save(new Savings());
    assertEquals(new BigDecimal(0), savings.getAmount());

    final ExecutorService executor = Executors.newFixedThreadPool(savingsData.size());

    savingsData.forEach(saving -> {
      executor.execute(() -> {
        try {
          savingsPessimisticService.updateTwoSavings(savings.getId(), savings2.getId(), saving);
          savingsPessimisticService.updateTwoSavingsReverse(savings.getId(),savings2.getId(),  saving);
        } catch (Exception e) {
            log.debug("Error updating savings {} ", e.getMessage());
        }
      });
    });

    executor.shutdown();
    assertTrue(executor.awaitTermination(1, TimeUnit.MINUTES));

    final Savings savingUpdate = savingsRepository.findById(savings.getId())
        .orElseThrow(() -> new IllegalArgumentException("No savings found!"));

    assertAll(
        () -> assertEquals(new BigInteger("6"), savingUpdate.getAmount().toBigInteger())
    );
  }
}

En la clase anterior se pueden ver tres test, en los que la idea es ver los posibles problemas del pessimistic locking. Por ejemplo el último test va a provocar un deadlock a través de la excepción CannotAcquireLockException.

Conclusión

En este artículo hemos visto como el uso de pessimistic Locking con JPA en Spring Boot, nos va a ayudar en los accesos concurrentes provocando diferentes bloqueos con tiempo. Aunque hay que tener en cuenta que puede afectar en cierto modo al rendimiento debido al tiempo que tenemos la petición bloqueada.

Si quieres descargarte el código completo lo puedes hacer de aquí.

Si necesitas más información puedes escribirnos un comentario o un correo electrónico a refactorizando.web@gmail.com o también nos puedes contactar por nuestras redes sociales Facebook o twitter y te ayudaremos encantados!


Deja una respuesta

Tu dirección de correo electrónico no será publicada. Los campos obligatorios están marcados con *