Contexto #
Trabalhando em um projeto Spring Data JPA + Hibernate me deparei com algo semelhante a isso:
// SomeClass.java file
@Component
public SomeClass {
private final CoolJpaRepository coolJpaRepository;
private final AnotherClass anotherClass;
public SomeClass(
final CoolJpaRepository coolJpaRepository,
final AnotherClass anotherClass
) {
this.coolJpaRepository = coolJpaRepository;
this.anotherClass = anotherClass;
}
@Transactional
public void doSomething() {
// ...
coolJpaRepository.findByCustomField();
anotherClass.doAnotherThing();
// ...
}
}
// AnotherClass.java file
@Component
public AnotherClass {
private final CoolJpaRepository coolJpaRepository;
public AnotherClass(final CoolJpaRepository coolJpaRepository) {
this.coolJpaRepository = coolJpaRepository;
}
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void doAnotherThing() {
// ...
coolJpaRepository.findByCustomField();
// ...
}
}
Analisando o exemplo #
Primeiro, temos uma classe chamada SomeClass que depende tanto de um repositório JPA chamado CoolJpaRepository quanto de outra classe gerenciada pelo Spring chamada AnotherClass.
@Component
public SomeClass {
private final CoolJpaRepository coolJpaRepository;
private final AnotherClass anotherClass;
public SomeClass(
final CoolJpaRepository coolJpaRepository,
final AnotherClass anotherClass
) {
this.coolJpaRepository = coolJpaRepository;
this.anotherClass = anotherClass;
}
// ...
}
Esta classe tem um método que executa dentro de uma transação e chama o método findByCustomField do repositório JPA e doAnotherThing de AnotherClass.
@Transactional
public void doSomething() {
// ...
coolJpaRepository.findByCustomField();
anotherClass.doAnotherThing();
// ...
}
Quando olhamos para a classe AnotherClass, vemos que ela também depende de CoolJpaRepository.
@Component
public AnotherClass {
private final CoolJpaRepository coolJpaRepository;
public AnotherClass(final CoolJpaRepository coolJpaRepository) {
this.coolJpaRepository = coolJpaRepository;
}
// ...
}
E chama findByCustomField em uma nova transação (por causa do @Transactional(propagation = Propagation.REQUIRES_NEW)).
@Transactional(propagation = Propagation.REQUIRES_NEW)
public void doAnotherThing() {
// ...
coolJpaRepository.findByCustomField();
// ...
}
Em essência, temos a mesma chamada de método do repositório (findByCustomField) em diferentes classes que, mesmo executando em transações separadas, são partes do mesmo fluxo.
O problema é que toda vez que executamos este fluxo a consulta relacionada ao método findByCustomField será executada duas vezes, isso, é claro, presumindo que JPA/Hibernate não tenha algum tipo de otimização de performance implementada…
Apresentando o Persistence Context #
O Persistence Context atua como um cache entre a aplicação e o banco de dados, por essa razão sendo também conhecido como cache de primeiro nível (ou first-level cache).
Este cache é criado a cada nova transação e garante que, dado um id único, uma e somente uma entidade relacionada estará dentro dele, garantindo mudanças consistentes na entidade e permitindo leituras repetidas (repeatable-reads) de dados já carregados.
Ele implementa uma estratégia de cache conhecida como write-behind, que basicamente significa que as mudanças na entidade são primeiro armazenadas no cache e, em um segundo momento, são “traduzidas” para operações de escrita que são enviadas em lote para o banco de dados.
Quando usando a especificação JPA o EntityManager gerencia o Persistence Context, enquanto que ao usar diretamente o Hibernate utilizamos o objeto Session.
Isso ajuda com nosso problema inicial? #
Dado que leituras repetidas são uma funcionalidade do Persistence Context podemos usar a nosso favor em nosso problema inicial? Infelizmente, não por enquanto.
Como explicado acima, temos um novo Persistence Context por transação e em nosso exemplo temos duas transações diferentes no mesmo fluxo graças ao @Transactional(propagation = Propagation.REQUIRES_NEW). Podemos alterar o codigo da seguinte forma:
@Component
public SomeClass {
private final CoolJpaRepository coolJpaRepository;
private final AnotherClass anotherClass;
public SomeClass(
final CoolJpaRepository coolJpaRepository,
final AnotherClass anotherClass
) {
this.coolJpaRepository = coolJpaRepository;
this.anotherClass = anotherClass;
}
@Transactional
public void doSomething() {
// ...
coolJpaRepository.findByCustomField();
anotherClass.doAnotherThing();
// ...
}
}
@Component
public AnotherClass {
private final CoolJpaRepository coolJpaRepository;
public AnotherClass(final CoolJpaRepository coolJpaRepository) {
this.coolJpaRepository = coolJpaRepository;
}
@Transactional // REMOVIDO O PROPAGATION
public void doAnotherThing() {
// ...
coolJpaRepository.findByCustomField();
// ...
}
}
Com o código acima, tanto doSomething quanto doAnotherThing compartilharão o mesmo limite de transação e, consequentemente, o mesmo Persistence Context.
Com essas mudanças, poderíamos esperar o seguinte comportamento: a primeira chamada de findByCustomField popularia o cache, e a segunda chamada acionaria uma leitura repetida em vez de acessar o banco de dados. Na realidade, ainda veremos duas consultas sendo feitas ao banco de dados ao executar o exemplo acima.
Nunca é tão simples…
Queries personalizadas e o cache de primeiro nível #
Ao lidar com consultas JPQL/HQL personalizadas ou SQL nativo, o Hibernate não verifica o cache de primeiro nível para entidades relacionadas a essas consultas, indo direto para o cache de segundo nível (se habilitado) ou para o banco de dados.
Isso explica por que no exemplo o método findByCustomField() executa duas consultas mesmo quando ambas as chamadas acontecem no mesmo limite transacional. Como por baixo dos panos o Spring Data JPA está gerando uma consulta JPQL a partir do método, ele não tem o benefício de obter a entidade do Persistence Context, mesmo se esta entidade já estiver carregada!
A exceção é quando não tentamos obter entidades através de consultas, mas sim usando métodos como EntityManager.find ou Session.load. Ambos estes métodos interagem diretamente com o Persistence Context e obtêm a entidade através do id associado a ela. No Spring Data JPA podemos usar o método findById para obter o mesmo resultado, já que ele é implementado com base no EntityManager.find.
Para tornar mais concreto, se tivéssemos algo como:
@Component
public SomeClass {
private final CoolJpaRepository coolJpaRepository;
private final AnotherClass anotherClass;
public SomeClass(
final CoolJpaRepository coolJpaRepository,
final AnotherClass anotherClass
) {
this.coolJpaRepository = coolJpaRepository;
this.anotherClass = anotherClass;
}
@Transactional
public void doSomething() {
// ...
coolJpaRepository.findById(); // ALTERADO PARA findById
anotherClass.doAnotherThing();
// ...
}
}
@Component
public AnotherClass {
private final CoolJpaRepository coolJpaRepository;
public AnotherClass(final CoolJpaRepository coolJpaRepository) {
this.coolJpaRepository = coolJpaRepository;
}
@Transactional
public void doAnotherThing() {
// ...
coolJpaRepository.findById(); // ALTERADO PARA findById
// ...
}
}
O banco de dados receberia a primeira requisição do findById e salvaria a entidade no cache de primeiro nível. Na segunda chamada de findById, o Persistence Context seria verificado para entidades existentes e, como encontraria aquela que acabamos de carregar, nenhuma nova consulta seria feita ao banco de dados.
Conclusão #
Como podemos ver, o Persistence Context pode nos ajudar a diminuir tempos de leitura no BD evitando a execução de consultas, mas apenas para casos de uso específicos onde verificamos diretamente o cache de primeiro nível.
Se precisarmos de queries personalizadas as entidades carregadas são ignoradas. A única maneira de evitar uma requisição para o banco de dados nesse caso seria implementando um cache de segundo nível.
Então por que temos leituras repetidas? No próximo artigo pretendo discutir esta questão, como as entidades são realmente gerenciadas no cache de primeiro nível e os possíveis riscos de ter leituras repetidas.
Fiquem ligados 👀.