Este ejemplo permite ver el problema de los (N + 1) queries. Consiste en un servicio REST hecho con Spring Boot, que tiene un solo endpoint: el que permite consultar los productos más recientes.
Solo hace falta tener instalado Docker Desktop (el pack docker engine y docker compose), seguí las instrucciones de esta página en el párrafo Docker
.
docker compose up
Eso levanta tanto PostgreSQL como el cliente pgAdmin, como está explicado en este ejemplo.
La conexión a la base se configura en el archivo application.yml
:
datasource:
url: jdbc:postgresql://0.0.0.0:5432/products
username: postgres
password: postgres
driver-class-name: org.postgresql.Driver
0.0.0.0
apunta a nuestro contenedor de Docker- el usuario y contraseña está definido en el archivo
docker-compose.yml
Tenemos productos y fabricantes, que cumplen una relación many-to-many: un producto tiene muchos fabricantes y un fabricante ofrece muchos productos. No hay relación en cascada: el producto y el fabricante tienen ciclos de vida separados.
La clase ProductosBootstrap genera el juego de datos inicial cuando no hay fabricantes:
- construye una lista de 25 fabricantes
- y 5000 productos: a la mitad les pone los primeros 10 fabricantes y a la otra mitad les asigna los fabricantes ubicados en las posiciones 11, 12, 13, 14 y 15
El único endpoint que publica el web server es /productosRecientes
, que implementa el método GET y llama al service para obtener los productos más recientes:
@Transactional(Transactional.TxType.NEVER)
fun buscarProductosRecientes() =
productoRepository
.findAll(PageRequest.of(0, PRODUCT_PAGINATION_AMOUNT, Sort.Direction.ASC, "fechaIngreso"))
.map { ProductoDTO.fromProducto(it) }
Dado que tenemos una gran cantidad de productos, decidimos paginar los resultados que envía el repositorio: .findAll(PageRequest.of(0, 5, Sort.Direction.ASC, "fechaIngreso"))
trae los primeros 5 resultados ordenados por fecha de ingreso en forma ascendente. Para poder trabajar con paginación debemos definir el repositorio extendiendo de la interfaz PagingAndSortingRepository
, además de la tradicional CrudRepository
que nos provee el método save:
interface ProductoRepository : PagingAndSortingRepository<Producto, Long>, CrudRepository<Producto, Long> {
Podemos ver cómo está definido el mapeo del objeto de dominio Producto:
@Entity
class Producto {
@Id
@GeneratedValue(strategy = GenerationType.AUTO)
var id: Long? = null
...
@JsonIgnore
@ManyToMany(fetch = FetchType.EAGER)
var proveedores: Set<Fabricante> = mutableSetOf()
Mmm... si los proveedores se anotan con FetchType EAGER, esto significa que cada vez que tomemos los datos de un producto también estaremos trayendo los proveedores. Si en el controller permitimos paginar 1000 elementos, vamos a ver que efectivamente resulta un problema esta configuración. Hagamos la llamada a la API:
http://localhost:8080/productosRecientes
o bien en Insomnia:
Como pueden ver tenemos
- un primer query que trae todos los productos
-- Hibernate: query del producto
select
producto0_.id as id1_1_,
producto0_.fechaIngreso as fechaIng2_1_,
producto0_.nombre as nombre3_1_
from
Producto producto0_
order by
producto0_.fechaIngreso asc limit ?
- y por cada producto, un query que baja la información de sus fabricantes (el temido n, la lista de productos que paginados pueden ser 5... o 1000)
-- Hibernate: query 1 del fabricante
select
proveedore0_.Producto_id as Producto1_2_0_,
proveedore0_.proveedores_id as proveedo2_2_0_,
fabricante1_.id as id1_0_1_,
fabricante1_.nombre as nombre2_0_1_
from
Producto_Fabricante proveedore0_
inner join
Fabricante fabricante1_
on proveedore0_.proveedores_id=fabricante1_.id
where
proveedore0_.Producto_id=?
Podemos pensar entonces que si configuramos la relación producto-fabricantes como lazy, habremos resuelto nuestro problema:
@Entity
class Producto {
...
@JsonIgnore
@ManyToMany(fetch = FetchType.LAZY)
var proveedores: Set<Fabricante> = mutableSetOf()
Pero si volvemos a levantar la aplicación, nos vamos a llevar una sorpresa no muy grata:
El mensaje de error es bastante claro:
org.hibernate.LazyInitializationException:
failed to lazily initialize a collection of role: ar.edu.unsam.productos.domain.Producto.proveedores,
could not initialize proxy - no Session
...
at ar.edu.phm.products.domain.Producto.getNombresDeProveedores(Producto.kt:30)
El problema está en que los proveedores son una colección lazy en producto, y que tenemos la configuración
open-in-view: false
Esto hace que la sesión se cierre
- en el service cuyo método se demarca con la anotación @Transactional (sea read-only o no)
- o bien en este caso dentro del método del ProductoRepository
es decir, que en el momento en que el controller quiere serializar a JSON la respuesta del endpoint, ya no tenemos una sesión abierta y se lanza la excepción.
Cambiar la configuración a
open-in-view: true
que es la opción por defecto de Springboot no mejora nuestro problema, volvemos a tener el mismo comportamiento que en el primer caso (con la configuración EAGER), porque en la serialización a JSON estamos yendo a buscar los proveedores por cada uno de los productos.
La configuración lazy es parte de la solución, pero debemos ajustar nuestro query para que los productos recientes los resuelva haciendo JOIN hacia la tabla de fabricantes en la misma consulta. Así evitamos el problema de los (n + 1) queries:
interface ProductoRepository : PagingAndSortingRepository<Producto, Long> {
@EntityGraph(attributePaths=[
"proveedores"
])
override fun findAll(pageable: Pageable): Page<Producto>
Ahora sí, al disparar la consulta nuevamente, se resuelve todo en un único query:
Hibernate:
select
producto0_.id as id1_1_0_,
fabricante2_.id as id1_0_1_,
producto0_.fechaIngreso as fechaIng2_1_0_,
producto0_.nombre as nombre3_1_0_,
fabricante2_.nombre as nombre2_0_1_,
proveedore1_.Producto_id as Producto1_2_0__,
proveedore1_.proveedores_id as proveedo2_2_0__
from
Producto producto0_
inner join
Producto_Fabricante proveedore1_
on producto0_.id=proveedore1_.Producto_id
inner join
Fabricante fabricante2_
on proveedore1_.proveedores_id=fabricante2_.id
order by
producto0_.fechaIngreso asc
Aun cuando hay que traer 1000 productos, la deserialización de la base hacia el modelo de objetos en la JDK y el posterior render a JSON tarda bastante menos, pero sobre todo, no perjudicamos a la base haciendo 1001 consultas.
Te dejamos el archivo de Insomnia con ejemplos para probarlo.
Si querés ver la explicación en un video te dejamos este link de youtube.
En lugar de hacer que JPA genere las tablas, nosotros escribiremos nuestro propio script que cree las tablas y le pediremos a Flyway que ejecute ese script cuando levante la aplicación. Lo que tenemos que hacer es:
- agregar las dependencias en el archivo
build.gradle.kts
- ubicar los scripts de creación de tablas y posteriores migraciones en el directorio
/db/migrations
. Son los que comienzan con la letra V y tienen el formatoVxxx__name.sql
dondexxx
es el número de versión y name es el nombre que vos quieras darle. También podés customizar tu propio formato, por ejemplo para utilizar fechas. Por último, debemos pensar en los scripts para deshacer los cambios (undo migrations) en el mismo directorio, son los que comienzan con la letra U. - en la configuración de la aplicación cambiamos la estrategia para que JPA solamente valide la estructura existente en las tablas vs. las definiciones que existen en el mapeo de los objetos de dominio:
spring:
jpa:
hibernate:
ddl-auto: validate
A la configuración de flyway debemos indicarle cuál es el esquema de la base de datos, entre otras cosas:
spring:
flyway:
enabled: true
create-schemas: true
# si hay tres scripts: V1.sql, V2.sql y V3.sql y
# en nuestra base local no se ejecutó V2.sql, out-of-order
# activado permite ejecutar los scripts pendientes
out-of-order: true
#
schemas: products
Flyway crea una tabla flyway_schema_history
donde va registrando las migraciones que se fueron ejecutando:
Esto sirve tanto para la creación inicial de nuestras tablas como para los sucesivos cambios que la aplicación requiera hacer: agregar nuevas tablas, incorporar o eliminar campos, índices, secuencias, vistas, etc.
Si querés investigar más podés chequear este artículo o bien la documentación oficial.
Como estamos ejecutando nuestros tests de integración con una base de datos in-memory, no vamos a utilizar Flyway, por lo tanto debemos sobreescribir la configuración de test:
spring:
database: H2
flyway:
enabled: false
jpa:
hibernate:
# seguimos creando las tablas cuando levantamos una instancia de testing
ddl-auto: create-drop