เนื่องจากนี่เป็นคำถามที่พบบ่อยมากฉันจึงเขียน
บทความนี้ขึ้นอยู่กับคำตอบนี้
ปัญหาแบบสอบถาม N + 1 คืออะไร
ปัญหาแบบสอบถาม N + 1 เกิดขึ้นเมื่อกรอบการเข้าถึงข้อมูลดำเนินการคำสั่ง N SQL เพิ่มเติมเพื่อดึงข้อมูลเดียวกับที่สามารถเรียกคืนได้เมื่อดำเนินการสืบค้น SQL หลัก
ยิ่งค่าของ N มากขึ้นเท่าไรก็ยิ่งมีการดำเนินการสืบค้นมากขึ้นเท่านั้น และแตกต่างจากบันทึกการสืบค้นที่ช้าที่สามารถช่วยคุณค้นหาการสืบค้นที่ทำงานช้าปัญหา N + 1 จะไม่เกิดขึ้นเพราะแบบสอบถามเพิ่มเติมแต่ละรายการจะทำงานเร็วพอที่จะไม่ทริกเกอร์บันทึกการสืบค้นที่ช้า
ปัญหากำลังดำเนินการกับแบบสอบถามจำนวนมากซึ่งโดยรวมแล้วใช้เวลาเพียงพอในการตอบสนองช้าลง
ลองพิจารณาว่าเรามีตารางฐานข้อมูลการโพสต์และ post_comments ดังต่อไปนี้ซึ่งก่อให้เกิดความสัมพันธ์แบบหนึ่งต่อหลายกลุ่ม :
เราจะสร้าง 4 post
แถวต่อไปนี้:
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)
และเราจะสร้างpost_comment
ระเบียนย่อย4 รายการ:
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)
ปัญหาแบบสอบถาม N + 1 กับ SQL ธรรมดา
หากคุณเลือกการpost_comments
ใช้แบบสอบถาม 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();
และในภายหลังคุณตัดสินใจดึงข้อมูลที่เกี่ยวข้องpost
title
สำหรับแต่ละรายการ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
);
}
คุณจะทริกเกอร์ปัญหาการสอบถาม N + 1 เพราะแทนที่จะเป็นการสืบค้น SQL หนึ่งครั้งคุณได้ดำเนินการ 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'
การแก้ไขปัญหาคิวรี N + 1 นั้นง่ายมาก สิ่งที่คุณต้องทำคือดึงข้อมูลทั้งหมดที่คุณต้องการในการสืบค้น SQL ดั้งเดิมออกมาดังนี้:
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
);
}
ในครั้งนี้มีการดำเนินการค้นหา SQL เพียงแบบสอบถามเดียวเพื่อดึงข้อมูลทั้งหมดที่เราสนใจใช้ต่อไป
ปัญหาการสืบค้น N + 1 กับ JPA และ Hibernate
เมื่อใช้ JPA และ Hibernate มีหลายวิธีที่คุณสามารถทริกเกอร์ปัญหาการสอบถาม N + 1 ได้ดังนั้นจึงเป็นสิ่งสำคัญที่จะต้องทราบว่าคุณสามารถหลีกเลี่ยงสถานการณ์เหล่านี้ได้อย่างไร
สำหรับตัวอย่างถัดไปให้พิจารณาว่าเรากำลังทำแผนที่post
และpost_comments
ตารางไปยังเอนทิตีต่อไปนี้:
การแม็พ JPA มีลักษณะเช่นนี้:
@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
การใช้FetchType.EAGER
ความสัมพันธ์ JPA ของคุณโดยปริยายหรืออย่างชัดเจนเป็นความคิดที่ไม่ดีเพราะคุณจะดึงข้อมูลที่คุณต้องการได้มากขึ้น ยิ่งไปกว่านั้นFetchType.EAGER
กลยุทธ์ยังมีแนวโน้มที่จะเกิดปัญหาการสอบถาม N + 1
น่าเสียดายที่การเชื่อมโยง@ManyToOne
และการ@OneToOne
เชื่อมโยงใช้FetchType.EAGER
ตามค่าเริ่มต้นดังนั้นหากการจับคู่ของคุณมีลักษณะดังนี้:
@ManyToOne
private Post post;
คุณกำลังใช้FetchType.EAGER
กลยุทธ์และทุกครั้งที่คุณลืมที่จะใช้JOIN FETCH
เมื่อโหลดPostComment
เอนทิตีบางอย่างด้วยการสืบค้น JPQL หรือ Criteria API:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
คุณกำลังจะเรียกปัญหาการสืบค้น 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
ขอให้สังเกตงบเลือกเพิ่มเติมที่จะดำเนินการเพราะpost
สมาคมจะต้องมีความจริงก่อนที่จะกลับมาList
ของPostComment
หน่วยงาน
ซึ่งแตกต่างจากแผนการดึงข้อมูลเริ่มต้นซึ่งคุณใช้เมื่อเรียกใช้find
เมธอดEnrityManager
แบบสอบถาม JPQL หรือ Criteria API จะกำหนดแผนอย่างชัดเจนที่ไฮเบอร์เนตไม่สามารถเปลี่ยนแปลงได้โดยการฉีด JOIN FETCH โดยอัตโนมัติ ดังนั้นคุณต้องทำด้วยตนเอง
หากคุณไม่ต้องการการpost
เชื่อมโยงเลยแสดงว่าคุณโชคไม่ดีเมื่อใช้FetchType.EAGER
เพราะไม่มีวิธีหลีกเลี่ยงที่จะดึงมันออกมา นั่นเป็นเหตุผลว่าทำไมจึงควรใช้เป็นFetchType.LAZY
ค่าเริ่มต้น
แต่ถ้าคุณต้องการใช้การpost
เชื่อมโยงคุณสามารถใช้JOIN FETCH
เพื่อสอบถามปัญหา 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()
);
}
เวลานี้ไฮเบอร์เนตจะดำเนินการคำสั่ง 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'
สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับสาเหตุที่คุณควรหลีกเลี่ยงFetchType.EAGER
กลยุทธ์ดึงข้อมูลลองดูบทความนี้ด้วย
FetchType.LAZY
แม้ว่าคุณจะเปลี่ยนเป็นการใช้FetchType.LAZY
อย่างชัดเจนสำหรับการเชื่อมโยงทั้งหมดคุณยังสามารถชนกับปัญหา N + 1 ได้
เวลานี้การpost
เชื่อมโยงถูกแมปดังนี้:
@ManyToOne(fetch = FetchType.LAZY)
private Post post;
ตอนนี้เมื่อคุณดึงPostComment
เอนทิตี้:
List<PostComment> comments = entityManager
.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
ไฮเบอร์เนตจะดำเนินการคำสั่ง SQL เดียว:
SELECT
pc.id AS id1_1_,
pc.post_id AS post_id3_1_,
pc.review AS review2_1_
FROM
post_comment pc
แต่ถ้าหลังจากนั้นคุณจะอ้างอิงความสัมพันธ์แบบขี้เกียจpost
:
for(PostComment comment : comments) {
LOGGER.info(
"The Post '{}' got this review '{}'",
comment.getPost().getTitle(),
comment.getReview()
);
}
คุณจะได้รับข้อความค้นหา 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'
เนื่องจากการpost
เชื่อมโยงถูกดึงออกมาอย่างเกียจคร้านคำสั่ง SQL รองจะถูกเรียกใช้เมื่อเข้าถึงการเชื่อมโยงที่ขี้เกียจเพื่อสร้างข้อความบันทึก
อีกครั้งการแก้ไขประกอบด้วยการเพิ่มส่วนJOIN FETCH
คำสั่งในการสืบค้น 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()
);
}
และเช่นเดียวกับในFetchType.EAGER
ตัวอย่างแบบสอบถาม JPQL นี้จะสร้างคำสั่ง SQL เดียว
แม้ว่าคุณจะใช้FetchType.LAZY
และไม่อ้างอิงการเชื่อมโยงลูกของ@OneToOne
ความสัมพันธ์ JPA สองทิศทางคุณยังสามารถทริกเกอร์ปัญหาการสอบถาม N + 1
สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับวิธีการที่คุณสามารถเอาชนะปัญหา N + 1 แบบสอบถามที่สร้างขึ้นโดย@OneToOne
สมาคมตรวจสอบบทความนี้
วิธีตรวจหาปัญหาการสืบค้น N + 1 โดยอัตโนมัติ
หากคุณต้องการตรวจสอบปัญหาการสืบค้น N + 1 ในเลเยอร์การเข้าถึงข้อมูลของคุณโดยอัตโนมัติบทความนี้จะอธิบายวิธีการทำเช่นนั้นโดยใช้db-util
โครงการโอเพ่นซอร์ส
ก่อนอื่นคุณต้องเพิ่มการพึ่งพา Maven ต่อไปนี้:
<dependency>
<groupId>com.vladmihalcea</groupId>
<artifactId>db-util</artifactId>
<version>${db-util.version}</version>
</dependency>
หลังจากนั้นคุณเพียงแค่ต้องใช้SQLStatementCountValidator
ยูทิลิตี้เพื่อยืนยันคำสั่ง SQL พื้นฐานที่สร้างขึ้น:
SQLStatementCountValidator.reset();
List<PostComment> comments = entityManager.createQuery("""
select pc
from PostComment pc
""", PostComment.class)
.getResultList();
SQLStatementCountValidator.assertSelectCount(1);
ในกรณีที่คุณใช้FetchType.EAGER
และเรียกใช้กรณีทดสอบข้างต้นคุณจะได้รับกรณีทดสอบล้มเหลว:
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!
สำหรับรายละเอียดเพิ่มเติมเกี่ยวกับdb-util
โครงการเปิดแหล่งที่มาตรวจสอบบทความนี้