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

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

mongosh/exp-seed.js
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

explain("executionStats") รัน query และคืนทั้ง plan ที่เลือกและตัวเลขที่วัดได้:

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

เมื่อ name ไม่มี index stage ที่ชนะคือ COLLSCAN คู่ที่บอกความจริงคือ docs (document ที่ตรวจ) เทียบกับ returned; การสแกนอ่านทุกอย่างเพื่อหาไม่กี่รายการ หลัง createIndex({ name: 1 }) stage บนสุดคือ FETCH ที่รับจาก IXSCAN ซึ่งอ่านหนึ่ง key:

mongosh/exp-scan.js
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 64ms
name_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:

app/exp-node.mjs
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 }

explain() รับ verbosity สิ่งที่เพิ่มเห็นได้จาก key ระดับบนและ executionStats:

mongosh/exp-modes.js
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, queryShapeHash
executionStats top-level: explainVersion, queryPlanner, executionStats, queryShapeHash
executionStats: executionSuccess, nReturned, executionTimeMillis, totalKeysExamined, totalDocsExamined, executionStages
allPlansExecution top-level: explainVersion, queryPlanner, executionStats, queryShapeHash
executionStats: executionSuccess, nReturned, executionTimeMillis, totalKeysExamined, totalDocsExamined, executionStages, allPlansExecution
explainVersion: 1 | winningPlan.isCached: false | winningPlan.nss: b2_b52af1.members
queryPlanner.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 หรือไม่

เมื่อหลาย 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"]
planner ทดลองผู้เข้าชิงแต่ละราย เลือกตัวที่ถูกที่สุด และ cache ผู้ชนะสำหรับ query ที่ตรงกัน
mongosh/exp-planner.js
db.members.createIndex({ city: 1 });
db.members.createIndex({ joined: 1, fines: 1 });
const q = { city: "Berlin", joined: 2023 }; // both indexes can help
const 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 8ms
forced city_1: FETCH > IXSCAN(city_1) returned=769 keys=20000 docs=20000 15ms

rejectedPlans แสดงผู้แพ้ ในการทดลองแต่ละผู้เข้าชิงได้ราว 1 003 works: ผู้ชนะให้ 101 ผลลัพธ์ในงบนั้น ผู้แพ้ 38 จึงได้คะแนนต่างกัน การทดลองเป็น heuristic - การรันเต็มยืนยันตรงนี้ (7 690 key เทียบ 20 000) แต่ planner ผิดได้เมื่อข้อมูลเบ้ เหตุผลที่มี hint() และการตรวจซ้ำเมื่อข้อมูลเปลี่ยน

ผู้ชนะถูก cache ตาม query shape การรันครั้งถัดไปจึงข้ามการแข่ง $planCacheStats แสดง cache; explain อย่างเดียวไม่สร้าง entry การรันจริงเท่านั้นที่สร้าง:

mongosh/exp-cache.js
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 do
const 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: true
after getPlanCache().clear(): 0 entries

มีระดับที่ดีกว่า 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 ถูกคืนเป็นค่าเริ่มต้น จึงมักต้องปิด

mongosh/exp-covered.js
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 18ms
covered : PROJECTION_COVERED > IXSCAN(city_1_joined_1) returned=20000 keys=20000 docs=0 10ms
forgot _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

ไม่ใช่ทุก explain ที่หน้าตาเป็นสาย FETCH > IXSCAN ข้างบน บาง pipeline (ที่นี่คือ $group) รันบน slot-based execution engine และรายงาน explainVersion: 2 พร้อม queryPlan (stage เชิงตรรกะ) และ slotBasedPlan (โปรแกรมที่รันจริง) การตั้งค่าเบื้องหลังบน server นี้คือ internalQueryFrameworkControl: "trySbeRestricted" ที่เห็นได้ใน serverParameters:

mongosh/exp-sbe.js
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, inputStage
aggregate explainVersion 2 | winningPlan keys: isCached, queryPlan, slotBasedPlan
queryPlan : GROUP > COLLSCAN
slotBasedPlan.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 ได้อาจต่างกันระหว่างเวอร์ชัน จงอ่านรูปทรงแทนการสมมติ

mongosh/exp-update.js
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: 599994

explain บน 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 ไม่มี

เมื่อถูกขอ “เพิ่ม 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:

mongosh/exp-bug.js
db.members.dropIndexes(); // only _id_ ...
db.members.createIndex({ city: 1 }); // ... and the "indexed" city field
const looksIndexed = (cur) => JSON.stringify(cur.explain("queryPlanner").queryPlanner.winningPlan).includes("IXSCAN"); // the AI check
const 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 members
console.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 16ms
ratio check says efficient: false
after { city, name } : true | FETCH > IXSCAN(city_1_name_1) returned=1 keys=1 docs=1 0ms

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 querydocs=0: ไม่อ่าน documentindex ต้องมีทุก field ที่ filter และคืน
  • “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 the explain() method” (query optimization, ตรวจ 2026-09-30) Atlas แสดง query ช้าแบบเดียวกันใน Performance Advisor และคุณยืนยันแต่ละคำแนะนำด้วย explain ก่อนเพิ่ม index

แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (การเรียก explain คืนเอกสาร plan) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

ใน output ของ explain stage COLLSCAN บอกอะไร
การเปรียบเทียบใดบ่งบอก query ไม่มีประสิทธิภาพที่สุด
อะไรทำให้ query เป็น "covered"
explain verbosity ใดแสดงตัวเลขการทดลองของ candidate ที่แพ้
การรัน explain("executionStats") กับ updateMany เปลี่ยนข้อมูลหรือไม่
เมื่อไรที่ explain แสดง winningPlan.isCached: true