DEV Community

Jordi Henrique Silva
Jordi Henrique Silva

Posted on

Criando Sistemas de Reservas consistentes com Optimistic Locking, Spring Boot e JPA/Hibernate

No artigo anterior foi utilizado a estratégia de Pessimistic Locking para construção de uma API REST para reserva de mesas de bar. Como visto no artigo existem situações onde empregar bloqueios físicos pode ser custoso visando que se diversas linhas estejam bloqueadas haverá uma diminuição de produtividade nas transações de banco de dados.

Caso você se encontre em uma destas situações, existem outras estratégias para lidar com Race Conditions, como é o caso do Optimistic Locking ou Bloqueio Otimista em português.

Optimistic Locking é uma estratégia de detecção de conflitos, ao contrário do Pessimistic Locking que seu foco é evitar conflitos, o bloqueio otimista deixa os conflitos acontecerem e ao identificá-los reverte as transações detentoras dos mesmos.

Conhecendo o Optimistic Locking

A ideia principal do bloqueio otimista é reduzir o período de tempo em que as transações detêm bloqueios físicos afim prover melhorias no desempenho da aplicação. Antes ao utilizar o bloqueio pessimista uma transação obtia um bloqueio garantindo exclusividade para alterar o recurso. Já no bloqueio otimista diversas transações podem tentar alterar, porém, somente uma ira conseguir e as demais serão abortadas.

Para identificar os conflitos a estrategia utiliza o estado do recurso como um critério de versionamento, permitindo que apenas as transações que contenham o recurso com versão equivalente a presente no banco de dados sejam confirmadas (COMMIT).

Optimistic Locking com JPA/Hibernate

JPA/Hibernate oferece suporte para estratégia de Optimistic Locking, para habilita-lo devemos inserir um campo na entidade para representar a versão. Este campo deverá ser anotado com @Version para ser gerenciado pela JPA/Hibernate. O atributo de versão deve ser mapeado para java.sql.Timestamp ou qualquer tipo que represente números inteiros, por exemplo, Long, long, int, Integer, short e Short.

@Entity
public class Mesa {
    @Id
    @GeneratedValue
    private Long id;
    @Version
    private Long version;
    //Demais campos, construtores e metodos omitidos  
}
Enter fullscreen mode Exit fullscreen mode

OBS: É recomendado que o versionamento de entidades seja através de atributos inteiros dado que a versão devera sempre crescer monotonicamente. O perigo de utilizar um atributos de data e hora é que por causa de uma sincronização do Network Time Protocol as datas podem retroceder.

Observe o comportamento ao persistir uma entidade.

@Transactional 
public void cadastrar(){
     Mesa mesa = new Mesa(4);
     entityManager.persist(mesa);
}
Enter fullscreen mode Exit fullscreen mode

O SQL gerado pela JPA/Hibernate.

INSERT INTO mesa
 (id, quantidadeDeLugares, disponivelParaReserva, version)
VALUES (1, 4, 1, 0);
Enter fullscreen mode Exit fullscreen mode

Dado que o mecanismo de bloqueio otimista está habilitado para uma entidade para cada operação de atualização é disparado uma checagem para verificar se a versão da entidade em memória corresponde a versão da entidade no banco de dados, caso corresponda atualização é confirmada e a versão da entidade é incrementada. Caso contrario a transação sera revertida.

Observe abaixo um exemplo de atualização bem sucedida.

@Transactional 
public void atualizarQuantidadeLugares(Long mesaId, int novaQuantidadeLugares){
     Mesa mesa = entityManager.find(Mesa.class, mesaId);
     mesa.atualizarQuantidadeDeLugares(novaQuantidadeLugares);
}
Enter fullscreen mode Exit fullscreen mode

O Hibernate gera a seguinte saída:

SELECT * 
  FROM mesa m
 WHERE m.id = :id

UPDATE mesa
   SET quantidadeDeLugares=:qtdLugeres, version = 1
 WHERE id=:id AND version = 0;
Enter fullscreen mode Exit fullscreen mode

Caso o retorno da operação UPDATE seja a contagem de 1 linha atualizada a operação SQL UPDATE sera commitada. Caso seja 0 sera lançada uma Exceção do tipo OptimisticLockException e automaticamente a transação sera revertida.

Para entender melhor, imagine que duas transações desejem atualizar informações da mesma entidade de maneira simultânea, ambas as transações iram obter a mesma versão de entidade. A primeira transação (Tx1) a terminar, irá persistir suas alterações, e incrementar a versão da entidade. A segunda transação (Tx2) não tem ciência que a versão da entidade foi incrementada, e dispara sua atualização, porém, a versão de presente em memória precede a versão presente no banco, portanto a segunda transação é abortada.

Evitando uma perda de atualização com Optimistic Locking

Construindo uma API REST para reserva de mesas com Optimistic Locking

Para se beneficiar do mecanismo de Optimistic Locking em nossa API de reserva, devemos implementar algumas mudanças em comparação a implementação anterior. Antes onde era adquirido um bloqueio pessimista através da consulta findByIdWithPessimisticLock( ) agora não é mais necessário dado que uma checagem de versão sera realizada pela JPA/Hibernate. Então podemos substituir a consulta pelo método default findById( ).

Um possível implementação para lógica descrita acima é:

@PostMapping("/mesas/{id}/reservas")
@Transactional
public ResponseEntity<?> reservar(
        @PathVariable(value = "id") Long mesaId,
        @RequestBody ReservaMesaRequest request,
        UriComponentsBuilder uriBuilder
) {
    Mesa mesa = mesaRepository.findById(mesaId)
            .orElseThrow(() -> new ResponseStatusException(NOT_FOUND, "Mesa não cadastrada"));

    Usuario usuario = usuarioRepository.findById(request.getUsuarioId())
            .orElseThrow(() -> new ResponseStatusException(UNPROCESSABLE_ENTITY, "Usuario não cadastrado"));

    LocalDateTime dataDaReserva = request.getDataReserva();

    if (reservaRepository.existsMesaByIdAndReservadoParaIs(mesaId, dataDaReserva)) {
        throw new ResponseStatusException(UNPROCESSABLE_ENTITY, "Horario indisponivel para reserva");
    }

    Reserva reserva = mesa.reservar(usuario, dataDaReserva);
    reservaRepository.save(reserva);


    URI location = uriBuilder.path("/mesas/{id}/reservas/{reservaId}")
            .buildAndExpand(mesa.getId(), reserva.getId())
            .toUri();

    return ResponseEntity.created(location).build();
}
Enter fullscreen mode Exit fullscreen mode

Sabemos que se uma operação de atualização UPDATE falhar no mecanismo de checagem de versão uma ObjectOptimisticLockingFailureExceptionserá lançada, e se o sistema não estive preparado um erro inesperado sera retornado ao cliente, então é importante que a exceção seja tratada e uma mensagem amigável e coerente ao caso seja dada ao cliente.

Segundo a RFC 7231 quando uma solicitação resulta em erro dado ao estado atual do recurso no servidor de destino é o indicado que a resposta a solicitação contenha o HTTP Status 409 Conflict.

Exemplo de Exception Handler com Spring:

@RestControllerAdvice
public class DeafultExceptionHandler {    

    @ExceptionHandler(ObjectOptimisticLockingFailureException.class)
    public ResponseEntity<?> optmisticLock(ObjectOptimisticLockingFailureException ex) {

        String msg = "Aconteceu um conflito em sua atualização, tente novamente.";

        return status(409)
                .body(
                        Map.of("mensagem",msg)
                );
    }
}
Enter fullscreen mode Exit fullscreen mode

Conclusão

O mecanismo de bloqueio otimista da JPA/Hibernate trabalha com a estratégia de detecção de conflitos. Isto que significa que o tempo em que o registro fica bloqueado é reduzido. Antes um bloqueio era adquirido no momento da leitura e era liberado ao fim da transação. Agora diversas transações simultâneas podem tentar atualizar o registro, mas apenas uma conseguirá.

Estrategia de bloqueio otimista pode trazer uma melhoria em performance para seu sistema como um todo na maioria dos casos, porém, não se engane, caso o índice de conflitos seja alto o Optimistic Locking pode ser mais custoso que um bloqueio pessimista, dado que será necessário um alto esforço do banco de dados para reverter transações. Outro fator que corrobora uma possível perda de performance é que bloqueios otimistas bem implementados requerem retries o que pode aumentar o tempo de resposta da sua aplicação.

Enfim, se seu sistema possui um índice de concorrência baixo ou moderado o uso de Optimistic Locking é uma ótima opção, pois oferece ganhos de consistência sem sacrificar a performance.

Referências

Top comments (2)

Collapse
 
jraraujo profile image
José Roberto Araújo

Assumindo que sua API rode em apenas 1 instância, porém receba 2 requests em simultâneo para a reserva da mesma mesa (com os mesmos dados), no mesmo instante (ambas as solicitações). O que aconteceria com as linhas abaixo?

Reserva reserva = mesa.reservar(usuario, dataDaReserva);
reservaRepository.save(reserva);

Ambas as requests iriam conseguir reservar a mesa? o que aconteceria com o sistema nesse caso? Como evitar o race condition nessas ruas linhas?

Parabéns por abordar esses temas, tão importantes para o desenvolvimento de sistemas!

Collapse
 
jordihofc profile image
Jordi Henrique Silva • Edited

Olá @jraraujo, fico feliz em poder contribuir ^^

Optimistick Locking é mecanismo de controle de concorrência baseado na estratégia de identificar conflitos, nesta abordagem deixamos as transações fluir livremente, e quando identificamos um conflito, apenas uma das transações confirmada (COMMIT), enquanto as demais são revertidas (ROLLBACK).

Quando olhamos na perspectiva da requisição, temos que lembrar que cada requisição possuirá uma transaction com banco de dados. Esta transação irá iniciar a leitura e obter o valor do atributo version como 0. A outra transação fará uma leitura e receberá o mesmo estado do dados. Quando uma transação for confirmada (COMMIT), o atributo version será incrementado, o que fará com que a outra transação seja revertida (ROLLBACK). Resultando em apenas uma requisição conseguindo reservar a mesa.

você pode aprender mais sobre na talk onde falo sobre 3 estratégias para lidar com race conditions.