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

jordihofc

Jordi Henrique Silva

Posted on August 1, 2022

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

OBS: Objetivo deste artigo não é ensinar a construir um sistema de reservas para produção, mas sim introduzir conceitos de controle de concorrência.

Uma cultura bastante popular em diversas cidades no mundo é se encontrar para bater papo e tomar alguns drinks em uma mesa de bar. Em alguns Bares a procura por mesas é tão grande que as pessoas precisam se adiantar para não ficar de fora e isto causa descontentamento aos clientes.

Uma possível solução para os donos de bares é prover um mecanismo para que todos os clientes possam reservar suas mesas antecipadamente, porém, dado a alta concorrência pelas mesas, já se pode deduzir que construir um sistema de reserva não é uma tarefa trivial, pois caso o desenvolvedor não se atente aos requisitos não funcionais, mais de um cliente poderá reservar a mesma mesa, ocasionado o que conhecemos como Anomalia da Atualização Perdida (Lost Update Anomaly).

Anomalia de Atualização Perdida é causada por Race Conditions ou Condições de Corrida em português. Race Conditions é um problema classico da Ciência da Computação que indica que se dois ou mais processos, tentam acessar um recurso simultaneamente, após varios acessos não podemos definir o estado do recurso, o que causa um problema de inconsistência.

Para identificar Race Conditions devemos nos atentar se o algoritmo da sua solução segue o seguinte padrão: ler - processar- escrever. Isto porque durante a janela de tempo entre ler e escrever, outro processo pode atualizar o estado do recurso, causando inconsistência.

Para entender melhor vamos realizar o seguinte exercício de imaginação. Imagine que dado o modelo de domínio abaixo, dois usuários tentem reservar a mesma mesa paralelamente.

Image description

Usuario 1 realiza a leitura do registro de uma mesa, e realizaria o processamento da lógica de reserva. Usuario 2 também executa a leitura para o mesmo registro de mesa, e obtêm que a mesa ainda está disponível para reserva. Usuario 1 conclui a lógica de reserva, e o Usuario 2 não é notificado que o status de disponibilidade da mesa mudou, portanto, a instância em memória da mesa que o Usuario 2 possui ainda indica que a mesa está disponível para reserva e dado a isso ele também consegue reservar a mesa.

fluxo de reserva com race conditions

O diagrama acima pode ser resumido da seguinte forma:

  1. Usuario 1 faz a consulta pela mesa 1, e recebe como resposta que a mesa esta disponivel.
  2. Usuario 2 também consulta a mesa 1, e recebe que a mesa esta disponivel.
  3. Usuario 1 procede com fluxo de reserva, e consegue reservar a mesa.
  4. Usuario 2 contém uma versão em memória que indica que a mesa está disponível, por tanto consegue progredir na reserva, agora tanto Usuario 1 quanto Usuario 2 tem uma reserva para mesa 1.

Existem diversas soluções para o problema de Race Conditions, uma delas é a aplicação do algoritmo de sessão critica, que pode ser resumido em, um processo obtem um bloqueio (lock) modifica o recurso, e libera o bloqueio para que outro processo consiga executar suas alterações.

No mundo Java uma solução popular para Race Conditions é a fazer com que a lógica de reserva fique encapsulada em um método com a palavra-chave synchronized. O que proveria a sincronização de acesso ao recurso ao nível de Thread da JVM. O problema desta abordagem é que por requisitos de escalabilidade ou disponibilidade podem existir mais de uma instância da aplicação, indicando a necessidade de um mecanismo de lock distribuído.

Ferramentas como Zookeeper, MongoDb e Redis provem mecanismos para obter bloqueios distribuídos, porém, se em sua infraestrutura já exista um banco de dados relacional, e caso todas as instâncias da sua aplicação acessem este banco é possível se favorecer dos mecanismos de controle de concorrência do banco.

Os Bancos de dados relacionais oferecem suporte a bloqueios exclusivos, adquiridos para evitar conflitos entre operações de escrita. Dado que um bloqueio é adquirido por uma transação, as demais transações que tentem acessar o mesmo recurso serão obrigadas a aguardar que a transação detentora do bloqueio seja confirmada (COMMIT) ou revertida (ROLLBACK).

Ao inserir um bloqueio exclusivo em uma consulta é utilizado a estratégia de Pessimistic Locking ou Bloqueio Pessimista em português.

Obtendo Pessimistic Locking em seu banco de dados relacional

Os bloqueios pessimistas podem ser adquiridos de maneira implícita ou explicita. Implícita quando alguma instrução SQL UPDATE ou DELETE são disparadas, para que o estado do registro não seja modificado um bloqueio é adquirido. Explicita é quando de maneira declarativa o usuário do SGBD solicita que a instrução SQL SELECT se comporte como uma operação UPDATE, desta forma outras transações são impedidas de escrever, deletar ou adquirir um bloqueio para o registro.

Para solicitar um bloqueio exclusivo em um PostgreSQL basta escrever uma operação SQL SELECT com as cláusulas FOR UDPATE.

  SELECT *
    FROM mesa m
   WHERE m.id = 1
     FOR UPDATE
Enter fullscreen mode Exit fullscreen mode

Existem alguns bancos como postgreSQL que oferecem suporte a um bloqueio compartilhado, este bloqueio permite que apenas a transação dentetora do bloqueio solicite um bloqueio exclusivo.
Para solicitar um bloqueio compartilhado em um PostgreSQL basta escrever uma operação SQL SELECT com as cláusulas FOR SHARE.

 SELECT *
   FROM mesa m
  WHERE m.id = 1
    FOR SHARE

Construindo o mecanismo de bloqueio exclusivo com Spring Data e JPA/Hibernate

O modulo Spring Data JPA oferece suporte a especificação JPA e utiliza Hibernate como implementação padrão, isto significa que nós podemos nos aproveitar dos mecanismos de controle de concorrência da JPA/Hibernate.

Os tipos de bloqueios suportados pela JPA/Hibernate estão descritos no enum LockModeType. Abaixo está descrito quais são as propriedades responsáveis por solicitar bloqueio exclusivo ou compartilhado:

  • LockModeType.PESSIMISTIC_READ: esta propriedade indica que um bloqueio compartilhado deve ser adquirido.
  • LockModeType.PESSIMISTIC_WRITE: esta propriedade indica que um bloqueio exclusivo deve ser adquirido.

EntityManager oferece a possibilidade de realizar um bloqueio através da consulta por id. O método find() oferece suporte para através do argumento opcional lockMode que recebe um tipo de lock através do LockModeType.

Mesa mesa = entityManager.find(Mesa.class, mesaId, LockModeType.PESSIMISTIC_WRITE);
Enter fullscreen mode Exit fullscreen mode

Observe o sql gerado.

SELECT m FROM Mesa m WHERE m.id = :id FOR UPDATE
Enter fullscreen mode Exit fullscreen mode

Outras formas de solicitar um lock através da EntityManager são pelo método lock(), e outra alternativa é ao criar uma Query criada através do método createQuery() informar a estratégia através do método setLockMode().

Caso você utilize os repositories (Repository) da Spring Data um lock pode ser solicitado por uma consulta através da anotação @Lock(lockMode = LockModeType.PESSIMISTIC_WRITE). No exemplo a baixo é implementado uma consulta por id obtendo um bloqueio exclusivo.

public interface MesaRepository extends JpaRepository<Mesa,Long> {
    @Lock(LockModeType.PESSIMISTIC_WRITE)
    @Query("SELECT m FROM Mesa m WHERE m.id = :id")
    Optional<Mesa> findByIdWithPessimisticLock(Long id);
}

Enter fullscreen mode Exit fullscreen mode

Quando optamos por construir nosso controle de concorrência utilizando um bloqueio exclusivo estamos implementando a estratégia de Pessimistic Locking ou Bloqueio Pessimista em português. Isso se deve a estratégia de bloqueio que acredita que a melhor forma de lidar com conflitos é garantindo que eles não existam.

Construindo o Serviço para Reserva

Durante a construção do serviço de reserva é o momento onde iremos implementar uma sessão critica através da estratégia de Pessimistic Locking.

A primeiro momento é necessário que esteja bem claro que cada transação é um processo independente, e que o problema de Race Condition acontece entre a leitura do registro e a persistência da operação, ou seja, durante o período que uma transação esta processando suas operações outra transação pode alterar o estado do registro.
Então o que precisa ser feito é que dado um contexto transacional é necessário que o registro da Mesa a ser reservada obtenha um bloqueio exclusivo.

Dado isso uma possível implementação para esta logica é:

@RestController
public class ReservarMesaController {
    private final MesaRepository mesaRepository;
    private final UsuarioRepository usuarioRepository;
    private final ReservaRepository reservaRepository;

    public ReservarMesaController(MesaRepository mesaRepository, UsuarioRepository usuarioRepository, ReservaRepository reservaRepository) {
        this.mesaRepository = mesaRepository;
        this.usuarioRepository = usuarioRepository;
        this.reservaRepository = reservaRepository;
    }

    @PostMapping("/mesas/{id}/reservas")
    @Transactional
    public ResponseEntity<?> reservar(
            @PathVariable(value = "id") Long mesaId,
            @RequestBody ReservaMesaRequest request,
            UriComponentsBuilder uriBuilder
    ) {
        Mesa mesa = mesaRepository.findByIdWithPessimisticLock(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

Os demais detalhes do código acima estão disponíveis no seguinte repositório.

A estratégia de Pessimistic Locking também pode trazer algumas desvantagens, caso sua tabela tenha um alto volume de acesso para operações de atualização e/ou remoção, existe a chance de que varias linhas fiquem bloqueadas, e o impacto disto é que diversas transações ficaram aguardando para serem executadas, o que resultaria em gargalos no banco que se propagariam por toda a aplicação.

Conclusão

Sistemas que envolvem reservas de recursos são fortes candidatos a sofrem com condições de corrida, isto porquê o espaço de tempo entre a leitura de um recurso e a escrita da solução é suficiente para que outro processo altere o estado do recurso causando inconsistência.

Problema de Race Conditions podem ser solucionados através da sincronização de acesso ao recurso. Para sincronizar o acesso a um determinado recurso podemos implementar uma sessão critica, que defina que apenas um processo detenha permissão suficiente para alterar o estado do recurso por vez.

Os bancos de dados relacionais oferecem mecanismos para controle de concorrência, que podem ser utilizados através das APIs da JPA/Hibernate. Sobre tudo implementar um mecanismo de controle de concorrência utilizando a estratégia de Pessimistic Lock é uma tarefa que exige atenção e cuidado, pois o uso de bloqueios exclusivos de maneira imatura podem causar uma baixa produtividade de transações o que afetaria a performance da aplicação.

Referências

  1. Race conditions
  2. Pessimistic Locking com Vlad Mihalcea
  3. Pessimistic Locking com Baeldung
  4. Select FOR Update e For Share em PostgreSQL Doc
💖 💪 🙅 🚩
jordihofc
Jordi Henrique Silva

Posted on August 1, 2022

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related