Mitigando problema de concorrência com PostgreSQL e Skip Locked
No sistema contábil da Conta Azul temos um processo periódico, que roda durante o dia todo, para efetuar a depreciação dos ativos imobilizados cadastrados de nossos clientes. Fazemos isso para ter informações em tempo real para os contadores que usam a plataforma.
Ao mesmo tempo, temos a funcionalidade de edição de ativo imobilizado que, a depender das alterações realizadas, precisa recalcular a depreciação desse bem. Um exemplo seria a alteração do tipo do bem que vai impactar no tempo de depreciação do bem e consequentemente os valores que foram depreciados até então.
Se esses processos rodassem ao mesmo tempo, em concorrência, poderíamos ter problemas nos valores a serem contabilizados e precisávamos de uma alternativa para isso.
Existem algumas abordagens possíveis para resolver o mesmo problema, mas a opção que adotamos foi utilizar select for update com skip locked do PostgreSQL.
O que é um select for update?
O select for update é uma forma de realizar um lock pessimista no banco de dados. Basicamente você está dizendo para o banco de dados que você está recuperando um registro que será alterado dentro da mesma transação e, por esse motivo, o banco de dados precisa fazer esperar todos os outros que estão fazendo a mesma coisa para o mesmo registro.
O que é o skip locked?
Skip Locked é uma instrução usada juntamente com o for update
para indicar ao banco de dados que você não quer esperar até que o recurso esteja liberado, você quer ignorar que esse registro existe enquanto ele está com lock.
Solução
A ideia para solucionar o problema basicamente é evitar que o job que roda periodicamente pegue um registro que está em edição no momento.
Caso o registro esteja em processo de persistência dos dados por outra funcionalidade, o job não “enxergará” esse mesmo registro e na próxima iteração, com a persistência já realizada, vai voltar a enxergar e realizará a depreciação caso necessário.
Quando o processo de depreciação estiver atuando exatamente no mesmo registro em persistência da edição, esse processo de edição ficará aguardando a finalização do job. Como o job lida com cada registro numa transação separada e o processo é muito rápido, essa espera tende a ser muito pequena.
Para fazer isso tudo funcionar utilizamos os recursos do Hibernate para indicar o LockMode
e também que deve ser realizado um skip locked
. As alterações necessárias foram poucas, mas foi difícil encontrar essas informações de forma fácil para a implementação.
Como utilizamos Spring Boot nesse projeto, basta fazer algumas anotações para fazer as consultas terem esse comportamento.
A primeira alteração que fizemos foi modificar o método de consulta já existente que busca os registros para o job para realizar um select for update
com skip locked
. A anotação @Lock(LockModeType.PESSIMISTIC_WRITE)
faz com que o Hibernate adicione a instrução for update
ao final da consulta montada.
O @QueryHints
indicando um timeout com valor de -2
(valor da constante SKIP_LOCKED
) faz o Hibernate adicionar a instrução skip locked
ao final da consulta.
Para que o Hibernate consiga colocar a instrução skip locked
é necessário estar com o dialeto correto configurado. Utilize o dialeto PostgreSQL95Dialect
ou de uma versão mais recente.
Assim é como fica a configuração no application.yaml
no Spring Boot:
spring:
jpa:
database-platform: org.hibernate.dialect.PostgreSQL95Dialect
Também alteramos a consulta do registro para edição para fazer select for update
para que, caso o registro esteja sendo usado pelo job, ele espere o processo finalizar. Nesse caso, não usamos o skip locked
por que a intenção é ficar esperando mesmo.
Porém, a estratégia nesse método foi diferente, Nesse caso, como a consulta faz um left join fetch
para retornar também registros filhos, temos um problema com a instrução padrão que o Hibernate gera. Por padrão, o Hibernate pega todos os aliases gerados e na instrução coloca algo como select ... for update of se, children
. Fazendo isso estou gerando lock para ambos registros do SomeEntity e a entidade de children
. Como é feito um left join
o PostgreSQL reclama dessa consulta pois não consegue fazer lock de um possível retorno null desses casos.
Por esse motivo, tivemos que usar uma abordagem diferente dizendo ao Hibernate que queremos fazer lock em um alias específico.
Para isso temos que usar o query hint org.hibernate.lockMode.alias
onde o alias
é de fato o alias que você definiu na sua consulta.
Feito isso, conseguimos o objetivo que desejávamos. Quando o job roda e temos uma edição em andamento ele simplesmente ignora o registro. Quando uma edição ocorre e o registro está em processo de depreciação no mesmo momento, ele aguarda até que finalize.
Se você utiliza outros banco de dados também tem possibilidade de fazer algo semelhante. Alguns banco de dados como MySQL e SQL Server tem recursos semelhantes ao skip locked.