explain และ query plan
คุณสร้าง index ที่ดูสมบูรณ์แบบได้ แต่ก็ยังไม่รู้ว่าได้ผลจริงไหม ทางเดียวที่จะมั่นใจคือถาม MongoDB ตรง ๆ ว่ารัน query นี้ยังไง เครื่องมือนั้นคือ explain
พอส่ง query เข้าไป explain จะรายงาน plan ที่ server เลือก ว่าหยิบ index ตัวไหนไปใช้ หรือถอยไปสแกนทั้ง collection และในโหมดที่ละเอียดที่สุดก็รายงานตัวเลขจริงจากการรันด้วย การอ่านผลลัพธ์ตรงนี้คือทักษะที่ใช้งานได้จริงที่สุดของทั้งโมดูล เพราะเปลี่ยน “น่าจะเร็ว” ให้เป็น “เห็นกับตาแล้วว่าเร็ว”
ขอ execution stats
หัวข้อที่มีชื่อว่า “ขอ execution stats”explain("executionStats") รัน query แล้วคืนทั้ง plan ที่เลือกและจำนวนที่วัดได้ เอาไปแนบกับ query ไหนก็ได้ที่ยิงใส่ members ในห้องสมุดของเรา:
db.members .find({ city: "Berlin" }) .explain("executionStats")await db.collection("members") .find({ city: "Berlin" }) .explain("executionStats");db.members.find({"city": "Berlin"}).explain()var result bson.Mcmd := bson.D{ {Key: "explain", Value: bson.D{ {Key: "find", Value: "members"}, {Key: "filter", Value: bson.D{{Key: "city", Value: "Berlin"}}}, }}, {Key: "verbosity", Value: "executionStats"},}if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil { return err}let explain = db .run_command(doc! { "explain": { "find": "members", "filter": { "city": "Berlin" } }, "verbosity": "executionStats", }) .await?;การอ่าน COLLSCAN
หัวข้อที่มีชื่อว่า “การอ่าน COLLSCAN”ถ้าไม่มี index ครอบตัวกรอง ขั้นตอนของ plan ที่ชนะคือ COLLSCAN ตัวเลขที่บอกอะไรได้คือ totalDocsExamined — จำนวน document ที่ server ต้องอ่าน — เทียบกับ nReturned — จำนวนที่คืนกลับมาจริง การสแกนอ่านทุกอย่างเพื่อหาเพียงไม่กี่ตัว:
{ "queryPlanner": { "winningPlan": { "stage": "COLLSCAN", "direction": "forward" } }, "executionStats": { "nReturned": 3, "totalDocsExamined": 10000, "executionTimeMillis": 12 }}การตรวจหนึ่งหมื่น document เพื่อคืนสามตัวคืออาการตามตำราของการขาด index อัตราส่วนของ totalDocsExamined ต่อ nReturned คือมาตรวัดประสิทธิภาพของคุณ: ยิ่งใกล้หนึ่งต่อหนึ่งยิ่งดี
การอ่าน IXSCAN
หัวข้อที่มีชื่อว่า “การอ่าน IXSCAN”สร้าง 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 ซื้อให้คุณ ทำให้มองเห็นได้
planner เลือกอย่างไร
หัวข้อที่มีชื่อว่า “planner เลือกอย่างไร”เมื่อ 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"]
นี่คือเหตุผลที่ explain รายงาน winningPlan และบางครั้งรายการ rejectedPlans: คุณกำลังเห็นผลลัพธ์ของการแข่งขันนั้น
covered query
หัวข้อที่มีชื่อว่า “covered query”มีอีกหนึ่งระดับที่ดีกว่า 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")await db.collection("members") .find({ city: "Berlin" }, { projection: { joined: 1, _id: 0 } }) .explain("executionStats");db.members.find( {"city": "Berlin"}, {"joined": 1, "_id": 0},).explain()cmd := bson.D{ {Key: "explain", Value: bson.D{ {Key: "find", Value: "members"}, {Key: "filter", Value: bson.D{{Key: "city", Value: "Berlin"}}}, {Key: "projection", Value: bson.D{{Key: "joined", Value: 1}, {Key: "_id", Value: 0}}}, }}, {Key: "verbosity", Value: "executionStats"},}if err := db.RunCommand(ctx, cmd).Decode(&result); err != nil { return err}let explain = db .run_command(doc! { "explain": { "find": "members", "filter": { "city": "Berlin" }, "projection": { "joined": 1, "_id": 0 }, }, "verbosity": "executionStats", }) .await?;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 เป็นการเพิ่มประสิทธิภาพ ไม่ใช่ข้อบังคับ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
อ่าน 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 ใหม่ต้องการ