ข้ามไปยังเนื้อหา

explain และ query plan

คุณสร้าง index ที่ดูสมบูรณ์แบบได้ แต่ก็ยังไม่รู้ว่าได้ผลจริงไหม ทางเดียวที่จะมั่นใจคือถาม MongoDB ตรง ๆ ว่ารัน query นี้ยังไง เครื่องมือนั้นคือ explain

พอส่ง query เข้าไป explain จะรายงาน plan ที่ server เลือก ว่าหยิบ index ตัวไหนไปใช้ หรือถอยไปสแกนทั้ง collection และในโหมดที่ละเอียดที่สุดก็รายงานตัวเลขจริงจากการรันด้วย การอ่านผลลัพธ์ตรงนี้คือทักษะที่ใช้งานได้จริงที่สุดของทั้งโมดูล เพราะเปลี่ยน “น่าจะเร็ว” ให้เป็น “เห็นกับตาแล้วว่าเร็ว”

explain("executionStats") รัน query แล้วคืนทั้ง plan ที่เลือกและจำนวนที่วัดได้ เอาไปแนบกับ query ไหนก็ได้ที่ยิงใส่ members ในห้องสมุดของเรา:

db.members
.find({ city: "Berlin" })
.explain("executionStats")

ถ้าไม่มี index ครอบตัวกรอง ขั้นตอนของ plan ที่ชนะคือ COLLSCAN ตัวเลขที่บอกอะไรได้คือ totalDocsExamined — จำนวน document ที่ server ต้องอ่าน — เทียบกับ nReturned — จำนวนที่คืนกลับมาจริง การสแกนอ่านทุกอย่างเพื่อหาเพียงไม่กี่ตัว:

{
"queryPlanner": {
"winningPlan": { "stage": "COLLSCAN", "direction": "forward" }
},
"executionStats": {
"nReturned": 3,
"totalDocsExamined": 10000,
"executionTimeMillis": 12
}
}

การตรวจหนึ่งหมื่น document เพื่อคืนสามตัวคืออาการตามตำราของการขาด index อัตราส่วนของ totalDocsExamined ต่อ nReturned คือมาตรวัดประสิทธิภาพของคุณ: ยิ่งใกล้หนึ่งต่อหนึ่งยิ่งดี

สร้าง index บน city รัน explain เดิม แล้ว plan ที่ชนะจะเปลี่ยน ขั้นตอนบนสุดตอนนี้คือ FETCH — การดึง document ฉบับเต็ม — ซึ่งป้อนมาจาก IXSCAN ที่เดินเฉพาะชิ้นที่เกี่ยวข้องของ index ที่สำคัญคือ totalKeysExamined และ totalDocsExamined ลดลงมาตรงกับ nReturned:

{
"queryPlanner": {
"winningPlan": {
"stage": "FETCH",
"inputStage": {
"stage": "IXSCAN",
"indexName": "city_1",
"direction": "forward"
}
}
},
"executionStats": {
"nReturned": 3,
"totalKeysExamined": 3,
"totalDocsExamined": 3,
"executionTimeMillis": 0
}
}

server ตรวจคีย์ index สามคีย์พอดีและดึง document สามฉบับพอดีเพื่อคืนผลลัพธ์สามตัว นั่นคือชัยชนะที่ index ซื้อให้คุณ ทำให้มองเห็นได้

เมื่อ index หลายอันให้บริการ query ได้ MongoDB ไม่เดา แต่จะสร้าง plan ผู้ท้าชิงให้ทุก index ที่ใช้ได้ รันสั้นๆ พร้อมกัน เลือกอันที่ทำงานน้อยที่สุด แล้วแคชตัวเลือกนั้นไว้เพื่อให้ query ที่เหมือนกันในอนาคตข้ามการแข่งขัน:

flowchart LR
  Q["Incoming query"] --> P["Query planner"]
  P --> C1["Candidate plan: index A"]
  P --> C2["Candidate plan: index B"]
  P --> C3["Candidate plan: collection scan"]
  C1 --> T["Trial run — measure work"]
  C2 --> T
  C3 --> T
  T --> W["Winning plan"]
  W --> Cache["Plan cached for matching queries"]
planner ทดสอบแต่ละผู้ท้าชิง เลือกอันที่ถูกที่สุด และแคชผู้ชนะไว้สำหรับ query ที่ตรงกัน

นี่คือเหตุผลที่ explain รายงาน winningPlan และบางครั้งรายการ rejectedPlans: คุณกำลังเห็นผลลัพธ์ของการแข่งขันนั้น

มีอีกหนึ่งระดับที่ดีกว่า IXSCAN ที่เร็วบวก FETCH: query ที่ index ตอบได้โดยไม่ต้องดึง document เลย สิ่งนี้เกิดขึ้นเมื่อทุกฟิลด์ที่ query ต้องการ — ทั้งฟิลด์ตัวกรองและฟิลด์ที่คุณขอกลับมาใน projection — อยู่ใน index server อ่านคำตอบตรงจากคีย์ index และข้าม document ไปทั้งหมด query แบบนั้นเรียกว่า covered และ plan จะไม่มีขั้น FETCH เลย

สร้าง index บน { city: 1, joined: 1 } แล้วขอเฉพาะ joined ขณะกด _id ไว้ (เพราะ _id ไม่อยู่ใน index นี้):

db.members
.find({ city: "Berlin" }, { joined: 1, _id: 0 })
.explain("executionStats")

plan ไปจาก IXSCAN ตรงสู่ผลลัพธ์โดยไม่มี FETCH คั่นกลาง และ totalDocsExamined เป็น 0 — เป็นเครื่องบ่งชี้ว่าไม่เคยมีการอ่าน document เลย:

{
"queryPlanner": {
"winningPlan": {
"stage": "PROJECTION_COVERED",
"inputStage": { "stage": "IXSCAN", "indexName": "city_1_joined_1" }
}
},
"executionStats": {
"nReturned": 3,
"totalKeysExamined": 3,
"totalDocsExamined": 0
}
}

ใน Compass: แท็บ Explain Plan เรนเดอร์สิ่งนี้เป็นต้นไม้ของขั้นตอนแบบภาพพร้อมจำนวนเดียวกัน คุณจึงยืนยัน IXSCAN จับ COLLSCAN ที่หลงมา และเห็น Documents Examined เทียบกับ Documents Returned ได้โดยไม่ต้องอ่านผลลัพธ์ดิบ

  • อัตราส่วนหัวเรื่องคือ totalDocsExamined เทียบกับ nReturned ถ้าใกล้หนึ่งต่อหนึ่ง แปลว่า index ทำงานได้ดี ถ้าตัวเลขพองขึ้น แปลว่ามีอะไรบางอย่างกำลังสแกนอยู่
  • executionStats รัน query จริงเพื่อเก็บตัวเลข ดังนั้นกับงานเขียนหรืองานอ่านที่แพง ให้ระวังจังหวะที่เรียกใช้
  • covered query ต้องจัดการ _id เนื่องจาก _id ถูกคืนมาโดยปริยายและแทบไม่อยู่ใน index ที่คุณทำเอง คุณจึงมักต้องปิด _id ใน projection เพื่อให้ query ยังเป็น covered อยู่
  • ขั้น FETCH ไม่ใช่ความล้มเหลว แค่แปลว่า index หา document เจอแล้ว แต่ projection ยังขอฟิลด์ที่ index ไม่ได้เก็บไว้ covered query เป็นการเพิ่มประสิทธิภาพ ไม่ใช่ข้อบังคับ
ตัวเลือกBenefitCost
อ่าน explain() ก่อนตัดสินใจเพิ่ม/ลบ indexเห็นหลักฐานจริงว่า query สแกนเท่าไรและใช้ index ตัวไหน ตัดสินใจได้แม่นยำต้องรัน query จริงและอ่านผลลัพธ์ กินเวลาเพิ่มก่อนลงมือ
เดาว่า index ไหนช่วยแล้วสร้างเลยทำได้เร็ว ไม่ต้องรอวิเคราะห์มักผิด ได้ index ที่ query ไม่เคยใช้ แถมยังแบกรับ write cost ของ index ที่ไม่มีประโยชน์
  • สร้าง index แล้วไม่ตรวจว่า query ใช้จริงหรือเปล่า — มี index อยู่ไม่ได้แปลว่า planner จะหยิบไปใช้ ต้องรัน explain() ยืนยันว่า winningPlan เป็น IXSCAN ที่ใช้ index นั้นจริง
  • มองแค่ nReturned โดยไม่ดู totalDocsExamined — query ที่คืนผลลัพธ์น้อยแต่ totalDocsExamined สูงมากยังเป็น query ที่ไม่มีประสิทธิภาพ แม้ผลลัพธ์จะดูถูกต้องและเร็วพอในสายตา
  • รัน explain("executionStats") บนระบบ production แบบไม่ระวัง — โหมดนี้รัน query จริง ไม่ใช่แค่ประเมิน plan ถ้า query เขียนหรืออ่านหนัก การรันซ้ำๆ บน production เพิ่มโหลดจริงให้ server

💡 ตัวอย่างจากของจริง

ทีม platform ที่ตั้ง slow query threshold ใน MongoDB Atlas — ทุก query ที่ช้ากว่า threshold จะเข้า log พร้อม plan summary แล้ววิศวกรรัน explain("executionStats") ซ้ำเพื่อยืนยันก่อนตัดสินใจเพิ่ม index แทนที่จะเดาจาก dashboard เฉยๆ

ทีม backend ที่ debug API endpoint ที่ช้าลงเรื่อยๆ เมื่อข้อมูลโต — ใช้ explain() เจอว่า query ที่เคยเป็น covered query กลายเป็น query ที่มี FETCH เพิ่มเข้ามาหลังเปลี่ยน projection เล็กน้อย จึงแก้ index ให้ครอบฟิลด์ที่ query ใหม่ต้องการ

ในผลลัพธ์ของ explain ขั้นตอน COLLSCAN บ่งบอกอะไร?
การเปรียบเทียบใดบ่งชี้ query ที่ไม่มีประสิทธิภาพได้ดีที่สุด?
อะไรทำให้ query เป็น "covered"?