Controle transacional

Deixando um pouco de lado o Spring Cloud e outros frameworks mais avançados, às 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, o recrutador olhou pra mim e falou que eu não era um desenvolvedor Java, mas um desenvolvedor de banco de dados que usava Java para acessar os 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 debaixo dos panos.

Spring Data
Spring Data

Criei um repositório para ilustrar o post —> git clone

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).

Spring Data
O documento 75726640842 já está no banco.

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.

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 uma 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 mais 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 colocar a 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 queiramos isso funcionando, temos várias estratégias (Eager, fetch, initialize do hibernate, etc), mas não são o foco desse post.

Código na pasta advanced

Agora iremos complicar os cenários. Você sabe afirmar com total certeza em quais condições acontecem os rollbacks? Em que casos não? Vamos analisar uma a 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 específica ou marcar a exceçã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 desejado. 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 marcada para rollback, não permitindo commitar as alterações.

Se quisermos evitar o rollback, podemos especificar isso na anotação para evitá-lo, tomando muito cuidado pois pode ter cenários em que seja desejável o rollback no método que chamou.

4- Persistindo a cada iteração

Vamos mudar a abordagem agora. Imagine que queremos persistir no banco a cada iteração do for. Isso pode fazer sentido para refletir antes das 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 quisermos 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.

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 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 otimista.

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.

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? Comente aí! Feedback sempre é bem-vindo!

[Texto publicado por Oriol Canalias

Equipe itbox.online

Entregamos soluções de TI que permitem que você esteja sempre online, atualizado e seguro!

Site: https://itbox.online

Deixe uma resposta

O seu endereço de e-mail não será publicado. Campos obrigatórios são marcados com *