Optimistic locking en JPA con Spring Data

Optimistic Locking con Spring Data

Optimistic Locking con JPA en Spring Boot


Cuando trabajamos con aplicaciones en los que podemos tener múltiples usuarios concurrentes, podemos incurrir en accesos simúltaneos a la Base de Datos que podría hacer que se produzcan modificaciones sobre un mismo registro, lo que podría provocar un error. Por lo que, debemos asegurar en nuestra aplicación la consistencia entre las lecturas y actualizaciones concurrentes, es por ese motivo que podemos hacer uso de Optimistic locking en JPA con Spring Data para solventar este problema.

En este artículo vamos a ver cómo funciona el Optimistic Locking y un ejemplo con Spring Data para solucionar los problemas de condiciones de carrera. En donde veremos como solventar las condiciones de carrera y evitar las lecturas sucias.

¿Qué es el Optimistic locking?

Antes de poder aplicar ninguna medida sobre los accesos concurrentes a Base de Datos y utilizar el Optimistic locking necesitamos entender qué es el optimistic locking.

El optimistic locking, o control de concurrencia optimista, es la gestión de concurrencia aplicado a sistemas transaccionales, para evitar dejar la Base de Datos inconsistente debido a accesos concurrentes a la Base de Datos.

¿Qué es el Optimistic Locking?| Optimistic locking en JPA con Spring Data
¿Qué es el Optimistic Locking?

¿Cómo gestionar el Optimistic locking en Spring?

Para poder hacer uso del optimistic locking en Spring vamos a hacer uso de la anotación @Version sobre un campo version dentro de nuestra clase de entidades. Al añadir la anotación @Version en la entidad cada transacción lee este valor, y antes de hacer un update se verifica el valor de esta propiedad.

Cuando se realiza un update y en el tiempo que dura ese update el valor ha cambiado, una excepción de OptimisticLockException es lanzada. Si en cambio la transacción se realiza con éxito incrementa el valor del campo @Version

Cómo usar el @Version en Spring

El uso de @version en Spring nos va a permitir aplicar el Optimistic Locking por defecto. De esta, el campo version será comprobado con cada actualización.

@Getter
@Setter
@NoArgsConstructor
@Entity
public class User {

  @Id
  @GeneratedValue
  @Type(type = "org.hibernate.type.UUIDCharType")
  @Column(name = "POLICY_RELATION_ID")
  private UUID id;

  private String name;

  private String surname;

  @Version
  private Integer version;

  
}

El campo @Version puede ser de tipo int, Integer, long, Long, short, Short y java.sql.Timestamp.

Este campo no hay que tocarlo manualmente, sino que se encargará el framework de gestionar la actualización. En el caso en el que intentemos hacer un bloqueo y el proveedor para hacer la persistencia no lo soporte, obtendremos un error de PersistenceException.

Modos de optimistic locking

Tenemos 2 modos diferenciados cuando aplicamos optimistic locking, que son proporcionados por JPA:

  • OPTIMISTIC o READ: para todos aquellas entidades con el campo version obtiene un bloqueo optimista de lectura.
  • OPTIMISTIC_FORCE_INCREMENT o WRITE: Al igual que OPTIMISTIC e incrementar la versión del campo version.

Uso de modo Optimistic o READ

Al hacer uso del modo Optimistic vamos a evitar las lecturas sucias, es decir, evitar la posibilidad de leer datos que están siendo escritos por otra transacción antes de que se realice el commit. Y además también evita las lecturas repetibles, es decir, lee dos veces un registro con valores diferentes porque ha sido modificado por otro update.

Uso de modo Optimistic_Force_Increment

Este modo de JPA, funciona igual que Optimistic, evitando lecturas sucias y repetibles y además incrementará el campo Version. De esta manera nos aseguramos, con el campos Version que tenemos otra versión del registro.

Aplicar Optimistic Locking de manera programática

Una vez que hemos visto y entendido como funciona el Optimistic Locking con JPA vamos a ver como aplicarlo.

Al hacer uso de @Version Optimistic Locking es aplicado por defecto

Podemos hacer uso del Optimistic Locking a través de anotaciones o haciendo uso del entitymanager.

Optimistic Locking con EntityManager

Vamos a ver como podemos aplicar Optimistic Locking haciendo uso de EntityManager de javax.persistence.EntityManager a través de LockModeType

Query findUser = entityManager.createQuery("from User where id = :userId");
findUser.setParameter("userId", userId);
findUser.setLockMode(LockModeType.OPTIMISTIC_FORCE_INCREMENT);
findUser.getResultList()

En el código anterior hemos aplicado a la query que hemos creado un OPTIMISTIC_FORCE_INCREMENT, es decir, vamos a forzar la actualización del campo version para incrementar en una unidad.

Student student = entityManager.find(Student.class, id);
entityManager.lock(student, LockModeType.OPTIMISTIC);
@NamedQuery(name="PolicyRelation.findAll", query="SELECT p FROM PolicyRelationEntity p", lockMode = WRITE)

Optimistic Locking con anotaciones

Si no queremos hacer uso de la anotación @Version podemos añadir la siguiente anotación en la cabecera de la clase:

@OptimisticLocking(type = OptimisticLockType.DIRTY) 

En el caso anterior no haríamos uso de la columna version y añadiríamos el tipo DIRTY para evitar las lecturas sucias.

Ejemplo Optimistic Locking con Spring Data

Vamos a ver un ejemplo completo de como aplicar Optimistic Locking en una aplicación Spring Boot con Spring Data y H2 como Base de Datos en memoria.

En el ejemplo vamos a ver que cuando hacemos accesos simúltaneos a una actualización podemos incurrir en una excepción de Optimistic Locking y que cuando hacemos accesos sin concurrencia únicamente se irá actualizando el campo version.

En el ejemplo vamos a tener una clase entidad la cual tendrá el @Version y una clase que se encargará de aumentar los savings de esa entidad cada vez que se la llame. Es en este punto donde vamos a evitar las lecturas sucias que se puedan realizar.

Vamos a ver el código generado y como podemos ir viendo el Optimistic Locking:

Crear entidad para Optimistic Locking

@Data
@Entity
public class UserEntity {

    @Id
    @GeneratedValue
    @Type(type = "org.hibernate.type.UUIDCharType")
    private String id;

    private Integer savings = 0;

    @Version
    private Long version;
}

En la entidad anterior hemos añadido la anotación @Version para poder tratar con el Optimistic Locking. Al añadir esta anotación, de manera automática vamos a aplicar por defecto el Optimistic Locking.

A continuación vamos a añadir dos clases cuya funcionalidad será la de incrementar los savings en cada llamada:

@RequiredArgsConstructor
@Service
public class SavingsService {

  private final UserRepository userRepository;

  @Transactional(propagation = Propagation.REQUIRES_NEW)
  public void updateSavings(UUID id, int saving) {
    UserEntity user = userRepository.findById(id).orElseThrow(EntityNotFoundException::new);
    user.setSavings(user.getSavings() + saving);
  }

}

El método anterior lleva anotado @Transactional porque mientras estemos en la misma transacción vamos a realizar actualizaciones sobre usuario, incrementar los savings y la version.

@Slf4j
@RequiredArgsConstructor
@Service
public class UserService {

    private final SavingsService savingService;

    public void updateSavings(UUID id, int saving) {
        try {
            savingService.updateSavings(id, saving);
        } catch (ObjectOptimisticLockingFailureException e) {
            log.warn("Savings has been updated before in concurrent transaction");
            savingService.updateSavings(id, saving);
        }
    }

}

A continuación vamos realizar diferentes test en los que accederemos de sin concurrencia y de una manera concurrente con lo que podemos ver como se realizar la actualización y el Optimistic Locking:

Test de Optimistic Locking sin concurrencia

A continuación vamos a hacer un test para verificar como se incrementa y actualiza la tabla User.

  @Test
  void given_savings_when_updated_user_then_version_is_incremented() {
    final UserEntity user = userRepository.save(new UserEntity());
    assertEquals(0, user.getVersion());

    savings.forEach(saving -> {
      userService.updateSavings(user.getId(), saving);
    });

    final UserEntity userUpdated = userRepository.findById(user.getId())
        .orElseThrow(() -> new IllegalArgumentException("User not found!"));

    assertAll(
        () -> assertEquals(2, userUpdated.getVersion()),
        () -> assertEquals(2, userUpdated.getSavings())
    );
  }

En el test anterior hemos realizado dos actualizaciones sobre nuestra entidad/tabla por lo que version ha sido incrementada en dos.

Test de Optimistic Locking con concurrencia

Pero qué pasaría si ahora realizamos accesos concurrentes para actualizar los savings del usuario?. En el siguiente test vamos a hacer uso de ExecutorService para realizar dos peticiones concurrentes y veremos como a gracias al Optimistic Locking vamos a evitar la lectura sucia:

  @Test
  void given_savings_when_updated_user_in_a_concurrent_way_then_version_is_incremented() throws InterruptedException {

    final UserEntity user = userRepository.save(new UserEntity());
    assertEquals(0, user.getVersion());

    final ExecutorService executor = Executors.newFixedThreadPool(savings.size());//size two

    savings.forEach(saving -> {
      executor.execute(() -> userService.updateSavings(user.getId(), saving));
    });

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

    final UserEntity userSaved = userRepository.findById(user.getId())
        .orElseThrow(() -> new IllegalArgumentException("User not found"));

    assertAll(
        () -> assertEquals(2, userSaved.getVersion()),
        () -> assertEquals(4, userSaved.getSavings())
    );
  }
}

En el caso anterior al tener dos hilos que intentan acceder a la vez y realizan una actualización, se va a tener una lectura sucia en el momento en el que el segundo intenta recuperar el user, y saltará una excepción. La excepción que va a capturar este error es: org.springframework.orm.ObjectOptimisticLockingFailureException:Object of class [com.refactorizando.example.optlockingservice.domain.UserEntity] with identifier [0a8765bb-a411-4c54-87c5-bddd9f4c5ead]: optimistic locking failed

En nuestra capa de servicio se puede ver que tenemos un try {} catch() en el que capturamos la excepción y volvemos a intentar guardar, de manera que guardamos con el objeto actualizado y de manera correcta.

Excepciones con Optimistic Locking

Cuando trabajamos con Optimistic Locking podemos capturar la excepción lanzada por el Optimistic Locking a través de esta excepción: ObjectOptimisticLockingFailureException.

Es muy importante capturar la excepción cuando tenemos un error de Optimistic Locking. Además, cuando una excepción de Optimistic Locking es lanzada, deberíamos de volver a intentar crear la nueva entidad, preferiblemente en una nueva transacción. Para crear una nueva transacción podemos hacer uso de la anotación @Transactional(propagation = Propagation.REQUIRES_NEW).

Conclusión

En esta entrada sobre Optimistic locking en JPA con Spring Data, hemos visto como tratar y trabajar con condiciones de carrera cuando tenemos usuarios concurrentes accediendo a nuestra Base de Datos.

Hacer uso de Optimistic Locking es imprescindible cuando pueden existir usuarios concurrentes. Y además nos ayudará a tener datos inconsistentes en nuestra Base de Datos.

Si quieres ver el código completo lo puedes descargar de nuestro github.

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 *