Bebé hijo vino a su padre
Y le pregunte al bebe
- lo que es bueno
y que es malo
Vladimir Mayakovsky
Este artículo trata sobre Spring Data JPA, es decir, sobre el rastrillo submarino que conocí en mi camino y, por supuesto, sobre el rendimiento.
Los ejemplos descritos en el artículo se pueden ejecutar en el entorno de prueba, accesible por referencia .
Nota para aquellos que aún no se han mudado a Spring Boot 2En las versiones de Spring Data JPA 2. * la interfaz principal para trabajar con repositorios, concretamente CrudRepository
, de la cual se hereda JpaRepository
, ha JpaRepository
. En las versiones 1. * los métodos principales se veían así:
public interface CrudRepository<T, ID> { T findOne(ID id); List<T> findAll(Iterable<ID> ids); }
En nuevas versiones:
public interface CrudRepository<T, ID> { Optional<T> findById(ID id); List<T> findAllById(Iterable<ID> ids); }
Entonces comencemos.
seleccione t. * desde t donde t.id en (...)
Una de las consultas más comunes es una consulta de la forma "seleccionar todos los registros para los que la clave se encuentra en el conjunto transmitido". Estoy seguro de que casi todos escribieron o vieron 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);
Estas son solicitudes adecuadas y que funcionan, no hay problemas de captura o rendimiento, pero hay un pequeño inconveniente completamente discreto.
Antes de abrir el forro, trata de pensar por ti mismo.La desventaja es que la interfaz es demasiado estrecha para transmitir claves. "¿Y qué?" - usted dice "Bueno, la lista, el conjunto, no veo ningún problema aquí". Sin embargo, si observamos los métodos de la interfaz raíz que toman muchos valores, en todas partes vemos Iterable
:
"¿Y qué? Y quiero una lista. ¿Por qué es peor?"
No peor, solo prepárate para la aparición de código similar en un nivel superior en tu aplicación:
public List<BankAccount> findByUserId(List<Long> userIds) { Set<Long> ids = new HashSet<>(userIds); return repository.findByUserIds(ids); }
Este código no hace más que invertir las colecciones. Puede suceder que el argumento del método sea una lista, y el método del repositorio acepta el conjunto (o viceversa), y solo tiene que volver a invocarlo para pasar la compilación. Por supuesto, esto no se convertirá en un problema en el contexto de los costos generales de la solicitud en sí, se trata más de gestos innecesarios.
Por lo tanto, es una buena práctica usar Iterable
:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Iterable<Long> ids);
Z.Y. Si estamos hablando de un método de *RepositoryCustom
, entonces tiene sentido usar Collection
para simplificar el cálculo del tamaño dentro de la implementación:
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: claves no duplicadas
En la continuación de la última sección, quiero llamar la atención sobre un error común:
@Query("select ba from BankAccount ba where ba.user.id in :ids") List<BankAccount> findByUserIds(@Param("ids") Set<Long> ids);
Otras manifestaciones del mismo error:
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);
A primera vista, nada inusual, ¿verdad?
Tómate tu tiempo, piensa por ti mismo;)Las consultas HQL / JPQL del formulario select t from t where t.field in ...
eventualmente se convertirán en una consulta
select b.* from BankAccount b where b.user_id in (?, ?, ?, ?, ?, …)
que siempre devolverá lo mismo independientemente de la presencia de repeticiones en el argumento. Por lo tanto, para garantizar la unicidad de las claves no es necesario. Hay un caso especial: Oracle, donde presionar más de 1000 teclas conduce a un error. Pero si está tratando de reducir el número de teclas excluyendo repeticiones, entonces debería pensar en el motivo de su aparición. Lo más probable es que el error esté en algún lugar arriba.
Entonces, en un buen 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
Eche un vistazo de cerca a este código y encuentre aquí tres fallas y un posible error:
@Query("from User u where u.id in :ids") List<User> findAll(@Param("ids") Iterable<Long> ids);
Piensa un poco mas- todo ya está implementado en
SimpleJpaRepository::findAllById
- solicitud inactiva al pasar una lista vacía (en
SimpleJpaRepository::findAllById
hay una verificación correspondiente) - todas las consultas descritas con
@Query
se verifican en la etapa de generar el contexto, lo que lleva tiempo (a diferencia de SimpleJpaRepository::findAllById
) - si se usa Oracle, cuando la colección de claves está vacía, obtenemos el error
ORA-00936: missing expression
(que no sucederá al usar SimpleJpaRepository::findAllById
, ver punto 2)
Harry potter y llave compuesta
Eche un vistazo a dos ejemplos y elija el que prefiera:
Método número veces
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity public class CompositeKeyEntity { @EmbeddedId CompositeKey key; }
Método número dos
@Embeddable public class CompositeKey implements Serializable { Long key1; Long key2; } @Entity @IdClass(value = CompositeKey.class) public class CompositeKeyEntity { @Id Long key1; @Id Long key2; }
A primera vista, no hay diferencia. Ahora prueba el primer método y ejecuta una prueba simple:
En el registro de consultas (lo mantiene, ¿verdad?) Veremos esto:
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 = ?
Ahora segundo ejemplo
El registro de consultas se ve 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=?
Esa es la diferencia: en el primer caso, siempre recibimos 1 solicitud, en el segundo, n solicitudes.
La razón de este comportamiento radica en SimpleJpaRepository::findAllById
:
El mejor método es el que debe determinar según la importancia del número de solicitudes.
Extra CrudRepository :: guardar
A menudo en el código hay tal antipatrón:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return repo.save(account); }
El lector está perplejo: ¿dónde está el antipatrón? Este código parece muy lógico: obtenemos la entidad - actualizar - guardar. Todo es como en las mejores casas de San Petersburgo. Me atrevo a decir que llamar a CrudRepository::save
es superfluo aquí.
Primero: el método updateRate
transaccional, por lo tanto, Hibernate realiza un seguimiento de todos los cambios en la entidad administrada y se convierte en una solicitud cuando Session::flush
ejecuta Session::flush
, que en este código ocurre cuando finaliza el método.
En segundo lugar, CrudRepository::save
un vistazo al método CrudRepository::save
. Como sabes, todos los repositorios se basan en SimpleJpaRepository
. Aquí está la implementación de 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); } }
Hay una sutileza que no todos recuerdan: Hibernate funciona a través de eventos. En otras palabras, cada acción del usuario genera un evento que se pone en cola y se procesa teniendo en cuenta otros eventos en la misma cola. En este caso, una llamada a EntityManager::merge
genera un MergeEvent
, que se procesa de manera predeterminada en el DefaultMergeEventListener::onMerge
. Contiene una lógica bastante ramificada pero simple para cada uno de los estados del argumento de entidad. En nuestro caso, la entidad se obtiene del repositorio dentro del método transaccional y está en el estado PERSISTENTE (es decir, esencialmente controlado por el marco):
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);
El diablo está en los detalles, es decir, en los métodos DefaultMergeEventListener::cascadeOnMerge
y DefaultMergeEventListener::copyValues
. Escuchemos el discurso directo de Vlad Mikhalche , uno de los desarrolladores clave de Hibernate:
En la llamada al método copyValues, el estado hidratado se copia de nuevo, por lo que se crea una nueva matriz de forma redundante, lo que desperdicia los ciclos de la CPU. Si la entidad tiene asociaciones secundarias y la operación de fusión también se conecta en cascada de entidades principales a secundarias, la sobrecarga es aún mayor porque cada entidad secundaria propagará un Evento de combinación y el ciclo continúa.
En otras palabras, se está haciendo un trabajo que no puede hacer. Como resultado, nuestro código se puede simplificar mientras se mejora su rendimiento:
@Transactional public BankAccount updateRate(Long id, BigDecimal rate) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setRate(rate); return account; }
Por supuesto, es inconveniente tener esto en cuenta al desarrollar y corregir el código de otra persona, por lo que nos gustaría realizar cambios a nivel de estructura metálica para que el método JpaRepository::save
pierda sus propiedades dañinas. ¿Es esto posible?
Sin embargo, el lector sofisticado probablemente ya sintió que algo andaba mal. De hecho, este cambio no romperá nada, sino solo en el caso simple cuando no hay entidades secundarias:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; }
Ahora suponga que su propietario está vinculado a la cuenta:
@Entity public class BankAccount { @Id Long id; @Column BigDecimal rate = BigDecimal.ZERO; @ManyToOne @JoinColumn(name = "user_id") User user; }
Hay un método que le permite desconectar al usuario de la cuenta y transferirlo al nuevo usuario:
@Transactional public BankAccount changeUser(Long id, User newUser) { BankAccount account = repo.findById(id).orElseThrow(NPE::new); account.setUser(newUser); return repo.save(account); }
¿Qué pasará ahora? La comprobación de em.contains(entity)
devolverá verdadero, lo que significa que no se llamará a em.merge(entity)
. Si la clave de entidad de User
se crea sobre la base de la secuencia (uno de los casos más comunes), no se creará hasta que se complete la transacción (o Session::flush
llame a Session::flush
manualmente), es decir, el usuario estará en el estado DESCONECTADO y su entidad principal ( cuenta) - en el estado PERSISTENTE. En algunos casos, esto puede romper la lógica de la aplicación, que es lo que sucedió:
02/03/2018 DATAJPA-931 rompe la fusión con RepositoryItemWriter
En este sentido, se creó la tarea Revert optimizaciones realizadas para entidades existentes en CrudRepository :: save y se realizaron los cambios: Revert DATAJPA-931 .
Blind CrudRepository :: findById
Seguimos considerando el mismo modelo de datos:
@Entity public class User { @Id Long id;
La aplicación tiene un método que crea una nueva cuenta para el usuario especificado:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); userRepository.findById(userId).ifPresent(account::setUser);
Con la versión 2. * el antipatrón indicado por la flecha no es tan llamativo, se ve más claramente en versiones anteriores:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.findOne(userId));
Si no ve la falla "a simple vista", eche un vistazo a las 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 ()
La primera solicitud la obtenemos al usuario por clave. A continuación, obtenemos la clave para la cuenta de recién nacido de la base de datos y la insertamos en la tabla. Y lo único que tomamos del usuario es la clave, que ya tenemos como argumento de método. Por otro lado, BankAccount
contiene el campo "usuario" y no podemos dejarlo vacío (como personas decentes establecemos una restricción en el esquema). Los desarrolladores experimentados probablemente ya vean un camino y comer un pez, y montar a caballo obtener tanto el usuario como la solicitud de no:
@Transactional public BankAccount newForUser(Long userId) { BankAccount account = new BankAccount(); account.setUser(userRepository.getOne(userId));
JpaRepository::getOne
devuelve un contenedor sobre la clave que tiene el mismo tipo que la "entidad" viva. Este código solo da dos solicitudes:
call next value for hibernate_sequence insert into bank_account (id, user_id) values ()
Cuando una entidad que se crea contiene muchos campos con una relación de muchos a uno / uno a uno, esta técnica ayudará a acelerar el ahorro y reducir la carga en la base de datos.
Ejecutando consultas HQL
Este es un tema separado e interesante :). El modelo de dominio es el mismo y existe tal solicitud:
@Query("select count(ba) " + " from BankAccount ba " + " join ba.user user " + " where user.id = :id") long countUserAccounts(@Param("id") Long id);
Considere la HQL "pura":
select count(ba) from BankAccount ba join ba.user user where user.id = :id
Cuando se ejecute, se creará la siguiente consulta SQL:
select count(ba.id) from bank_account ba inner join user u on ba.user_id = u.id where u.id = ?
El problema aquí no es evidente de inmediato, incluso por una vida sabia y una buena comprensión de los desarrolladores de SQL: inner join
por clave de usuario excluirá las cuentas con falta de user_id
de la selección (y de una buena manera, insertarlas debería estar prohibido en el nivel de esquema), lo que significa que no es user_id
unirse a la tabla de user
Necesito La solicitud se puede simplificar (y acelerar):
select count(ba.id) from bank_account ba where ba.user_id = ?
Hay una manera de lograr fácilmente este comportamiento en c usando HQL:
@Query("select count(ba) " + " from BankAccount ba " + " where ba.user.id = :id") long countUserAccounts(@Param("id") Long id);
Este método crea una solicitud "lite".
Consulta vs método abstracto
Una de las características principales de Spring Data es la capacidad de crear una consulta a partir del nombre del método, lo cual es muy conveniente, especialmente en combinación con el complemento inteligente de IntelliJ IDEA. La consulta descrita en el ejemplo anterior se puede reescribir fácilmente:
Parece ser más simple, más corto, más legible y, lo más importante, no es necesario que mire la solicitud en sí. Leí el nombre del método, y ya está claro qué elige y cómo. Pero el diablo está aquí en los detalles. La consulta final para el método marcado con @Query
ya hemos visto. ¿Qué pasará en el segundo caso?
Babah! select count(ba.id) from bank_account ba left outer join // <
"¿Qué demonios?" - El desarrollador exclamará. Después de todo, ya hemos visto eso violinista join
no join
necesario.
La razón es prosaica:
Si aún no ha actualizado a las versiones parcheadas, y unirse a la tabla ralentiza la solicitud aquí y ahora, entonces no se desespere: hay dos formas de aliviar el dolor:
una buena manera es agregar optional = false
(si el circuito lo permite):
@Entity public class BankAccount { @Id Long id; @ManyToOne @JoinColumn(name = "user_id", optional = false) User user; }
La forma de muleta es agregar una columna del mismo tipo que la clave de entidad de User
y usarla en consultas en lugar del campo de 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; }
Ahora la solicitud de método será más agradable:
long countByUserId(Long id);
da
select count(ba.id) from bank_account ba where ba.user_id = ?
¿Qué logramos?
Límite de muestreo
Para nuestros propósitos, necesitamos limitar la selección (por ejemplo, queremos devolver Optional
desde el método *RepositoryCustom
):
select ba.* from bank_account ba order by ba.rate limit ?
Ahora 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); }
El código especificado tiene una característica desagradable: en el caso de que la solicitud devuelva una selección vacía, se generará una excepción
Caused by: javax.persistence.NoResultException: No entity found for query
En los proyectos que vi, esto se resolvió de dos maneras principales:
- try-catch con variaciones de
Optonal.empty()
sin rodeos Optonal.empty()
excepción y devolver Optonal.empty()
a métodos más avanzados, como pasar una lambda con una solicitud a un método de utilidad - aspecto en el que los métodos de repositorio envueltos regresan
Optional
Y muy raramente, vi la solución correcta:
@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
es parte del estándar JPA, mientras que Session
pertenece a Hibernate y es en mi humilde opinión una herramienta más avanzada, que a menudo se olvida.
[A veces] mejora perjudicial
Cuando necesita obtener un campo pequeño de una entidad "gruesa", hacemos esto:
@Query("select a.available from BankAccount a where a.id = :id") boolean findIfAvailable(@Param("id") Long id);
La solicitud le permite obtener un campo del tipo boolean
sin cargar toda la entidad (con la adición de un caché de primer nivel, verificar los cambios al final de la sesión y otros gastos). A veces, esto no solo no mejora el rendimiento, sino viceversa: crea consultas innecesarias desde cero. Imagine un código que realiza algunas comprobaciones:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
Este código realiza al menos 2 solicitudes, aunque la segunda podría evitarse:
@Override @Transactional public boolean checkAccount(Long id) { BankAccount acc = repository.findById(id).orElseThow(NPE::new);
La conclusión es simple: no descuide el caché del primer nivel, en el marco de una transacción, solo el primer JpaRepository::findById
refiere a la base de datos, JpaRepository::findById
caché del primer nivel siempre está JpaRepository::findById
y vinculado a una sesión, que generalmente está vinculada a la transacción actual.
Pruebas para jugar (el enlace al repositorio se encuentra al principio del artículo):
- prueba de interfaz estrecha:
InterfaceNarrowingTest
- prueba para un ejemplo con una clave compuesta:
EntityWithCompositeKeyRepositoryTest
- prueba exceso
CrudRepository::save
: ModifierTest.java
- prueba ciega
CrudRepository::findById
: ChildServiceImplTest
- prueba de
left join
innecesaria: BankAccountControlRepositoryTest
El costo de una llamada adicional a CrudRepository::save
se puede calcular usando RedundantSaveBenchmark
. Se inicia utilizando la clase BenchmarkRunner
.