Poiché questa è una domanda molto comune, ho scritto
questo articolo , su cui si basa questa risposta.
Qual è il problema di query N + 1
Il problema di query N + 1 si verifica quando il framework di accesso ai dati ha eseguito N istruzioni SQL aggiuntive per recuperare gli stessi dati che avrebbero potuto essere recuperati durante l'esecuzione della query SQL primaria.
Maggiore è il valore di N, più query verranno eseguite, maggiore sarà l'impatto sulle prestazioni. E, a differenza del registro delle query lente che può aiutarti a trovare le query con esecuzione lenta, il problema N + 1 non sarà individuabile perché ogni singola query aggiuntiva viene eseguita abbastanza velocemente da non attivare il registro delle query lente.
Il problema è l'esecuzione di un gran numero di query aggiuntive che, nel complesso, richiedono tempo sufficiente per rallentare i tempi di risposta.
Consideriamo che abbiamo le seguenti tabelle del database post e post_comments che formano una relazione da una a molte tabelle :
Creeremo le seguenti 4 post
righe:
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)
E creeremo anche 4 post_comment
record figlio:
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 di query N + 1 con SQL semplice
Se si seleziona post_comments
utilizzando questa query 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();
E, in seguito, decidi di recuperare gli associati post
title
per ciascuno 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
);
}
Inizierai il problema della query N + 1 perché, anziché una query SQL, hai eseguito 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'
Risolvere il problema relativo alla query N + 1 è molto semplice. Tutto quello che devi fare è estrarre tutti i dati necessari nella query SQL originale, in questo modo:
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
);
}
Questa volta, viene eseguita una sola query SQL per recuperare tutti i dati che siamo ulteriormente interessati a utilizzare.
Problema di query N + 1 con JPA e Hibernate
Quando si utilizzano JPA e Hibernate, è possibile innescare il problema della query N + 1 in diversi modi, quindi è molto importante sapere come evitare queste situazioni.
Per i prossimi esempi, considera che stiamo mappando le tabelle post
e post_comments
sulle seguenti entità:
I mapping JPA si presentano così:
@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'utilizzo FetchType.EAGER
implicito o esplicito per le associazioni JPA è una cattiva idea perché recupererai molti più dati di cui hai bisogno. Di più, ilFetchType.EAGER
Inoltre strategia è anche soggetta a problemi di query N + 1.
Sfortunatamente, le associazioni @ManyToOne
e @OneToOne
usano FetchType.EAGER
di default, quindi se le tue mappature assomigliano a questo:
@ManyToOne
private Post post;
Stai usando la FetchType.EAGER
strategia e, ogni volta che dimentichi di usarla JOIN FETCH
quando carichi alcune PostComment
entità con una query API JPQL o Criteria:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Stai per innescare il problema della query 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
Notare le istruzioni SELECT aggiuntive che vengono eseguite perché l' post
associazione deve essere recuperata prima di restituire il List
diPostComment
entità.
A differenza del piano di recupero predefinito, che si sta utilizzando quando si chiama il find
metodo di EnrityManager
, una query API JPQL o Criteria definisce un piano esplicito che Hibernate non può modificare iniettando un JOIN FETCH automaticamente. Quindi, è necessario farlo manualmente.
Se non hai bisogno post
dell'associazione, sei sfortunato quando lo usi FetchType.EAGER
perché non c'è modo di evitare di recuperarlo. Ecco perché è meglio usareFetchType.LAZY
di default.
Ma, se si desidera utilizzare l' post
associazione, è possibile utilizzare JOIN FETCH
per evitare il problema della query 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()
);
}
Questa volta, Hibernate eseguirà una singola istruzione 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'
Per maggiori dettagli sul motivo per cui dovresti evitare la FetchType.EAGER
strategia di recupero, consulta anche questo articolo .
FetchType.LAZY
Anche se passi all'uso FetchType.LAZY
esplicito per tutte le associazioni, puoi comunque imbatterti nel problema N + 1.
Questa volta, l' post
associazione è mappata in questo modo:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
Ora, quando recuperi le PostComment
entità:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
Hibernate eseguirà una singola istruzione SQL:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
Ma, se in seguito, farai riferimento post
all'associazione caricata in modo pigro :
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
Verrà visualizzato il problema della query 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'
Perché la post
associazione viene recuperata pigramente, quando si accede all'associazione pigra verrà eseguita un'istruzione SQL secondaria per creare il messaggio di registro.
Ancora una volta, la correzione consiste nell'aggiunta di una JOIN FETCH
clausola alla query 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()
);
}
E, proprio come FetchType.EAGER
nell'esempio, questa query JPQL genererà una singola istruzione SQL.
Anche se si sta utilizzando FetchType.LAZY
e non si fa riferimento all'associazione figlio di una @OneToOne
relazione JPA bidirezionale , è comunque possibile attivare il problema della query N + 1.
Per maggiori dettagli su come superare il problema di query N + 1 generato dalle @OneToOne
associazioni, consulta questo articolo .
Come rilevare automaticamente il problema della query N + 1
Se si desidera rilevare automaticamente il problema di query N + 1 nel livello di accesso ai dati, questo articolo spiega come farlo utilizzandodb-util
progetto open-source.
Innanzitutto, è necessario aggiungere la seguente dipendenza Maven:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
Successivamente, devi solo usare l' SQLStatementCountValidator
utilità per affermare le istruzioni SQL sottostanti che vengono generate:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
Nel caso in cui si stia utilizzando FetchType.EAGER
ed eseguendo il test case sopra riportato, si otterrà il seguente errore del test case:
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!
Per maggiori dettagli sul progetto db-util
open source, consulta questo articolo .