Spring Data JPA: o que é bom e o que é ruim

Filho veio ao pai
E perguntou ao bebê
- o que é bom
e o que é ruim

Vladimir Mayakovsky


Este artigo é sobre o Spring Data JPA, especificamente no rake subaquático que conheci no caminho e, é claro, um pouco sobre desempenho.


Os exemplos descritos no artigo podem ser executados no ambiente de teste, acessível por referência .


Nota para aqueles que ainda não se mudaram para o Spring Boot 2

Nas versões do Spring Data JPA 2. *, a interface principal para trabalhar com repositórios, nomeadamente CrudRepository , da qual o JpaRepository é herdado, JpaRepository . Nas versões 1. *, os principais métodos eram assim:


 public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); } 

Em novas versões:


 public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); } 

Então, vamos começar.


selecione t. * de t onde t.id (...)


Uma das consultas mais comuns é uma consulta no formato "selecione todos os registros para os quais a chave se enquadra no conjunto transmitido". Tenho certeza que quase todos vocês escreveram ou viram algo como


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") List<Long> ids); @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids); 

Esses pedidos estão funcionando, são adequados, não há problemas de captura ou desempenho, mas há uma pequena desvantagem completamente discreta.


Antes de abrir o revestimento, tente pensar por si mesmo.

A desvantagem é que a interface é muito estreita para transmitir chaves. "Então o que?" você diz. "Bem, a lista, bem o conjunto, não vejo nenhum problema aqui." No entanto, se observarmos os métodos da interface raiz que recebem muitos valores, em todos os lugares vemos Iterable :


"E daí? E eu quero uma lista. Por que é pior?"
Não é pior, apenas esteja preparado para a aparência de código semelhante em um nível mais alto no seu aplicativo:


 public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); } // public List<BankAccount> findByUserIds(Set<Long> userIds) { List<Long> ids = new ArrayList<>(userIds); return repository.findByUserIds(ids); } 

Esse código não faz nada além de reverter as coleções. Pode acontecer que o argumento para o método seja uma lista, e o método do repositório aceite o conjunto (ou vice-versa), e você apenas precisará reordená-lo para passar na compilação. Obviamente, isso não se tornará um problema no contexto dos custos indiretos da própria solicitação, trata-se mais de gestos desnecessários.


Portanto, é uma boa prática usar o Iterable :


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids); 

Z.Y. Se estamos falando de um método de *RepositoryCustom , faz sentido usar Collection para simplificar o cálculo do tamanho dentro da implementação:


 public interface BankAccountRepositoryCustom { boolean anyMoneyAvailable(Collection<Long> accountIds); } public class BankAccountRepositoryImpl { @Override public boolean anyMoneyAvailable(Collection<Long> accountIds) { if (ids.isEmpty()) return false; //... } } 

Código extra: chaves não duplicadas


Na continuação da última seção, quero chamar a atenção para um equívoco comum:


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids); 

Outras manifestações do mesmo erro:


 Set<Long> ids = new HashSet<>(notUniqueIds); List<BankAccount> accounts = repository.findByUserIds(ids); List<Long> ids = ts.stream().map(T::id).distinct().collect(toList()); List<BankAccount> accounts = repository.findByUserIds(ids); Set<Long> ids = ts.stream().map(T::id).collect(toSet()); List<BankAccount> accounts = repository.findByUserIds(ids); 

À primeira vista, nada de anormal, certo?


Leve o seu tempo, pense por si mesmo;)

As consultas HQL / JPQL do formulário select t from t where t.field in ... acabará por se transformar em uma consulta


 select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …) 

que sempre retornará a mesma coisa, independentemente da presença de repetições no argumento. Portanto, para garantir a exclusividade das chaves não é necessário. Há um caso especial - Oracle, em que pressionar mais de 1000 chaves leva a um erro. Mas se você está tentando reduzir o número de chaves eliminando repetições, deve pensar na razão de sua ocorrência. Provavelmente o erro está em algum lugar acima.


Portanto, em bom código, use Iterable :


 @Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids); 

Samopis


Dê uma olhada neste código e encontre aqui três falhas e um possível erro:


 @Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids); 

Pense um pouco mais
  • tudo já está implementado no SimpleJpaRepository::findAllById
  • solicitação inativa ao passar uma lista vazia (em SimpleJpaRepository::findAllById há uma verificação correspondente)
  • todas as consultas descritas usando @Query são verificadas no estágio de aumentar o contexto, o que leva tempo (diferente de SimpleJpaRepository::findAllById )
  • se o Oracle for usado, quando a coleção de chaves estiver vazia, obteremos o erro ORA-00936: missing expression (o que não acontecerá ao usar SimpleJpaRepository::findAllById , consulte o ponto 2)

Harry potter e chave composta


Dê uma olhada em dois exemplos e escolha o seu preferido:


Número do método vezes


 @Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; } 

Método número dois


 @Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; } 

À primeira vista, não há diferença. Agora tente o primeiro método e execute um teste simples:


 //case for @EmbeddedId @Test public void findAll() { int size = entityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5); } 

No log de consulta (você mantém, certo?) Veremos isso:


 select e.key1, e.key2 from CompositeKeyEntity e where e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? or e.key1 = ? and e.key2 = ? 

Agora segundo exemplo


 //case for @Id @Id @Test public void _findAll() { int size = anotherEntityWithCompositeKeyRepository.findAllById(compositeKeys).size(); assertEquals(size, 5); } 

O log de consulta parece diferente:


 select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? select e.key1, e.key2 from CompositeKeyEntity e where e.key1=? and e.key2=? 

Essa é toda a diferença: no primeiro caso, sempre recebemos 1 solicitação, no segundo - n pedidos.
O motivo desse comportamento está em SimpleJpaRepository::findAllById :


 // ... if (entityInfo.hasCompositeId()) { List<T> results = new ArrayList<>(); for (ID id : ids) { findById(id).ifPresent(results::add); } return results; } // ... 

Qual método é o melhor para você determinar com base na importância do número de solicitações.


CrudRepository extra :: salvar


Freqüentemente no código existe um antipadrão:


 @Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); } 

O leitor está perplexo: onde está o antipadrão? Este código parece muito lógico: obtemos a entidade - atualização - salvamento. Tudo é como nas melhores casas de São Petersburgo. Ouso dizer que chamar CrudRepository::save é supérfluo aqui.


Primeiro: o método updateRate transacional; portanto, todas as alterações na entidade gerenciada são rastreadas pelo Hibernate e se transformam em uma solicitação quando o Session::flush executado, o que nesse código ocorre quando o método termina.


Em segundo lugar, CrudRepository::save dar uma olhada no método CrudRepository::save . Como você sabe, todos os repositórios são baseados no SimpleJpaRepository . Aqui está a implementação do CrudRepository::save :


 @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } } 

Há uma sutileza que nem todos se lembram: o Hibernate funciona através de eventos. Em outras palavras, cada ação do usuário gera um evento na fila e processado, levando em consideração outros eventos na mesma fila. Nesse caso, uma chamada para EntityManager::merge gera um MergeEvent , que é processado por padrão no DefaultMergeEventListener::onMerge . Ele contém uma lógica bastante ramificada, mas simples, para cada um dos estados do argumento da entidade. Em nosso caso, a entidade é obtida do repositório dentro do método transacional e está no estado PERSISTENT (ou seja, essencialmente controlado pela estrutura):


 protected void entityIsPersistent(MergeEvent event, Map copyCache) { LOG.trace("Ignoring persistent instance"); Object entity = event.getEntity(); EventSource source = event.getSession(); EntityPersister persister = source.getEntityPersister(event.getEntityName(), entity); ((MergeContext)copyCache).put(entity, entity, true); this.cascadeOnMerge(source, persister, entity, copyCache); //<---- this.copyValues(persister, entity, entity, source, copyCache); //<---- event.setResult(entity); } 

O diabo está nos detalhes, nomeadamente nos métodos DefaultMergeEventListener::cascadeOnMerge e DefaultMergeEventListener::copyValues . Vamos ouvir o discurso direto de Vlad Mikhalche , um dos principais desenvolvedores do Hibernate:


Na chamada do método copyValues, o estado hidratado é copiado novamente, para que uma nova matriz seja criada de forma redundante, desperdiçando assim ciclos de CPU. Se a entidade tiver associações filhas e a operação de mesclagem também for cascateada de pai para filho, a sobrecarga será ainda maior porque cada entidade filho propagará um MergeEvent e o ciclo continuará.

Em outras palavras, está sendo feito um trabalho que você não pode fazer. Como resultado, nosso código pode ser simplificado enquanto aprimora seu desempenho:


 @Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; } 

Obviamente, é inconveniente ter isso em mente ao desenvolver e revisar o código de outra pessoa, portanto, gostaríamos de fazer alterações no nível da estrutura de arame para que o método JpaRepository::save perca suas propriedades nocivas. Isso é possível?


Sim talvez
 // @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else { return em.merge(entity); } } // @Transactional public <S extends T> S save(S entity) { if (entityInformation.isNew(entity)) { em.persist(entity); return entity; } else if (!em.contains(entity)) { return em.merge(entity); } return entity; } 

Essas mudanças foram realmente feitas em dezembro de 2017:
https://jira.spring.io/browse/DATAJPA-931
https://github.com/spring-projects/spring-data-jpa/pull/237


No entanto, o leitor sofisticado provavelmente já sentiu que algo estava errado. De fato, essa mudança não quebrará nada, mas apenas no caso simples quando não houver entidades filhas:


 @Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; } 

Agora, suponha que seu proprietário esteja vinculado à conta:


 @Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; } 

Existe um método que permite desconectar o usuário da conta e transferi-lo para o novo usuário:


 @Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); } 

O que vai acontecer agora? A verificação de em.contains(entity) retornará true, o que significa que em.merge(entity) não será chamado. Se a chave da entidade User for criada com base na sequência (um dos casos mais comuns), ela não será criada até que a transação seja concluída (ou Session::flush chamada manualmente), ou seja, o usuário estará no estado DETACHED e sua entidade pai ( conta) - no estado PERSISTENTE. Em alguns casos, isso pode quebrar a lógica do aplicativo, e foi o que aconteceu:


02/03/2018 DATAJPA-931 interrompe a fusão com o RepositoryItemWriter


Nesse sentido, a tarefa Reverter otimizações feitas para entidades existentes no CrudRepository :: save foi criada e as alterações foram feitas: Reverter DATAJPA-931 .


Blind CrudRepository :: findById


Continuamos a considerar o mesmo modelo de dados:


 @Entity public class User { @Id Long id; // ... } @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; } 

O aplicativo possui um método que cria uma nova conta para o usuário especificado:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser); //<---- return accountRepository.save(account); } 

Com a versão 2. * o antipadrão indicado pela seta não é tão impressionante - é mais claramente visto nas versões mais antigas:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId)); //<---- return accountRepository.save(account); } 

Se você não vê a falha "a olho nu", dê uma olhada nas consultas:
 select u.id, u.name from user u where u.id = ? call next value for hibernate_sequence insert into bank_account (id, /*…*/ user_id) values (/*…*/) 

A primeira solicitação, obtemos o usuário por chave. Em seguida, obtemos a chave da conta do recém-nascido no banco de dados e a inserimos na tabela. E a única coisa que extraímos do usuário é a chave, que já temos como argumento de método. Por outro lado, BankAccount contém o campo "user" e não podemos deixá-lo vazio (como pessoas decentes, definimos uma restrição no esquema). Desenvolvedores experientes provavelmente já veem uma maneira e comer um peixe, e andar a cavalo faça com que o usuário e a solicitação não:


 @Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId)); //<---- return accountRepository.save(account); } 

JpaRepository::getOne retorna um wrapper sobre a chave que tem o mesmo tipo da "entidade" viva. Este código fornece apenas duas solicitações:


 call next value for hibernate_sequence insert into bank_account (id, /*…*/ user_id) values (/*…*/) 

Quando uma entidade que está sendo criada contém muitos campos com um relacionamento muitos-para-um / um-para-um, essa técnica ajuda a acelerar a economia e reduzir a carga no banco de dados.


Executando consultas HQL


Este é um tópico separado e interessante :). O modelo de domínio é o mesmo e existe uma solicitação:


 @Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id); 

Considere o HQL "puro":


 select count(ba) from BankAccount ba join ba.user user where user.id = :id 

Quando é executada, a seguinte consulta SQL será criada:


 select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ? 

O problema aqui não é imediatamente evidente, mesmo com uma vida sábia e um bom entendimento dos desenvolvedores SQL: inner join por chave de usuário excluirá as contas com user_id ausente da seleção (e, de uma maneira boa, a inserção delas deve ser proibida no nível do esquema), o que significa que não é user_id ingressar na tabela do user . preciso. A solicitação pode ser simplificada (e acelerada):


 select count(ba.id) from bank_account ba where ba.user_id = ? 

Existe uma maneira de obter esse comportamento facilmente em c usando o HQL:


 @Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id); 

Este método cria uma solicitação "Lite".


Resumo de consulta vs. método


Um dos principais recursos do Spring Data é a capacidade de criar uma consulta a partir do nome do método, o que é muito conveniente, especialmente em combinação com o complemento inteligente do IntelliJ IDEA. A consulta descrita no exemplo anterior pode ser facilmente reescrita:


 // @Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id); // long countByUserAccount_Id(Long id); 

Parece ser mais simples, mais curto, mais legível e mais importante - você não precisa examinar a própria solicitação. Li o nome do método - e já está claro o que ele escolhe e como. Mas o diabo está aqui nos detalhes. A consulta final para o método marcado com @Query já vimos. O que acontecerá no segundo caso?


Babah!
 select count(ba.id) from bank_account ba left outer join // <--- !!!!!!! user u on ba.user_id = u.id where u.id = ? 

"Que diabos!?" - o desenvolvedor exclama. Afinal, já vimos isso violinista join não join necessária.


O motivo é prosaico:



Se você ainda não fez o upgrade para as versões corrigidas e a união da tabela diminui a solicitação aqui e agora, não se desespere: existem duas maneiras de aliviar a dor:


  • uma boa maneira é adicionar optional = false (se o circuito permitir):


     @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; } 

  • A maneira mais importante é adicionar uma coluna do mesmo tipo que a chave da entidade User e usá-la em consultas em vez do campo do user :


     @Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id") User user; @Column(name = "user_id", insertable = false, updatable = false) Long userId; } 

    Agora, o request-from-method será melhor:


     long countByUserId(Long id); 


     select count(ba.id) from bank_account ba where ba.user_id = ? 

    o que conseguimos?



Limite de amostragem


Para nossos propósitos, precisamos limitar a seleção (por exemplo, queremos retornar Optional do método *RepositoryCustom ):


 select ba.* from bank_account ba order by ba.rate limit ? 

Agora Java:


 @Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; BankAccount account = em .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .getSingleResult(); return Optional.ofNullable(bankAccount); } 

O código especificado possui um recurso desagradável: no caso em que a solicitação retornou uma seleção vazia, uma exceção será lançada


 Caused by: javax.persistence.NoResultException: No entity found for query 

Nos projetos que vi, isso foi resolvido de duas maneiras principais:


  • try-catch com variações de Optonal.empty() sem exceção Optonal.empty() exceção e retornar Optonal.empty() para maneiras mais avançadas, como passar um lambda com uma solicitação para um método utilitário
  • aspecto no qual os métodos de repositório retornam Optional

E muito raramente, vi a solução certa:


 @Override public Optional<BankAccount> findWithHighestRate() { String query = "select b from BankAccount b order by b.rate"; return em.unwrap(Session.class) .createQuery(query, BankAccount.class) .setFirstResult(0) .setMaxResults(1) .uniqueResultOptional(); } 

EntityManager faz parte do padrão JPA, enquanto a Session pertence ao Hibernate e é IMHO uma ferramenta mais avançada, que geralmente é esquecida.


[Às vezes] melhoria prejudicial


Quando você precisa obter um pequeno campo de uma entidade "grossa", fazemos o seguinte:


 @Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id); 

A solicitação permite que você obtenha um campo do tipo boolean sem carregar toda a entidade (com a adição de um cache de primeiro nível, verificando alterações no final da sessão e outras despesas). Às vezes, isso não apenas não melhora o desempenho, mas vice-versa - ele cria consultas desnecessárias do zero. Imagine um código que execute algumas verificações:


 @Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findIfAvailable(id); } 

Esse código faz pelo menos 2 solicitações, embora a segunda possa ser evitada:


 @Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new); // ... return repository.findById(id) //    .map(BankAccount::isAvailable) .orElseThrow(IllegalStateException::new); } 

A conclusão é simples: não negligencie o cache do primeiro nível, dentro da estrutura de uma transação, apenas o primeiro JpaRepository::findById se refere ao banco de dados, JpaRepository::findById cache do primeiro nível está sempre JpaRepository::findById e está vinculado a uma sessão, que geralmente está vinculada à transação atual.


Testes para jogar (o link para o repositório é fornecido no início do artigo):


  • teste de interface estreita: InterfaceNarrowingTest
  • teste para um exemplo com uma chave composta: EntityWithCompositeKeyRepositoryTest
  • testar excesso CrudRepository::save : ModifierTest.java
  • teste cego CrudRepository::findById ChildServiceImplTest : ChildServiceImplTest
  • teste de left join desnecessário: BankAccountControlRepositoryTest

O custo de uma chamada extra para CrudRepository::save pode ser calculado usando RedundantSaveBenchmark . É lançado usando a classe BenchmarkRunner .

Source: https://habr.com/ru/post/pt441386/


All Articles