Explain และ query plan
คุณสร้าง index สมบูรณ์แบบได้แต่ก็ยังไม่รู้ว่ามันได้ผล วิธีเดียวที่แน่ใจคือถาม MongoDB ว่ามันรัน query จริงอย่างไร และเครื่องมือคือ explain เมื่อได้ query แล้ว explain รายงาน plan ที่ server เลือก - ใช้ index ไหน หรือถอยกลับไปสแกนทั้งหมด - และในโหมดที่ละเอียดกว่ามีตัวเลขจริงจากการรัน การอ่าน output นี้คือทักษะที่ใช้ได้จริงที่สุดของโมดูล: เปลี่ยน “ฉันคิดว่าเร็ว” เป็น “ฉันเห็นว่าเร็ว”
output ด้านล่างจับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว บทนี้เตรียม members 200 000 รายการ (10 เมือง 26 ปี) และนิยาม plan() ตัวสรุปบรรทัดเดียวของ explain("executionStats"): ลำดับ stage แล้วผลลัพธ์ (returned) entry ของ index ที่อ่าน (keys) document ที่อ่าน (docs) และมิลลิวินาทีฝั่ง server แท็บ Python, Go และ Rust ถูกรันอย่างละหนึ่งครั้งในโปรแกรม scratch
const cities = ["Berlin", "Paris", "Lima", "Oslo", "Cairo", "Tokyo", "Rome", "Lagos", "Dhaka", "Quito"];globalThis.plan = (cur) => { // stage chain, then results, keys, documents, server-side ms const e = cur.explain("executionStats"), s = e.executionStats, stages = []; for (let p = e.queryPlanner.winningPlan; p; p = p.inputStage) stages.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : "")); return stages.join(" > ") + " returned=" + s.nReturned + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined + " " + s.executionTimeMillis + "ms";};for (let s = 0; s < 200000; s += 50000) { db.members.insertMany(Array.from({ length: 50000 }, (_, k) => { const i = s + k; return { name: "m" + i, joined: NumberInt(2000 + (Math.floor(i / 10) % 26)), fines: i % 7, city: cities[i % 10] }; }), { ordered: false });}console.log("members:", db.members.countDocuments());members: 200000ขอ execution stats
หัวข้อที่มีชื่อว่า “ขอ execution stats”explain("executionStats") รัน query และคืนทั้ง plan ที่เลือกและตัวเลขที่วัดได้:
db.members .find({ city: "Berlin" }) .explain("executionStats")await db.collection("members") .find({ city: "Berlin" }) .explain("executionStats");db.members.find({"city": "Berlin"}).explain()var res bson.Merr := db.RunCommand(ctx, bson.D{ {Key: "explain", Value: bson.D{{Key: "find", Value: "members"}, {Key: "filter", Value: bson.M{"city": "Berlin"}}}}, {Key: "verbosity", Value: "executionStats"},}).Decode(&res)if err != nil { return err}let res = db .run_command(doc! { "explain": { "find": "members", "filter": { "city": "Berlin" } }, "verbosity": "executionStats", }) .await?;อ่าน COLLSCAN และ IXSCAN
หัวข้อที่มีชื่อว่า “อ่าน COLLSCAN และ IXSCAN”เมื่อ name ไม่มี index stage ที่ชนะคือ COLLSCAN คู่ที่บอกความจริงคือ docs (document ที่ตรวจ) เทียบกับ returned; การสแกนอ่านทุกอย่างเพื่อหาไม่กี่รายการ หลัง createIndex({ name: 1 }) stage บนสุดคือ FETCH ที่รับจาก IXSCAN ซึ่งอ่านหนึ่ง key:
console.log("no index :", plan(db.members.find({ name: "m123456" })));db.members.createIndex({ name: 1 });console.log("name_1 :", plan(db.members.find({ name: "m123456" })));no index : COLLSCAN returned=1 keys=0 docs=200000 64msname_1 : FETCH > IXSCAN(name_1) returned=1 keys=1 docs=1 1msตรวจ 200 000 document เพื่อคืนหนึ่งคือ อาการตำราของ index ที่หายไป 1 key และ 1 document คือชัยชนะ อัตราส่วน docs ต่อ returned คือเกจประสิทธิภาพ - ยิ่งใกล้ 1:1 ยิ่งดี แท็บ Node.js พิสูจน์เรื่องเดียวกันบน 10 000 document:
const c = db.collection("members");await c.insertMany(Array.from({ length: 10000 }, (_, i) => ({ name: "m" + i, city: ["Berlin", "Oslo"][i % 2] })));const before = (await c.find({ city: "Berlin" }).explain("executionStats")).executionStats;await c.createIndex({ city: 1 });const after = (await c.find({ city: "Berlin" }).explain("executionStats")).executionStats;console.log({ noIndexDocs: before.totalDocsExamined, indexedDocs: after.totalDocsExamined, returned: after.nReturned });if (before.totalDocsExamined !== 10000 || after.totalDocsExamined !== after.nReturned) throw new Error("unexpected plan");{ noIndexDocs: 10000, indexedDocs: 5000, returned: 5000 }สาม verbosity
หัวข้อที่มีชื่อว่า “สาม verbosity”explain() รับ verbosity สิ่งที่เพิ่มเห็นได้จาก key ระดับบนและ executionStats:
const q = { name: "m123456" };for (const mode of ["queryPlanner", "executionStats", "allPlansExecution"]) { const e = db.members.find(q).explain(mode); console.log(mode.padEnd(18), "top-level:", Object.keys(e).filter((k) => !["serverInfo", "serverParameters", "ok", "$clusterTime", "operationTime", "command"].includes(k)).join(", ")); if (e.executionStats) console.log(" ".repeat(18), "executionStats:", Object.keys(e.executionStats).join(", "));}const e = db.members.find(q).explain("queryPlanner");console.log("explainVersion:", e.explainVersion, "| winningPlan.isCached:", e.queryPlanner.winningPlan.isCached, "| winningPlan.nss:", e.queryPlanner.winningPlan.nss);console.log("queryPlanner.planCacheShapeHash / planCacheKey:", e.queryPlanner.planCacheShapeHash, e.queryPlanner.planCacheKey);queryPlanner top-level: explainVersion, queryPlanner, queryShapeHashexecutionStats top-level: explainVersion, queryPlanner, executionStats, queryShapeHash executionStats: executionSuccess, nReturned, executionTimeMillis, totalKeysExamined, totalDocsExamined, executionStagesallPlansExecution top-level: explainVersion, queryPlanner, executionStats, queryShapeHash executionStats: executionSuccess, nReturned, executionTimeMillis, totalKeysExamined, totalDocsExamined, executionStages, allPlansExecutionexplainVersion: 1 | winningPlan.isCached: false | winningPlan.nss: b2_b52af1.membersqueryPlanner.planCacheShapeHash / planCacheKey: F4DDDCDC 34C29116- queryPlanner (ค่าเริ่มต้น) วาง plan แต่ไม่รัน query: ปลอดภัยและทันที แต่ไม่มีตัวเลข
- executionStats รัน plan ที่ชนะจนจบและเพิ่ม counter
- allPlansExecution ยังรายงานการทดลองของทุก plan ผู้เข้าชิง
บน 8.3 find รายงาน explainVersion: 1 (classic engine) และแต่ละ stage มี nss คือ namespace; isCached บอกว่า plan มาจาก plan cache หรือไม่
planner เลือกอย่างไร
หัวข้อที่มีชื่อว่า “planner เลือกอย่างไร”เมื่อหลาย index รับ query ได้ MongoDB ไม่เดา มันสร้าง plan ผู้เข้าชิงสำหรับแต่ละอัน รันใน การทดลอง สั้น ๆ ที่หยุดหลังจำนวนหน่วยงานคงที่ ให้คะแนน แล้วเลือกผู้ชนะ query ด้านล่างใช้ได้ทั้ง city_1 หรือ joined_1_fines_1:
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"]
db.members.createIndex({ city: 1 });db.members.createIndex({ joined: 1, fines: 1 });const q = { city: "Berlin", joined: 2023 }; // both indexes can helpconst all = db.members.find(q).explain("allPlansExecution");const wp = all.queryPlanner.winningPlan;console.log("winner :", wp.inputStage.indexName, "| rejected:", all.queryPlanner.rejectedPlans.map((p) => p.inputStage.indexName));console.log("trial run (each candidate gets a capped number of works):");console.log(all.executionStats.allPlansExecution.map((p) => { let s = p.executionStages; while (s.inputStage) s = s.inputStage; return { index: s.indexName, nReturned: p.nReturned, keys: p.totalKeysExamined, score: +p.score.toFixed(4) };}));console.log("full run:", plan(db.members.find(q)));console.log("forced city_1:", plan(db.members.find(q).hint("city_1")));winner : joined_1_fines_1 | rejected: [ 'city_1' ]trial run (each candidate gets a capped number of works):[ { index: 'joined_1_fines_1', nReturned: 101, keys: 1003, score: 1.1009 }, { index: 'city_1', nReturned: 38, keys: 1003, score: 1.0381 }]full run: FETCH > IXSCAN(joined_1_fines_1) returned=769 keys=7690 docs=7690 8msforced city_1: FETCH > IXSCAN(city_1) returned=769 keys=20000 docs=20000 15msrejectedPlans แสดงผู้แพ้ ในการทดลองแต่ละผู้เข้าชิงได้ราว 1 003 works: ผู้ชนะให้ 101 ผลลัพธ์ในงบนั้น ผู้แพ้ 38 จึงได้คะแนนต่างกัน การทดลองเป็น heuristic - การรันเต็มยืนยันตรงนี้ (7 690 key เทียบ 20 000) แต่ planner ผิดได้เมื่อข้อมูลเบ้ เหตุผลที่มี hint() และการตรวจซ้ำเมื่อข้อมูลเปลี่ยน
plan cache
หัวข้อที่มีชื่อว่า “plan cache”ผู้ชนะถูก cache ตาม query shape การรันครั้งถัดไปจึงข้ามการแข่ง $planCacheStats แสดง cache; explain อย่างเดียวไม่สร้าง entry การรันจริงเท่านั้นที่สร้าง:
const q = { city: "Berlin", joined: 2023 };console.log("before any real execution:", db.members.aggregate([{ $planCacheStats: {} }]).toArray().length, "entries");for (let i = 0; i < 3; i++) db.members.find(q).toArray(); // explain() does not populate the cache; real runs doconst entry = db.members.aggregate([{ $planCacheStats: {} }]).toArray()[0];console.log({ isActive: entry.isActive, works: Number(entry.works), cachedPlanStage: entry.cachedPlan.stage, createdFromQuery: entry.createdFromQuery });console.log("explain now says isCached:", db.members.find(q).explain().queryPlanner.winningPlan.isCached);db.members.getPlanCache().clear();console.log("after getPlanCache().clear():", db.members.aggregate([{ $planCacheStats: {} }]).toArray().length, "entries");before any real execution: 0 entries{ isActive: true, works: 1003, cachedPlanStage: 'FETCH', createdFromQuery: { query: { city: 'Berlin', joined: 2023 }, sort: {}, projection: {} }}explain now says isCached: trueafter getPlanCache().clear(): 0 entriescovered query
หัวข้อที่มีชื่อว่า “covered query”มีระดับที่ดีกว่า IXSCAN บวก FETCH: query ที่ index ตอบได้ โดยไม่ fetch document เลย MongoDB นิยามว่าเป็น “a query that can be satisfied entirely using an index and doesn’t have to examine any documents”: ทุก field ที่ filter และที่คืนอยู่ใน index เดียว (query optimization, ตรวจ 2026-09-30) plan ไม่มี FETCH และ docs=0 _id ถูกคืนเป็นค่าเริ่มต้น จึงมักต้องปิด
db.members.createIndex({ city: 1, joined: 1 });console.log("needs name :", plan(db.members.find({ city: "Berlin" }, { name: 1, _id: 0 })));console.log("covered :", plan(db.members.find({ city: "Berlin" }, { joined: 1, _id: 0 })));console.log("forgot _id :", plan(db.members.find({ city: "Berlin" }, { joined: 1 })));assert.match(plan(db.members.find({ city: "Berlin" }, { joined: 1, _id: 0 })), /PROJECTION_COVERED/);needs name : PROJECTION_SIMPLE > FETCH > IXSCAN(city_1) returned=20000 keys=20000 docs=20000 18mscovered : PROJECTION_COVERED > IXSCAN(city_1_joined_1) returned=20000 keys=20000 docs=0 10msforgot _id : PROJECTION_SIMPLE > FETCH > IXSCAN(city_1) returned=20000 keys=20000 docs=20000 17msบรรทัดที่หนึ่งและสามแสดงสองรอยรั่วที่พบบ่อย: ขอ field ที่ index ไม่มี (name) และลืม _id: 0 - ทั้งคู่เพิ่ม FETCH และอ่าน 20 000 document สังเกตด้วยว่าเมื่อไม่ปิด _id planner ไม่ได้เลือก compound index ด้วยซ้ำ
In Compass: แท็บ Explain Plan แสดงเป็นต้นไม้ของ stage พร้อมตัวเลขเดียวกัน คุณจึงยืนยัน IXSCAN เห็น COLLSCAN ที่หลุดมา และเทียบ Documents Examined กับ Documents Returned
plan ของ aggregation และ slot-based engine
หัวข้อที่มีชื่อว่า “plan ของ aggregation และ slot-based engine”ไม่ใช่ทุก explain ที่หน้าตาเป็นสาย FETCH > IXSCAN ข้างบน บาง pipeline (ที่นี่คือ $group) รันบน slot-based execution engine และรายงาน explainVersion: 2 พร้อม queryPlan (stage เชิงตรรกะ) และ slotBasedPlan (โปรแกรมที่รันจริง) การตั้งค่าเบื้องหลังบน server นี้คือ internalQueryFrameworkControl: "trySbeRestricted" ที่เห็นได้ใน serverParameters:
const f = db.members.find({ city: "Berlin" }).explain();const a = db.members.explain().aggregate([{ $group: { _id: "$city", n: { $sum: 1 } } }]);console.log("find explainVersion", f.explainVersion, "| winningPlan keys:", Object.keys(f.queryPlanner.winningPlan).join(", "));console.log("aggregate explainVersion", a.explainVersion, "| winningPlan keys:", Object.keys(a.queryPlanner.winningPlan).join(", "));console.log("queryPlan :", a.queryPlanner.winningPlan.queryPlan.stage, ">", a.queryPlanner.winningPlan.queryPlan.inputStage.stage);console.log("slotBasedPlan.stages:\n" + a.queryPlanner.winningPlan.slotBasedPlan.stages.replace(/@"[0-9a-f-]+"/, '@"<collection uuid>"'));find explainVersion 1 | winningPlan keys: isCached, stage, nss, inputStageaggregate explainVersion 2 | winningPlan keys: isCached, queryPlan, slotBasedPlanqueryPlan : GROUP > COLLSCANslotBasedPlan.stages:[3] project [s7 = newBsonObj("_id", s6, "n", s4)][3] group [s6] [s4 = _internalCount::accumulate(s4, 1)] spillSlots[s5] mergingExprs[_internalCount::merge(s5)][3] project [s6 = (s3 ?: null)][1] scan generic [s1 = record, s2 = recordId] [s3 = city] @"<collection uuid>"อ่าน queryPlan (GROUP > COLLSCAN) แบบเดียวกับ plan classic; slotBasedPlan สำหรับวิศวกรที่ debug engine engine ที่ query ได้อาจต่างกันระหว่างเวอร์ชัน จงอ่านรูปทรงแทนการสมมติ
explain บน write คือ dry run
หัวข้อที่มีชื่อว่า “explain บน write คือ dry run”const total = () => db.members.aggregate([{ $group: { _id: null, f: { $sum: "$fines" } } }]).toArray()[0].f;const before = total();const e = db.runCommand({ explain: { update: "members", updates: [{ q: { city: "Berlin" }, u: { $inc: { fines: 1 } }, multi: true }] }, verbosity: "executionStats",});const st = e.executionStats.executionStages;console.log({ topStage: st.stage, nWouldModify: st.nWouldModify, nMatched: st.nMatched, inputStage: st.inputStage.stage });console.log("sum of fines before:", before, "after explain:", total());assert.strictEqual(before, total());{ topStage: 'UPDATE', nWouldModify: 20000, nMatched: 20000, inputStage: 'FETCH'}sum of fines before: 599994 after explain: 599994explain บน update วาง plan และ (ใน executionStats) รายงาน nWouldModify โดยไม่ใช้ update จริง คุณจึงตรวจขอบเขตของการเปลี่ยนจำนวนมากก่อนได้
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- อัตราส่วนหลักคือ
docsต่อreturnedใกล้ 1:1 index ทำงานดี ถ้าพอง แปลว่ามีอะไรสแกนหรือกรองช้า executionStatsรัน query (อ่านอย่างเดียวสำหรับfind) ดังนั้นกับการอ่านที่แพงควรระวัง;queryPlannerไม่เคยรัน- covered query ต้องมี
_id: 0(เว้นแต่_idอยู่ใน index) และมีแต่ field ที่ทำ index FETCHไม่ใช่ความล้มเหลว - แค่ projection ต้องการ field ที่ index ไม่มี
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอ “เพิ่ม guard ที่ทำให้ CI ล้มเมื่อ query ไม่มี index” AI เขียน check แบบที่คนส่วนใหญ่คิด:
🐛 หา bug ในโค้ดนี้
// AI comment: "fail the build if the winning plan has no index scan"const plan = await members.find({ city: "Berlin", name: "m1000" }).explain();if (!JSON.stringify(plan.queryPlanner.winningPlan).includes("IXSCAN")) throw new Error("query is not indexed");เฉลย
“ใช้ index” กับ “มีประสิทธิภาพ” เป็นคนละข้ออ้าง เมื่อมีแค่ { city: 1 } query นี้ผ่าน check แต่ตรวจ 20 000 document เพื่อคืนหนึ่งรายการ เพราะ index จำกัดแค่ city check ต้องดู executionStats และเทียบงานกับผลลัพธ์ (ที่นี่เป็น guard แบบอัตราส่วน) และทางแก้จริงคือ index ที่ตรงทั้ง filter:
db.members.dropIndexes(); // only _id_ ...db.members.createIndex({ city: 1 }); // ... and the "indexed" city fieldconst looksIndexed = (cur) => JSON.stringify(cur.explain("queryPlanner").queryPlanner.winningPlan).includes("IXSCAN"); // the AI checkconst efficient = (cur) => { const s = cur.explain("executionStats").executionStats; return s.totalDocsExamined <= 10 * Math.max(1, s.nReturned); };const q = { city: "Berlin", name: "m1000" }; // one match in 20 000 Berlin membersconsole.log("AI check says indexed:", looksIndexed(db.members.find(q)), "|", plan(db.members.find(q)));console.log("ratio check says efficient:", efficient(db.members.find(q)));db.members.createIndex({ city: 1, name: 1 });console.log("after { city, name } :", efficient(db.members.find(q)), "|", plan(db.members.find(q)));AI check says indexed: true | FETCH > IXSCAN(city_1) returned=1 keys=20000 docs=20000 16msratio check says efficient: falseafter { city, name } : true | FETCH > IXSCAN(city_1_name_1) returned=1 keys=1 docs=1 0msMental Model
หัวข้อที่มีชื่อว่า “Mental Model”explain คือใบเสร็จของ query planner queryPlanner คือใบเสนอราคา (สิ่งที่ตั้งใจทำ) executionStats คือใบแจ้งหนี้ (สิ่งที่ทำจริง) และ allPlansExecution ยังแสดงใบเสนอราคาที่ถูกปฏิเสธ plan ที่ถูกที่สุดคือที่ keys และ docs ใกล้ returned
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
explain("queryPlanner") | ทันที ไม่รัน query | ไม่มีตัวเลข - เห็นความเปลืองระดับ 20 000 document ไม่ได้ |
explain("executionStats") | keys และ documents ที่ตรวจจริง | รัน query; เพิ่มโหลดกับการอ่านใหญ่ |
allPlansExecution | แสดงว่าทำไม plan ชนะหรือแพ้ | รันการทดลองของทุกผู้เข้าชิง; output ยาว |
| covered query | docs=0: ไม่อ่าน document | index ต้องมีทุก field ที่ filter และคืน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “IXSCAN ใน plan แปลว่า query เร็ว” - มันตรวจ 20 000 document เพื่อ 1 ผลลัพธ์ผ่าน
city_1 - “
explain()รัน query” - มีแต่executionStats/allPlansExecution; ค่าเริ่มต้นแค่วาง plan - “plan ที่ cache ปรากฏหลัง explain” -
$planCacheStatsว่างหลัง explain และมีของหลังรันจริง - “explain บน update เปลี่ยนข้อมูล” -
nWouldModify: 20000ผลรวม fines ไม่เปลี่ยน - “ทุก explain มีสาย
winningPlan.inputStage” - aggregation อาจรายงานqueryPlanและslotBasedPlan
ตัวอย่างจากของจริง
เอกสารของ MongoDB เองบอกวิธีตรวจคุณสมบัติที่บทนี้วัด: “To determine whether a query is a covered query, use the
db.collection.explain()or theexplain()method” (query optimization, ตรวจ 2026-09-30) Atlas แสดง query ช้าแบบเดียวกันใน Performance Advisor และคุณยืนยันแต่ละคำแนะนำด้วยexplainก่อนเพิ่ม index
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (การเรียก explain คืนเอกสาร plan) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส