ทำไม index จึงสำคัญ
query ที่คืนคำตอบถูกต้องก็ยังเป็น query ที่ช้าได้ เมื่อคุณขอให้ MongoDB หาสมาชิกทุกคนที่เข้าร่วมในปี 2023 แล้วไม่มีอะไรช่วยเลย server ก็มีทางเลือกเดียว: เปิด collection ที่ document แรกแล้วอ่านไปข้างหน้า ตรวจทีละตัวจนครบทุก document การอ่านแบบกำลังดิบนี้เรียกว่า collection scan และ MongoDB ติดป้ายว่า COLLSCAN ในข้อมูลวินิจฉัย กับสมาชิกห้องสมุดไม่กี่คนคุณจะไม่รู้สึกอะไร แต่กับหนึ่งล้าน document นี่คือความต่างระหว่างตอบใน 1 มิลลิวินาทีกับตอบแบบคลาน
index คือทางแก้ เป็นโครงสร้างข้อมูลแยกที่เรียงลำดับไว้ และ server ดูแลควบคู่กับ collection ข้างในเก็บค่าของ field หนึ่งหรือหลาย field เรียงตามลำดับ พร้อม pointer ชี้กลับไปยัง document ฉบับเต็ม เพราะค่าเรียงไว้แล้ว server จึงกระโดดตรงไปยังค่าที่ต้องการได้แทนที่จะอ่านทั้งหมด เหมือนที่คุณพลิกไปท้ายเล่มตำราแทนการอ่านทุกหน้าเพื่อหาคำศัพท์ โมดูลนี้ว่าด้วยการสร้าง index ที่ถูกต้องให้ query ที่ถูกต้อง และพิสูจน์ชัยชนะด้วย explain
ตัวเลขทั้งหมดด้านล่างถูกจับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว (single node ไม่มีโหลดอื่น) ให้มองมิลลิวินาทีเป็นลำดับขนาด ส่วนจำนวน key/document เป็นค่าตรง ๆ แท็บ Python, Go และ Rust ถูกรันอย่างละหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน
scan เทียบกับ seek
หัวข้อที่มีชื่อว่า “scan เทียบกับ seek”ลองนึกภาพสองกลยุทธ์เทียบกัน เมื่อไม่มี index query เดินผ่านทั้ง collection เมื่อมี index มันไต่ลงต้นไม้ที่เรียงไว้แล้วลงตรงช่วงที่ match:
flowchart TB
subgraph NoIndex["Without an index - COLLSCAN"]
Q1["Query: joined equals 2023"] --> S0["Read doc 1"]
S0 --> S1["Read doc 2"]
S1 --> S2["Read doc 3"]
S2 --> S3["...read every document"]
S3 --> R1["Return the matches"]
end
subgraph WithIndex["With an index - IXSCAN"]
Q2["Query: joined equals 2023"] --> B0["Index root"]
B0 --> B1["Sorted branch"]
B1 --> B2["Leaf: joined equals 2023"]
B2 --> R2["Follow pointers to matching docs"]
end วัดผลบน 1 000 000 document
หัวข้อที่มีชื่อว่า “วัดผลบน 1 000 000 document”ทุกบทเตรียมข้อมูลของตัวเอง บทนี้สร้าง members หนึ่งล้านคน (name, ปีที่เข้าร่วม, fines, city, หนังสือที่ยืมหนึ่งเล่ม) และนิยาม plan() ตัวช่วยที่ย่อ explain("executionStats") เป็นบรรทัดเดียว: ลำดับ stage แล้วตามด้วย returned (ผลลัพธ์), keys (index entry ที่อ่าน) และ docs (document ที่อ่าน)
const cities = ["Berlin", "Paris", "Lima", "Oslo", "Cairo", "Tokyo", "Rome", "Lagos", "Dhaka", "Quito"];const mk = (i) => ({ name: "m" + i, joined: NumberInt(2000 + (i % 26)), fines: i % 7, city: cities[i % 10], borrowed: ["b" + (i % 500)] });globalThis.mk = mk;globalThis.plan = (cur) => { // one line per explain: stages, then the work counters 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";};const t = Date.now();for (let s = 0; s < 1000000; s += 50000) { db.members.insertMany(Array.from({ length: 50000 }, (_, k) => mk(s + k)), { ordered: false });}console.log("members:", db.members.countDocuments(), "docs, seeded in", Date.now() - t, "ms");assert.strictEqual(db.members.countDocuments(), 1000000);members: 1000000 docs, seeded in 6079 msขอสมาชิกหนึ่งคนตามชื่อโดยไม่มีอะไรช่วย server ต้องอ่านทั้งล้าน document เพื่อคืนแค่หนึ่ง:
console.log("no index :", plan(db.members.find({ name: "m777777" })));let t = Date.now(); db.members.find({ name: "m777777" }).toArray();console.log("wall clock:", Date.now() - t, "ms");assert.strictEqual(db.members.find({ name: "m777777" }).explain("executionStats").executionStats.totalDocsExamined, 1000000);no index : COLLSCAN returned=1 keys=0 docs=1000000 301mswall clock: 267 msทีนี้ index แรก createIndex รับ field กับทิศทาง (1 = ascending) และคืนชื่อ index - เป็น string เปล่า ๆ ไม่ใช่ reply ดิบของ command การสร้างบนหนึ่งล้าน document ใช้ราวหนึ่งวินาที:
let t = Date.now();console.log("createIndex returned:", db.members.createIndex({ name: 1 }), "in", Date.now() - t, "ms");console.log("with index:", plan(db.members.find({ name: "m777777" })));t = Date.now(); db.members.find({ name: "m777777" }).toArray();console.log("wall clock:", Date.now() - t, "ms");assert.strictEqual(db.members.find({ name: "m777777" }).explain("executionStats").executionStats.totalDocsExamined, 1);const c = db.collection("members");await c.insertMany(Array.from({ length: 1000 }, (_, i) => ({ name: "m" + i, joined: 2000 + (i % 26) })));const name = await c.createIndex({ joined: 1 });const e = await c.find({ joined: 2023 }).explain("executionStats");console.log(name, e.executionStats.totalKeysExamined, e.executionStats.totalDocsExamined, e.executionStats.nReturned);if (name !== "joined_1" || e.executionStats.totalDocsExamined !== e.executionStats.nReturned) throw new Error("index not used");db.members.create_index([("joined", 1)])_, err := coll.Indexes().CreateOne( ctx, mongo.IndexModel{Keys: bson.D{{Key: "joined", Value: 1}}},)if err != nil { return err}coll.create_index( IndexModel::builder().keys(doc! { "joined": 1 }).build(),).await?;createIndex returned: name_1 in 991 mswith index: FETCH > IXSCAN(name_1) returned=1 keys=1 docs=1 4mswall clock: 1 msquery เดิมตอนนี้อ่านหนึ่ง key และหนึ่ง document แทนหนึ่งล้าน แท็บ Node.js รันบน collection 1 000 document ของตัวเองและพิสูจน์รูปแบบเดียวกัน: joined_1 โดย key = document = ผลลัพธ์
joined_1 38 38 38In Compass: เปิด collection แล้วเลือกแท็บ Indexes คุณจะเห็น index _id_ ที่มีมาให้เสมอ และเพิ่มอันใหม่ได้ด้วยปุ่ม Create Index โดยพิมพ์ชื่อ field และเลือกทิศทาง
index ไม่ได้ชนะเสมอ: selectivity
หัวข้อที่มีชื่อว่า “index ไม่ได้ชนะเสมอ: selectivity”index ช่วยเพราะทำให้ server ข้าม document ได้ เมื่อ filter เก็บเกือบทั้ง collection ก็ไม่มีอะไรให้ข้าม joined มีค่าต่างกันเพียง 26 ค่า joined: 2023 เก็บข้อมูล 3.8% แต่ช่วงที่เก็บทุก document เป็นอีกเรื่อง hint() บังคับ plan เพื่อเทียบ:
db.members.createIndex({ joined: 1 });console.log("joined = 2023 (3.8% of docs), index :", plan(db.members.find({ joined: 2023 })));console.log("joined = 2023, forced COLLSCAN :", plan(db.members.find({ joined: 2023 }).hint({ $natural: 1 })));console.log("all members, index scan forced :", plan(db.members.find({ joined: { $gte: 2000 } }).hint({ joined: 1 })));console.log("all members, forced COLLSCAN :", plan(db.members.find({ joined: { $gte: 2000 } }).hint({ $natural: 1 })));joined = 2023 (3.8% of docs), index : FETCH > IXSCAN(joined_1) returned=38461 keys=38461 docs=38461 35msjoined = 2023, forced COLLSCAN : COLLSCAN returned=38461 keys=0 docs=1000000 291msall members, index scan forced : FETCH > IXSCAN(joined_1) returned=1000000 keys=1000000 docs=1000000 993msall members, forced COLLSCAN : COLLSCAN returned=1000000 keys=0 docs=1000000 293msสอง plan ที่บังคับสำหรับ “ทุกคน” อ่าน document จำนวนเท่ากัน แต่เส้นทางผ่าน index ต้องอ่าน key อีก 1 000 000 ตัวก่อน (ช้ากว่าราว 3 เท่าที่นี่) filter ที่คัดแทบไม่ได้ช่วยน้อย planner ชั่งน้ำหนักให้ ซึ่งเป็นเหตุผลที่ explain (ไม่ใช่สัญชาตญาณ) เป็นผู้ตัดสิน
index มีต้นทุนอะไร
หัวข้อที่มีชื่อว่า “index มีต้นทุนอะไร”ดิสก์และหน่วยความจำ index แต่ละอันเป็นโครงสร้างแยก ใน collection นี้ข้อมูลราว 100 MB และ index ใช้:
const s = db.members.stats(), mb = (n) => +(n / 1048576).toFixed(1);console.log({ dataMB: mb(s.size), indexMB: Object.fromEntries(Object.entries(s.indexSizes).map(([k, v]) => [k, mb(v)])) });assert.ok(s.indexSizes.name_1 > 0);{ dataMB: 100.6, indexMB: { _id_: 26.9, name_1: 11.9, joined_1: 5.3 } }เวลาเขียน ทุก insert, update field ที่มี index หรือ delete ต้องแก้ทุก index ด้วย เอกสาร 200 000 ชุดเดิม ครั้งแรกใส่ collection ที่มีแต่ _id_ อีกครั้งใส่อันที่มี index เพิ่มห้าอัน:
function bulkLoad(coll, n) { const t = Date.now(); for (let s = 0; s < n; s += 10000) coll.insertMany(Array.from({ length: 10000 }, (_, k) => mk(s + k)), { ordered: false }); return Date.now() - t;}const bare = bulkLoad(db.w0, 200000);["name", "joined", "fines", "city", "borrowed"].forEach((f) => db.w5.createIndex({ [f]: 1 }));const heavy = bulkLoad(db.w5, 200000);console.log("200 000 inserts, _id index only :", bare, "ms");console.log("200 000 inserts, _id + 5 indexes :", heavy, "ms");assert.ok(heavy > bare);200 000 inserts, _id index only : 1244 ms200 000 inserts, _id + 5 indexes : 2240 msMongoDB จำกัดหนึ่ง collection ไว้ที่ 64 index และ compound index ที่ 32 field (MongoDB limits, ตรวจ 2026-09-30) แต่เพดานจริงคืองบการเขียนของคุณซึ่งมาถึงก่อนนั้นมาก
หา index ที่ไม่มีใครใช้
หัวข้อที่มีชื่อว่า “หา index ที่ไม่มีใครใช้”$indexStats นับว่าแต่ละ index ถูกใช้ตอบ query กี่ครั้งตั้งแต่ server เริ่ม index ที่ ops: 0 เป็นตัวเลือกให้ลบ ก่อนลบ hideIndex() จะยังดูแล index ไว้แต่ซ่อนจาก planner คุณจึงเฝ้าดูอาการถดถอยได้และ unhideIndex() ทันทีถ้าช้าลง - ไม่ต้อง rebuild
db.members.find({ name: "m1" }).toArray();db.members.find({ joined: 2023 }).limit(1).toArray();const usage = db.members.aggregate([{ $indexStats: {} }, { $project: { _id: 0, name: 1, ops: "$accesses.ops" } }, { $sort: { name: 1 } }]).toArray();console.log(usage);db.members.hideIndex("joined_1");console.log("joined_1 hidden:", plan(db.members.find({ joined: 2023 })));db.members.unhideIndex("joined_1");console.log("joined_1 visible again:", plan(db.members.find({ joined: 2023 })));[ { name: '_id_', ops: Long('0') }, { name: 'joined_1', ops: Long('1') }, { name: 'name_1', ops: Long('2') }]joined_1 hidden: COLLSCAN returned=38461 keys=0 docs=1000000 305msjoined_1 visible again: FETCH > IXSCAN(joined_1) returned=38461 keys=38461 docs=38461 29msโมดูลนี้ครอบคลุมอะไรบ้าง
หัวข้อที่มีชื่อว่า “โมดูลนี้ครอบคลุมอะไรบ้าง”บทเรียนไล่จาก field เดียวไปจนครบชุดเครื่องมือ:
- ทำไม index จึงสำคัญ - scan เทียบ seek และต้นทุนของการไม่มีหรือมีมากเกินไป
- Single และ compound index - prefix rule ที่ตัดสินว่า compound index รับ query ไหนได้
- กฎ ESR - ลำดับ Equality, Sort, Range พร้อมตัวเลขวัดจริง
- Explain และ query plan - อ่าน
explain, rejected plan, plan cache และ covered query - ชนิดของ index - multikey, text, TTL, partial, unique, sparse, wildcard, hashed และ geospatial
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ทุก collection เริ่มด้วย unique index บน
_idหนึ่งอัน ลบไม่ได้ createIndexคืนชื่อ index เป็น string ธรรมดา ("name_1") และการสร้าง index ที่มีอยู่แล้วไม่ทำอะไร - แต่ key เดิมที่ option ต่างกันเป็น error (ดูด้านล่าง)- index ใช้ดิสก์ หน่วยความจำ และเวลาเขียน (ราว 11.9 MB และ insert ช้าลงสำหรับ string field เดียวที่นี่) ทำ index เฉพาะที่ query ใช้ และใช้
$indexStatsหาที่เหลือ - field ที่ selectivity ต่ำ (26 ปี, fines 7 ค่า) แทบไม่คุ้มเป็น index เดี่ยว ๆ ให้วางไว้หลัง field ที่คัดได้ดีใน compound index
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ผู้ช่วย AI ที่ถูกสั่งให้ “ทำให้การค้นหาสมาชิกเร็ว” มักเขียนแบบนี้พร้อมคอมเมนต์ที่ฟังดูสบายใจ:
🐛 หา bug ในโค้ดนี้
// AI comment: "make sure the index exists, then query"app.get("/members/:email", async (req, res) => {const members = db.collection("members");await members.createIndex({ email: 1 }, { unique: true }); // safe: createIndex is idempotentres.json(await members.findOne({ email: req.params.email }));});เฉลย
คอมเมนต์ถูกครึ่งเดียว: createIndex เป็น idempotent การเรียกซ้ำจึงไม่ rebuild แต่ไม่ฟรีและไม่ปลอดภัย รัน 500 ครั้งกับ index ที่มีอยู่แล้วเสียหนึ่ง round trip ทุกครั้ง (ตัวเลขด้านล่าง) และเมื่อชุด option เปลี่ยน - ที่นี่ deploy ถัดมาเพิ่ม unique: true ให้ key เดิม - ทุก request จะ throw ไม่ใช่แค่ครั้งแรก เพราะ index email_1 มีอยู่แล้วด้วย option ต่างกัน การจัดการ index ควรอยู่ในขั้น startup/migration ที่รันครั้งเดียว พร้อม name ชัดเจน:
db.people.insertMany(Array.from({ length: 200 }, (_, i) => ({ email: "p" + i + "@x.test" })));db.people.createIndex({ email: 1 }); // deploy 1: plain indexlet t = Date.now();for (let i = 0; i < 500; i++) db.people.createIndex({ email: 1 }); // "make sure the index exists" on every requestconsole.log("500 repeated createIndex calls:", Date.now() - t, "ms, indexes:", db.people.getIndexes().length);try { db.people.createIndex({ email: 1 }, { unique: true }); } // deploy 2: same key, new optioncatch (e) { console.log(e.codeName + ":", e.message); }assert.strictEqual(db.people.getIndexes().length, 2);db.people.dropIndex("email_1");console.log("fixed startup script:", db.people.createIndex({ email: 1 }, { unique: true, name: "email_unique" }));500 repeated createIndex calls: 158 ms, indexes: 2IndexKeySpecsConflict: An existing index has the same name as the requested index. When index names are not specified, they are auto generated and can cause conflicts. Please refer to our documentation. Requested index: { v: 2, unique: true, key: { email: 1 }, name: "email_1" }, existing index: { v: 2, key: { email: 1 }, name: "email_1" }fixed startup script: email_uniqueการแก้ผ่านได้หลังลบ index เดิมที่ไม่ unique เสียก่อนเท่านั้น: การเปลี่ยน index ต้อง drop แล้วสร้างใหม่อย่างชัดเจน (หรือ collMod สำหรับบางคุณสมบัติ) ไม่ใช่สร้างซ้ำแบบเงียบ ๆ
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”index คือสำเนาที่สองที่เรียงลำดับของบาง field พร้อม pointer ย้อนกลับ การอ่านที่ใช้มันได้จ่ายแค่ entry ที่ match ส่วนการเขียนทุกครั้งจ่ายทั้งข้อมูลและทุก index ที่เกี่ยวกับ field ที่เปลี่ยน การตัดสินใจคือ อ่านที่ประหยัดได้ เทียบกับเขียนและหน่วยความจำที่เสียไป และ explain คือสมุดบัญชี
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| ทำ index field ที่ query ใช้บ่อย | COLLSCAN 1 000 000 document กลายเป็น 1 key และ 1 document | ดิสก์เพิ่ม (11.9 MB สำหรับ name_1) และเขียนช้าลง |
| ไม่สร้าง index เลย | insert เร็วที่สุด ไม่มีอะไรต้องดูแล | ทุก query เป็น COLLSCAN ที่โตตามข้อมูล |
| ทำ index field ที่ selectivity ต่ำอย่างเดี่ยว ๆ | ช่วยเมื่อค่านั้นหายาก | ได้น้อยเมื่อ filter เก็บเกือบทั้งหมด แต่ยังจ่ายต้นทุนเขียน |
| hide ก่อนลบ | ทดสอบย้อนกลับได้ว่า “ต้องใช้ index นี้ไหม” | ยังจ่ายต้นทุนเขียนขณะซ่อน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “index ทำให้ query เร็วขึ้นเสมอ” - filter ที่เก็บทุก document อ่าน key 1 000 000 และ document 1 000 000 ผ่าน index ไม่ดีไปกว่า scan
- “index เป็นตัวช่วยฟรีที่กระทบแต่การอ่าน” - index เพิ่มห้าอันทำให้ insert 200 000 รายการชุดเดิมช้าลง (ตัวเลขด้านบน)
- “เรียก
createIndexทุกครั้งที่ startup หรือทุก request ไม่เป็นไร” - ไม่ทำอะไรก็ต่อเมื่อ option เหมือนเดิม; option ที่เปลี่ยนทำให้เกิดIndexKeySpecsConflict - “index ที่ไม่ใช้ไม่เป็นอันตราย” - มันยังถูกอัปเดตทุกครั้งที่เขียนและกินแคช;
$indexStatsบอกว่าอันไหนว่าง
ตัวอย่างจากของจริง
Performance Advisor ของ MongoDB Atlas “monitors queries that MongoDB considers slow and suggests new indexes to improve query performance” พร้อม query ตัวอย่างที่จัดกลุ่มตาม query shape และใช้ได้เฉพาะ cluster M10 ขึ้นไป (Performance Advisor, ตรวจ 2026-09-30) ถือคำแนะนำของมันเป็นจุดเริ่มต้นและยืนยันด้วย
explainก่อนใช้ เพราะทุกคำแนะนำคือ index อีกหนึ่งอันที่ต้องดูแล
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (สร้าง index joined แล้วอ่านชื่อกลับมา) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส