Como esta es una pregunta muy común, escribí
este artículo , en el que se basa esta respuesta.
¿Cuál es el problema de consulta N + 1?
El problema de la consulta N + 1 ocurre cuando el marco de acceso a datos ejecuta N sentencias SQL adicionales para obtener los mismos datos que podrían haberse recuperado al ejecutar la consulta SQL primaria.
Cuanto mayor sea el valor de N, más consultas se ejecutarán, mayor será el impacto en el rendimiento. Y, a diferencia del registro de consulta lento que puede ayudarlo a encontrar consultas de ejecución lenta, el problema de N + 1 no se detectará porque cada consulta adicional individual se ejecuta lo suficientemente rápido como para no activar el registro de consulta lenta.
El problema es ejecutar una gran cantidad de consultas adicionales que, en general, toman suficiente tiempo para ralentizar el tiempo de respuesta.
Consideremos que tenemos las siguientes tablas de base de datos post y post_comments que forman una relación de tabla de uno a muchos :
Vamos a crear las siguientes 4 post
filas:
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 1', 1)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 2', 2)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 3', 3)
INSERT INTO post (title, id)
VALUES ('High-Performance Java Persistence - Part 4', 4)
Y también crearemos 4 post_comment
registros secundarios:
INSERT INTO post_comment (post_id, review, id)
VALUES (1, 'Excellent book to understand Java Persistence', 1)
INSERT INTO post_comment (post_id, review, id)
VALUES (2, 'Must-read for Java developers', 2)
INSERT INTO post_comment (post_id, review, id)
VALUES (3, 'Five Stars', 3)
INSERT INTO post_comment (post_id, review, id)
VALUES (4, 'A great reference book', 4)
Problema de consulta N + 1 con SQL simple
Si selecciona el post_comments
uso de esta consulta SQL:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
""", Tuple.class)
.getResultList();
Y, más tarde, decide buscar el asociado post
title
para cada uno post_comment
:
for (Tuple comment : comments) {
String review = (String) comment.get("review");
Long postId = ((Number) comment.get("postId")).longValue();
String postTitle = (String) entityManager.createNativeQuery("""
SELECT
p.title
FROM post p
WHERE p.id = :postId
""")
.setParameter("postId", postId)
.getSingleResult();
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
Va a desencadenar el problema de consulta N + 1 porque, en lugar de una consulta SQL, ejecutó 5 (1 + 4):
SELECT
pc.id AS id,
pc.review AS review,
pc.post_id AS postId
FROM post_comment pc
SELECT p.title FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.title FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.title FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.title FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Arreglar el problema de consulta N + 1 es muy fácil. Todo lo que necesita hacer es extraer todos los datos que necesita en la consulta SQL original, así:
List<Tuple> comments = entityManager.createNativeQuery("""
SELECT
pc.id AS id,
pc.review AS review,
p.title AS postTitle
FROM post_comment pc
JOIN post p ON pc.post_id = p.id
""", Tuple.class)
.getResultList();
for (Tuple comment : comments) {
String review = (String) comment.get("review");
String postTitle = (String) comment.get("postTitle");
LOGGER.info(
"The Post '{}' got this review '{}'",
postTitle,
review
);
}
Esta vez, solo se ejecuta una consulta SQL para obtener todos los datos que nos interesan más.
Problema de consulta N + 1 con JPA e Hibernate
Al usar JPA e Hibernate, hay varias formas en que puede desencadenar el problema de consulta N + 1, por lo que es muy importante saber cómo puede evitar estas situaciones.
Para los siguientes ejemplos, considere que estamos asignando las tablas post
y post_comments
a las siguientes entidades:
Las asignaciones JPA se ven así:
@Entity(name = "Post")
@Table(name = "post")
public class Post {
@Id
private Long id;
private String title;
//Getters and setters omitted for brevity
}
@Entity(name = "PostComment")
@Table(name = "post_comment")
public class PostComment {
@Id
private Long id;
@ManyToOne
private Post post;
private String review;
//Getters and setters omitted for brevity
}
FetchType.EAGER
El uso FetchType.EAGER
implícito o explícito para sus asociaciones JPA es una mala idea porque va a obtener muchos más datos que necesita. Más, elFetchType.EAGER
Además estrategia también es propensa a problemas de consulta N + 1.
Desafortunadamente, las asociaciones @ManyToOne
y se @OneToOne
usan FetchType.EAGER
de manera predeterminada, por lo que si sus asignaciones se ven así:
@ManyToOne
private Post post;
Está utilizando la FetchType.EAGER
estrategia y, cada vez que olvida usarla JOIN FETCH
al cargar algunas PostComment
entidades con una consulta JPQL o Criteria API:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Va a desencadenar el problema de consulta N + 1:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
Observe las instrucciones SELECT adicionales que se ejecutan porque la post
asociación debe recuperarse antes de devolver el List
dePostComment
las entidades.
A diferencia del plan de recuperación predeterminado, que está utilizando al llamar al find
método de la EnrityManager
, una consulta de JPQL o Criteria API define un plan explícito que Hibernate no puede cambiar al inyectar un JOIN FETCH automáticamente. Por lo tanto, debe hacerlo manualmente.
Si no necesitaba la post
asociación en absoluto, no tiene suerte al usarla FetchType.EAGER
porque no hay forma de evitarla. Por eso es mejor usarFetchType.LAZY
por defecto.
Pero, si desea utilizar la post
asociación, puede utilizar JOIN FETCH
para evitar el problema de consulta N + 1:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Esta vez, Hibernate ejecutará una sola declaración SQL:
SELECT
pc.id as id1_1_0_,
pc.post_id as post_id3_1_0_,
pc.review as review2_1_0_,
p.id as id1_0_1_,
p.title as title2_0_1_
FROM
post_comment pc
INNER JOIN
post p ON pc.post_id = p.id
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Para obtener más detalles sobre por qué debe evitar la FetchType.EAGER
estrategia de recuperación, consulte también este artículo .
FetchType.LAZY
Incluso si cambia a usar FetchType.LAZY
explícitamente para todas las asociaciones, aún puede toparse con el problema N + 1.
Esta vez, la post
asociación se mapea así:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Ahora, cuando busca las PostComment
entidades:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate ejecutará una sola declaración SQL:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Pero, si después, va a hacer referencia a la post
asociación con carga lenta:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Obtendrá el problema de consulta N + 1:
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 1
-- The Post 'High-Performance Java Persistence - Part 1' got this review
-- 'Excellent book to understand Java Persistence'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 2
-- The Post 'High-Performance Java Persistence - Part 2' got this review
-- 'Must-read for Java developers'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 3
-- The Post 'High-Performance Java Persistence - Part 3' got this review
-- 'Five Stars'
SELECT p.id AS id1_0_0_, p.title AS title2_0_0_ FROM post p WHERE p.id = 4
-- The Post 'High-Performance Java Persistence - Part 4' got this review
-- 'A great reference book'
Debido a que la post
asociación se obtiene perezosamente, se ejecutará una instrucción SQL secundaria al acceder a la asociación perezosa para generar el mensaje de registro.
Nuevamente, la solución consiste en agregar una JOIN FETCH
cláusula a la consulta JPQL:
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
join fetch pc.post p
""", PostComment.class)
.getResultList();
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Y, al igual que en el FetchType.EAGER
ejemplo, esta consulta JPQL generará una sola declaración SQL.
Incluso si está utilizando FetchType.LAZY
y no hace referencia a la asociación secundaria de una @OneToOne
relación JPA bidireccional , aún puede desencadenar el problema de consulta N + 1.
Para obtener más detalles sobre cómo puede superar el problema de consulta N + 1 generado por las @OneToOne
asociaciones, consulte este artículo .
Cómo detectar automáticamente el problema de consulta N + 1
Si desea detectar automáticamente un problema de consulta N + 1 en su capa de acceso a datos, este artículo explica cómo puede hacerlo utilizando eldb-util
proyecto de código abierto.
Primero, debe agregar la siguiente dependencia de Maven:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
Después, solo tiene que usar la SQLStatementCountValidator
utilidad para afirmar las declaraciones SQL subyacentes que se generan:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
En caso de que esté utilizando FetchType.EAGER
y ejecute el caso de prueba anterior, obtendrá el siguiente error de caso de prueba:
SELECT
pc.id as id1_1_,
pc.post_id as post_id3_1_,
pc.review as review2_1_
FROM
post_comment pc
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 1
SELECT p.id as id1_0_0_, p.title as title2_0_0_ FROM post p WHERE p.id = 2
-- SQLStatementCountMismatchException: Expected 1 statement(s) but recorded 3 instead!
Para obtener más detalles sobre el db-util
proyecto de código abierto, consulte este artículo .