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

ทำไม 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 เดียวกัน

ลองนึกภาพสองกลยุทธ์เทียบกัน เมื่อไม่มี 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
การสแกนอ่านทุก document ส่วนการ seek ด้วย index กระโดดไปยังช่วงที่ตรงกันแล้วตาม pointer

ทุกบทเตรียมข้อมูลของตัวเอง บทนี้สร้าง members หนึ่งล้านคน (name, ปีที่เข้าร่วม, fines, city, หนังสือที่ยืมหนึ่งเล่ม) และนิยาม plan() ตัวช่วยที่ย่อ explain("executionStats") เป็นบรรทัดเดียว: ลำดับ stage แล้วตามด้วย returned (ผลลัพธ์), keys (index entry ที่อ่าน) และ docs (document ที่อ่าน)

mongosh/idx-seed.js
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 เพื่อคืนแค่หนึ่ง:

mongosh/idx-collscan.js
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 301ms
wall clock: 267 ms

ทีนี้ index แรก createIndex รับ field กับทิศทาง (1 = ascending) และคืนชื่อ index - เป็น string เปล่า ๆ ไม่ใช่ reply ดิบของ command การสร้างบนหนึ่งล้าน document ใช้ราวหนึ่งวินาที:

mongosh/idx-create.js
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);
createIndex returned: name_1 in 991 ms
with index: FETCH > IXSCAN(name_1) returned=1 keys=1 docs=1 4ms
wall clock: 1 ms

query เดิมตอนนี้อ่านหนึ่ง key และหนึ่ง document แทนหนึ่งล้าน แท็บ Node.js รันบน collection 1 000 document ของตัวเองและพิสูจน์รูปแบบเดียวกัน: joined_1 โดย key = document = ผลลัพธ์

joined_1 38 38 38

In Compass: เปิด collection แล้วเลือกแท็บ Indexes คุณจะเห็น index _id_ ที่มีมาให้เสมอ และเพิ่มอันใหม่ได้ด้วยปุ่ม Create Index โดยพิมพ์ชื่อ field และเลือกทิศทาง

index ช่วยเพราะทำให้ server ข้าม document ได้ เมื่อ filter เก็บเกือบทั้ง collection ก็ไม่มีอะไรให้ข้าม joined มีค่าต่างกันเพียง 26 ค่า joined: 2023 เก็บข้อมูล 3.8% แต่ช่วงที่เก็บทุก document เป็นอีกเรื่อง hint() บังคับ plan เพื่อเทียบ:

mongosh/idx-selectivity.js
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 35ms
joined = 2023, forced COLLSCAN : COLLSCAN returned=38461 keys=0 docs=1000000 291ms
all members, index scan forced : FETCH > IXSCAN(joined_1) returned=1000000 keys=1000000 docs=1000000 993ms
all members, forced COLLSCAN : COLLSCAN returned=1000000 keys=0 docs=1000000 293ms

สอง plan ที่บังคับสำหรับ “ทุกคน” อ่าน document จำนวนเท่ากัน แต่เส้นทางผ่าน index ต้องอ่าน key อีก 1 000 000 ตัวก่อน (ช้ากว่าราว 3 เท่าที่นี่) filter ที่คัดแทบไม่ได้ช่วยน้อย planner ชั่งน้ำหนักให้ ซึ่งเป็นเหตุผลที่ explain (ไม่ใช่สัญชาตญาณ) เป็นผู้ตัดสิน

ดิสก์และหน่วยความจำ index แต่ละอันเป็นโครงสร้างแยก ใน collection นี้ข้อมูลราว 100 MB และ index ใช้:

mongosh/idx-size.js
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 เพิ่มห้าอัน:

mongosh/idx-write-cost.js
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 ms
200 000 inserts, _id + 5 indexes : 2240 ms

MongoDB จำกัดหนึ่ง collection ไว้ที่ 64 index และ compound index ที่ 32 field (MongoDB limits, ตรวจ 2026-09-30) แต่เพดานจริงคืองบการเขียนของคุณซึ่งมาถึงก่อนนั้นมาก

$indexStats นับว่าแต่ละ index ถูกใช้ตอบ query กี่ครั้งตั้งแต่ server เริ่ม index ที่ ops: 0 เป็นตัวเลือกให้ลบ ก่อนลบ hideIndex() จะยังดูแล index ไว้แต่ซ่อนจาก planner คุณจึงเฝ้าดูอาการถดถอยได้และ unhideIndex() ทันทีถ้าช้าลง - ไม่ต้อง rebuild

mongosh/idx-unused.js
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 305ms
joined_1 visible again: FETCH > IXSCAN(joined_1) returned=38461 keys=38461 docs=38461 29ms

บทเรียนไล่จาก field เดียวไปจนครบชุดเครื่องมือ:

  1. ทำไม index จึงสำคัญ - scan เทียบ seek และต้นทุนของการไม่มีหรือมีมากเกินไป
  2. Single และ compound index - prefix rule ที่ตัดสินว่า compound index รับ query ไหนได้
  3. กฎ ESR - ลำดับ Equality, Sort, Range พร้อมตัวเลขวัดจริง
  4. Explain และ query plan - อ่าน explain, rejected plan, plan cache และ covered query
  5. ชนิดของ 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 ที่ถูกสั่งให้ “ทำให้การค้นหาสมาชิกเร็ว” มักเขียนแบบนี้พร้อมคอมเมนต์ที่ฟังดูสบายใจ:

🐛 หา 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 idempotent
res.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 ชัดเจน:

mongosh/idx-handler-bug.js
db.people.insertMany(Array.from({ length: 200 }, (_, i) => ({ email: "p" + i + "@x.test" })));
db.people.createIndex({ email: 1 }); // deploy 1: plain index
let t = Date.now();
for (let i = 0; i < 500; i++) db.people.createIndex({ email: 1 }); // "make sure the index exists" on every request
console.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 option
catch (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: 2
IndexKeySpecsConflict: 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 สำหรับบางคุณสมบัติ) ไม่ใช่สร้างซ้ำแบบเงียบ ๆ

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 นี้ไหม”ยังจ่ายต้นทุนเขียนขณะซ่อน
  • “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 แล้วอ่านชื่อกลับมา) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

เมื่อไม่มี index ที่ใช้ได้ MongoDB ตอบ query อย่างไร
โดยแก่นแล้ว index คืออะไร
collection ทุกอันมี index ใดโดยไม่ต้องสร้างเอง
index บน joined (26 ปีที่ต่างกัน) และ query ที่ match 3.8% ของ 1M document ผลการรันแสดงอะไร
ความเร็ว insert เปลี่ยนไปอย่างไรเมื่อเพิ่ม secondary index 5 อัน
hideIndex ทำอะไร