Controle transacional
Deixando um pouco de lado o Spring Cloud e outros frameworks mais avançados, as vezes perdemos de vista o mais básico do dia a dia. Uma vez participei de uma entrevista para uma vaga de Java. Depois de explicar minha experiência na área, ele olhou pra mim e falou que eu não era um desenvolvedor Java, mas um desenvolvedor de banco de dados que usava Java para acessas aos dados. Reconheço que fiquei meio cabreiro, mas ele estava certo. Uma porcentagem altíssima de aplicações corporativas tem como principal função armazenar e acessar dados num banco, relacional ou não. Quando entrevisto candidatos para as vagas Java na minha empresa, o conhecimento sobre acesso a dados é fundamental. Os frameworks abstraem muitas ações para facilitar o desenvolvimento, porém isso faz que com que o desenvolvedor não entenda exatamente o que acontece por embaixo dos panos.
Configuração básica
Criei um repositório para ilustrar o post:
> git clone https://github.com/iundarigun/transactional.git
Na pasta basic, há um projeto com uma configuração básica de acesso a dados com spring-data. Existem duas entidades , User e Bill. O usuário tem uma lista de faturas. O banco é mysql (pode ver no README.md da raiz como subir um mysql em Docker). Os dados são deletados e criados quando o projeto inicia.
Criamos um endpoint que recupera todos os usuários do banco e soma todos os valores das faturas.
Analisemos os logs após a chamada:
2018–03–04 15:14:03.566 INFO 20236 — — [nio-8080-exec-6] b.c.d.t.basic.service.UserService : M=getTotalAmount, start
2018–03–04 15:14:03.665 INFO 20236 — — [nio-8080-exec-6] o.h.h.i.QueryTranslatorFactoryInitiator : HHH000397: Using ASTQueryTranslatorFactory
Hibernate: select user0_.id as id1_1_, user0_.document as document2_1_, user0_.name as name3_1_ from user user0_
2018–03–04 15:14:04.217 INFO 20236 — — [nio-8080-exec-6] b.c.d.t.basic.service.UserService : M=getTotalAmount, totalUsers=10
Hibernate: select billlist0_.user_id as user_id5_0_0_, billlist0_.id as id1_0_0_, billlist0_.id as id1_0_1_, billlist0_.reference_date as referenc2_0_1_, billlist0_.type as type3_0_1_, billlist0_.user_id as user_id5_0_1_, billlist0_.value as value4_0_1_ from bill billlist0_ where billlist0_.user_id=?
.....................Hibernate: select billlist0_.user_id as user_id5_0_0_, billlist0_.id as id1_0_0_, billlist0_.id as id1_0_1_, billlist0_.reference_date as referenc2_0_1_, billlist0_.type as type3_0_1_, billlist0_.user_id as user_id5_0_1_, billlist0_.value as value4_0_1_ from bill billlist0_ where billlist0_.user_id=?
2018–03–04 15:14:04.482 INFO 20236 — — [nio-8080-exec-6] b.c.d.t.basic.service.UserService : M=getTotalAmount, totalAmount=5216.33
Funcionou como esperado. Nosso repositório trouxe os usuários e, quando percorreu as faturas, trouxe-as do banco, seguindo a configuração LAZY da lista de faturas na entidade User.
Agora, vamos ver outro endpoint. Temos um cara que recebe uma lista de documentos e persiste no banco (tá, não seria um exemplo muito real pois gera de forma automática alguns dados, mas é só para ilustrar o problema). Passamos uma lista de 5 números e coloca o documento de um usuário existente no meio desses 5 (O documento é chave única).
Quando rodar o insert deveríamos tomar um erro, pois configuramos na entidade que o campo document deve ser único:
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into user (document, name, id) values (?, ?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into user (document, name, id) values (?, ?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into user (document, name, id) values (?, ?, ?)
Hibernate: select next_val as id_val from hibernate_sequence for update
Hibernate: update hibernate_sequence set next_val= ? where next_val=?
Hibernate: insert into user (document, name, id) values (?, ?, ?)
2018-03-06 16:23:09.033 WARN 7093 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper : SQL Error: 1062, SQLState: 23000
2018-03-06 16:23:09.033 ERROR 7093 --- [nio-8080-exec-2] o.h.engine.jdbc.spi.SqlExceptionHelper : Duplicate entry '75726640842' for key 'UK_hhvt9g0ib1o34svqy4qc71gkq'
2018-03-06 16:23:09.034 INFO 7093 --- [nio-8080-exec-2] o.h.e.j.b.internal.AbstractBatchImpl : HHH000010: On release of batch it still contained JDBC statements
2018-03-06 16:23:09.035 ERROR 7093 --- [nio-8080-exec-2] o.h.i.ExceptionMapperStandardImpl : HHH000346: Error during managed flush [org.hibernate.exception.ConstraintViolationException: could not execute statement]
2018-03-06 16:23:09.042 ERROR 7093 --- [nio-8080-exec-2] o.a.c.c.C.[.[.[/].[dispatcherServlet] : Servlet.service() for servlet [dispatcherServlet] in context with path [] threw exception [Request processing failed; nested exception is org.springframework.dao.DataIntegrityViolationException: could not execute statement; SQL [n/a]; constraint [UK_hhvt9g0ib1o34svqy4qc71gkq]; nested exception is org.hibernate.exception.ConstraintViolationException: could not execute statement] with root cause
com.mysql.jdbc.exceptions.jdbc4.MySQLIntegrityConstraintViolationException: Duplicate entry '75726640842' for key 'UK_hhvt9g0ib1o34svqy4qc71gkq'
Beleza, a unique constraint funcionou. Mas o problema é que os usuários antes da exception foram persistidos, e esse comportamento pode não ser o desejado.
Delimitando a transação
Código na pasta transaction
Para evitar esse problema, podemos (e seria aconselhável) anotar o método como @Transactional do spring. Isso marca o contexto transacional e caso acontecer um erro na execução, acontece o rollback no banco.
Um ponto importante é que um transação é commitada automaticamente sem precisar salvar os objetos. Isso significa que se recuperamos um usuário e alteramos alguma coisa, quando sair do contexto, as alterações serão persistidas automaticamente no banco.
Por estranho que pareça, como a lista de faturas é persistida em qualquer alteração do usuário, quando sair do método addBill a transação é commitada, então precisamos tomar cuidado para evitar alterações indesejadas.
Nesse caso (sei, meio idiota…) o usuário será modificado no banco adicionando Sr(a). na frente de cada nome dos usuários recuperados no método getUser.
{
"id": 1,
"name": "Sr(a). Sr(a). Sr(a). Bianca Oliveira Neto",
"document": "10967286073"
}
Para resolver isso, basta em colocar o propriedade readOnly=true na anotação de transação:
Agora.. temos outro problema. Vamos imaginar que criamos um método na classe User para recuperar o total das faturas, para serem retornadas na pesquisa de usuários:
Na resposta do webService volta o somatório… porém a consulta no banco é feita fora do método marcado como transacional. Se realmente queremos ter o controle, não deveria acontecer. O Spring Boot por padrão deixa a sessão aberta para acesso ao banco. Se queremos evitar isso, devemos configurar nossa aplicação para não permitir a sessão aberta, no application.yml:
jpa:
open-in-view: false
Caso queremos isso funcionando, temos várias estratégias (Eager, fetch, initialize do hibernate, etc) mas não são o foco desse post.
Controle de exceptions
Código na pasta advanced
Agora iremos complicar os cenários. Você sabe afirmar com total certeza em que condições acontecem os rollbacks? Em que casos não? Vamos analisar uma por uma. No projeto advanced tem todos os exemplos, e para ilustrar, recebemos uma lista de faturas que serão persistidas no banco. Cada fatura tem uma validação em que só permite faturas com datas passadas:
[
{
"date": "2017-01-10",
"type": "INTERNET",
"value": 22.50
},
{
"date": "2018-01-10",
"type": "TV",
"value": 90.50
},
{
"date": "2018-06-10",
"type": "INTERNET",
"value": 42.50
},
{
"date": "2018-01-10",
"type": "ENERGY",
"value": 12.50
}
]
1- CheckedException
Primeiro, o código:
Nossa TransactionalException extende de Exception, então é uma exceção checked. Se passamos a lista anterior, no terceiro item é lançada a Exception. Na minha cabeça era para dar rollback e não persistir nada… mas o comportamento para Checked Exception é outro… O processo vai persistir os dois primeiros elementos pois ao sair do contexto transacional (marcado pelo @transactional) ele entende que deve commitar (mesmo saindo por conta de uma exception.
Para forçar o rollback, precisamos especificar que queremos isso para o tipo de exception.
Podemos marcar a exceção especifica ou marcar a excepção pai.
2- UncheckedException
Nesse caso sim o comportamento é o esperado, pois no terceiro item teremos uma RuntimeException (unchecked) e a transação será marcada para rollback.
3- Capturando a exceção
Um caso que gera muita dúvida é quando a exceção é capturada. Inicialmente, temos o seguinte código:
Como a exceção é capturada, os dois primeiros itens da lista e o último serão persistidos no banco, descartando só o terceiro item.
Porém, um caso talvez não tão conhecido é o seguinte:
O cenário é quando a validação acontece em outra classe, injetada no service. Repare que a chamada é feita via validationService.
Incrivelmente nesse caso a exceção, mesmo capturada, provoca um rollback provavelmente não desejada. Isso acontece porque o método do ValidationService está marcado como transacional. Ao sair do método, ele entende que foi por UncheckedException e marca a transação como rollback. O método addBillCatchingProxyUncheckedException vai continuar a execução, porém quando sair do método, vai tentar commitar e a transação já foi marcado para rollback, não permitindo commitar as alterações.
Se queremos evitar o rollback, podemos especificar isso na anotação para evitá-lo, mas tomando muito cuidado pois pode ter cenários onde seja desejável o rollback no método que chamou.
4- Persistindo a cada iteração
Vamos mudar a abordagem agora. Imagina que queremos persistir no banco a cada iteração do for. Isso pode fazer sentido para refletir antes as mudanças, para paralelizar execuções com transações concorrentes, etc. Temos a opção de marcar a transação como REQUIRES_NEW. Tiramos as validações e damos um RuntimeException no final:
A nossa surpresa é que isso não funciona. Assim que lançar a exceção é feito o rollback de todos os objetos persistidos no método createBill. O motivo é simples. O @Transactional funciona por orientação a aspectos. Então ele precisa passar por um proxy para poder ser processado. Então, caso querermos fazer isso, precisamos “autoinjetar” o serviço nele mesmo e chamar o método pelo bean gerenciado pelo spring. Vale lembrar que isso não funcionava nas primeiras versões do spring (não sei a partir de qual…)
Agora sim temos a nova transação aberta a cada chamada ao método e a exceção lançada no final não provoca rollback nas alterações feitas.
Transações concorrentes
Código na pasta lock
Mudamos um pouco de foco agora. O último assunto do post (está ficando grande né…) é sobre transações concorrentes. Vejam o seguinte código:
Imagine que invocamos o método addSlowValue passando por parâmetros o usuário 1 e o valor 100,00. No momento seguinte, fazemos uma chamada ao método addValue passando o o usuário 1 e o valor 450,00. Vamos imaginar que no banco o registro correspondente ao usuário 1 tem 200,00. O que acontece nesse cenário?
- O addSlowValue lê a entidade do banco e altera o valor para 300,00.
- O addValue lê a entidade do banco e altera o valor para 650,00.
- O addValue persiste a alteração no banco, deixando 650,00 no registro.
- O addSlowValue persiste a alteração no banco, deixando 300,00 no registro.
É evidente que estamos perdendo dados e nem percebemos. Precisamos alterar o comportamento do nosso sistema para nos alterar dessas situações. Tem várias soluções possíveis, mas talvez a menos invasiva é lock optimista.
Conceitualmente é bem simples: De algum jeito identificamos se o registro do banco foi alterado desde que o consultamos, e caso afirmativo, lançamos uma exception. O JPA fornece uma forma simples de fazer essa implementação. A estratégia é guardar um campo no registro com um contador de versão do mesmo:
Com essa alteração, quando executar o exemplo anterior, no último passo o sistema lança uma exception.
org.hibernate.StaleStateException: Batch update returned unexpected row count from update [0]; actual row count: 0; expected: 1
Agora é coisa nossa decidir qual é a criticidade desse erro e como tratá-lo. Vai depender do comportamento esperado do nosso sistema, mas pelo menos não teremos alterações inesperadas no nosso banco.
Conclusão
Bom, o post ficou maior que o esperado, então se leu até aqui está de parabéns… Espero que ajude a dar um pouco de luz nesse mundo mais importante do que parece, mas não tão complexo se conhecer os detalhes mais relevantes.
Gostou? Faltou alguma coisa? Comenta ai! Feedback sempre é bem-vindo!