Puisque c'est une question très courante, j'ai écrit
cet article , sur lequel cette réponse est basée.
Quel est le problème de requête N + 1
Le problème de requête N + 1 se produit lorsque l'infrastructure d'accès aux données a exécuté N instructions SQL supplémentaires pour extraire les mêmes données qui auraient pu être récupérées lors de l'exécution de la requête SQL principale.
Plus la valeur de N est élevée, plus les requêtes seront exécutées, plus l'impact sur les performances sera important. Et, contrairement au journal des requêtes lentes qui peut vous aider à trouver des requêtes à exécution lente, le problème N + 1 ne sera pas localisé car chaque requête supplémentaire individuelle s'exécute suffisamment rapidement pour ne pas déclencher le journal des requêtes lentes.
Le problème est l'exécution d'un grand nombre de requêtes supplémentaires qui, dans l'ensemble, prennent suffisamment de temps pour ralentir le temps de réponse.
Considérons que nous avons les tables de base de données post et post_comments suivantes qui forment une relation de table un-à-plusieurs :
Nous allons créer les 4 post
lignes suivantes:
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)
Et, nous allons également créer 4 post_comment
enregistrements enfants:
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)
Problème de requête N + 1 avec SQL simple
Si vous sélectionnez l' post_comments
utilisation de cette requête 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();
Et, plus tard, vous décidez de récupérer les associés post
title
pour chacun 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
);
}
Vous allez déclencher le problème de requête N + 1 car, au lieu d'une requête SQL, vous avez exécuté 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'
La résolution du problème de requête N + 1 est très facile. Tout ce que vous devez faire est d'extraire toutes les données dont vous avez besoin dans la requête SQL d'origine, comme ceci:
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
);
}
Cette fois, une seule requête SQL est exécutée pour récupérer toutes les données que nous souhaitons utiliser.
Problème de requête N + 1 avec JPA et Hibernate
Lorsque vous utilisez JPA et Hibernate, il existe plusieurs façons de déclencher le problème de requête N + 1, il est donc très important de savoir comment éviter ces situations.
Pour les exemples suivants, considérez que nous mappons les tables post
et post_comments
aux entités suivantes:
Les mappages JPA ressemblent à ceci:
@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
L'utilisation FetchType.EAGER
implicite ou explicite de vos associations JPA est une mauvaise idée car vous allez récupérer bien plus de données dont vous avez besoin. De plus, la FetchType.EAGER
stratégie est également sujette à des problèmes de requête N + 1.
Malheureusement, les associations @ManyToOne
et @OneToOne
utilisent FetchType.EAGER
par défaut, donc si vos mappages ressemblent à ceci:
@ManyToOne
private Post post;
Vous utilisez la FetchType.EAGER
stratégie et, chaque fois que vous oubliez de l'utiliser JOIN FETCH
lors du chargement de certaines PostComment
entités avec une requête API JPQL ou Criteria:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Vous allez déclencher le problème de requête 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
Notez les instructions SELECT supplémentaires qui sont exécutées car le post
association doit être puisée avant de retourner le List
des PostComment
entités.
Contrairement au plan de récupération par défaut que vous utilisez lorsque vous appelez le find
méthode de EnrityManager
, une requête API JPQL ou Criteria définit un plan explicite que Hibernate ne peut pas modifier en injectant automatiquement un JOIN FETCH. Donc, vous devez le faire manuellement.
Si vous n'aviez pas besoin du post
association, vous n'avez pas de chance lors de l'utilisation FetchType.EAGER
car il n'y a aucun moyen d'éviter de la chercher. C'est pourquoi il vaut mieux utiliser FetchType.LAZY
par défaut.
Mais si vous souhaitez utiliser l' post
association, vous pouvez utiliserJOIN FETCH
pour éviter le problème de requête 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()
);
}
Cette fois, Hibernate exécutera une seule instruction 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'
Pour plus de détails sur les raisons pour lesquelles vous devriez éviter la FetchType.EAGER
stratégie de récupération, consultez également cet article .
FetchType.LAZY
Même si vous passez à l'utilisation FetchType.LAZY
explicite pour toutes les associations, vous pouvez toujours rencontrer le problème N + 1.
Cette fois, l' post
association est cartographiée comme suit:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Maintenant, lorsque vous récupérez les PostComment
entités:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate exécutera une seule instruction SQL:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Mais, si après, vous allez référencer l'association paresseuse post
:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Vous obtiendrez le problème de requête 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'
Parce que le post
association est extraite paresseusement, une instruction SQL secondaire sera exécutée lors de l'accès à l'association paresseuse afin de générer le message de journal.
Encore une fois, le correctif consiste à ajouter une JOIN FETCH
clause à la requête 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()
);
}
Et, tout comme dans le FetchType.EAGER
exemple, cette requête JPQL générera une seule instruction SQL.
Même si vous utilisez FetchType.LAZY
et ne faites pas référence à l'association enfant d'une @OneToOne
relation JPA bidirectionnelle , vous pouvez toujours déclencher le problème de requête N + 1.
Pour plus de détails sur la façon de résoudre le problème de requête N + 1 généré par les @OneToOne
associations, consultez cet article .
Comment détecter automatiquement le problème de requête N + 1
Si vous souhaitez détecter automatiquement le problème de requête N + 1 dans votre couche d'accès aux données, cet article explique comment procéder à l'aide de ladb-util
projet open-source.
Tout d'abord, vous devez ajouter la dépendance Maven suivante:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
Ensuite, il vous suffit d'utiliser l' SQLStatementCountValidator
utilitaire pour affirmer les instructions SQL sous-jacentes générées:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
Si vous utilisez FetchType.EAGER
et exécutez le scénario de test ci-dessus, vous obtiendrez l'échec du scénario de test suivant:
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!
Pour plus de détails sur le db-util
projet open-source, consultez cet article .