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

Single และ compound index

index ที่ง่ายที่สุดครอบคลุมหนึ่ง field เมื่อ query ของคุณ filter หรือ sort สอง field พร้อมกัน คุณจะใช้ compound index - index เดียวที่เรียง document ตามหลาย field พร้อมกัน compound index คือแหล่งพลังและแหล่งความสับสนหลัก เพราะมันไม่ได้ช่วยทุก query ที่พูดถึง field ของมัน กฎที่ตัดสินว่ามันรับ query ไหนคือ prefix rule

ทุก output จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว collection มี member 200 000 คน 10 เมือง 26 ปีที่เข้าร่วม city: "Berlin" จึง match 20 000 document และ city + joined match 769 บทนี้เตรียมข้อมูลเอง และนิยาม helper plan() บรรทัดเดียวเหมือนบทก่อน (ลำดับ stage แล้วผลลัพธ์ key ของ index และ document ที่อ่าน) แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch

mongosh/idx2-seed.js
const cities = ["Berlin", "Paris", "Lima", "Oslo", "Cairo", "Tokyo", "Rome", "Lagos", "Dhaka", "Quito"];
globalThis.plan = (cur) => { // stage chain, then keys/docs actually read
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;
};
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

ทิศทาง - 1 ascending, -1 descending - กำหนดลำดับที่เก็บ สำหรับ field เดียวแทบไม่สำคัญเพราะเดิน index ได้ทั้งสองทาง ที่นี่ fines แบบ ascending ทั้ง filter แบบ equality และ sort top-N อ่านแทบไม่มีอะไร:

mongosh/idx2-single.js
console.log("createIndex ->", db.members.createIndex({ fines: 1 }));
console.log("fines = 3 :", plan(db.members.find({ fines: 3 })));
console.log("sort fines:", plan(db.members.find().sort({ fines: -1 }).limit(5)));
createIndex -> fines_1
fines = 3 : FETCH > IXSCAN(fines_1) returned=28571 keys=28571 docs=28571
sort fines: LIMIT > FETCH > IXSCAN(fines_1) returned=5 keys=5 docs=5

createIndex คืน string ชื่อ index fines: 3 อ่าน key และ document ที่ตรง 28 571 รายการ; sort แบบ limit(5) อ่านแค่ 5

compound index ระบุมากกว่าหนึ่ง field แต่ละอันมีทิศทางของตัวเอง ลำดับ field กำหนดลำดับการเรียง: ตาม field แรกก่อน แล้วตาม field ที่สองภายในกลุ่มที่ค่าแรกเท่ากัน ที่นี่ city ascending แล้ว joined descending สมาชิกจึงรวมกลุ่มตามเมืองและในแต่ละเมืองใหม่สุดก่อน:

db.members.createIndex({ city: 1, joined: -1 })

ชื่อ index ต่อ field และทิศทางเข้าด้วยกัน: city_1_joined_-1

compound index รับ query ได้เฉพาะผ่าน left-prefix ของ field - field แรก หรือสองตัวแรก เริ่มจากซ้ายเสมอและห้ามข้าม city_1_joined_-1 มี prefix ที่ใช้ได้สองแบบ: city และ city + joined ห้า query กับมัน:

mongosh/idx2-compound.js
console.log("createIndex ->", db.members.createIndex({ city: 1, joined: -1 }));
const rows = [
["{ city }", db.members.find({ city: "Berlin" })],
["{ city, joined }", db.members.find({ city: "Berlin", joined: 2023 })],
["{ city, joined >= 2020 }", db.members.find({ city: "Berlin", joined: { $gte: 2020 } })],
["{ joined } (skips city)", db.members.find({ joined: 2023 })],
["{ joined }, hint the index", db.members.find({ joined: 2023 }).hint({ city: 1, joined: -1 })],
];
rows.forEach(([label, cur]) => console.log(label.padEnd(28), plan(cur)));
assert.match(plan(db.members.find({ joined: 2023 })), /^COLLSCAN/);
createIndex -> city_1_joined_-1
{ city } FETCH > IXSCAN(city_1_joined_-1) returned=20000 keys=20000 docs=20000
{ city, joined } FETCH > IXSCAN(city_1_joined_-1) returned=769 keys=769 docs=769
{ city, joined >= 2020 } FETCH > IXSCAN(city_1_joined_-1) returned=4614 keys=4614 docs=4614
{ joined } (skips city) COLLSCAN returned=7690 keys=0 docs=200000
{ joined }, hint the index FETCH > IXSCAN(city_1_joined_-1) returned=7690 keys=200000 docs=200000

อ่านทีละแถว { city } อ่าน 20 000 key สำหรับ 20 000 ผลลัพธ์ และ { city, joined } อ่าน 769 พอดี ช่วงบน field ที่สองยังขี่ index ได้ (joined >= 2020 อ่านเฉพาะ key ที่ตรงภายในบล็อกของเมือง) แต่ { joined } อย่างเดียวเป็น COLLSCAN ทั้ง 200 000 document: index เรียงตาม city ก่อน ปีที่เข้าร่วมจึงกระจายทั่ว index การบังคับ index ด้วย hint() พิสูจน์ว่าไม่ใช่แค่ความชอบของ planner - server ต้องเดินทั้ง 200 000 key เพราะ MongoDB ไม่มี skip scan

แท็บ Node.js พิสูจน์กฎเดียวกันบน collection 2 000 document ของตัวเอง: query city อ่าน key เท่าผลลัพธ์ และ query เฉพาะ joined สแกนทั้ง 2 000 document

app/idx2-compound.mjs
const c = db.collection("members");
await c.insertMany(Array.from({ length: 2000 }, (_, i) => ({ city: ["Berlin", "Oslo"][i % 2], joined: 2000 + (i % 26) })));
const name = await c.createIndex({ city: 1, joined: -1 });
const ex = async (f) => (await c.find(f).explain("executionStats")).executionStats;
const a = await ex({ city: "Berlin" }), b = await ex({ joined: 2023 });
console.log(name, "| city:", a.totalKeysExamined, "keys | joined only:", b.totalDocsExamined, "docs (COLLSCAN)");
if (a.totalKeysExamined !== a.nReturned || b.totalDocsExamined !== 2000) throw new Error("prefix rule not observed");
city_1_joined_-1 | city: 1000 keys | joined only: 2000 docs (COLLSCAN)

In Compass: แท็บ Indexes แสดงแต่ละ index พร้อม field และทิศทางตามลำดับ คุณจึงอ่าน prefix ที่ใช้ได้ทันที และบอกขนาดบนดิสก์ของแต่ละ index

index เดินได้ทั้งไปหน้าและถอยหลัง ลำดับที่ตรงข้ามกับที่ประกาศจึงฟรี สิ่งที่ไม่ฟรีคือทิศทาง ผสม ที่ลำดับที่เก็บสร้างไม่ได้:

mongosh/idx2-sort.js
const q = { city: "Berlin" };
console.log("sort { joined: -1 } :", plan(db.members.find(q).sort({ joined: -1 })));
console.log("sort { joined: 1 } (reverse walk):", plan(db.members.find(q).sort({ joined: 1 })));
console.log("no filter, sort { city: -1, joined: 1 }:", plan(db.members.find().sort({ city: -1, joined: 1 }).limit(5)));
console.log("no filter, sort { city: 1, joined: 1 }:", plan(db.members.find().sort({ city: 1, joined: 1 }).limit(5)));
assert.ok(!plan(db.members.find(q).sort({ joined: 1 })).includes("SORT"));
assert.ok(plan(db.members.find().sort({ city: 1, joined: 1 }).limit(5)).includes("SORT"));
sort { joined: -1 } : FETCH > IXSCAN(city_1_joined_-1) returned=20000 keys=20000 docs=20000
sort { joined: 1 } (reverse walk): FETCH > IXSCAN(city_1_joined_-1) returned=20000 keys=20000 docs=20000
no filter, sort { city: -1, joined: 1 }: LIMIT > FETCH > IXSCAN(city_1_joined_-1) returned=5 keys=5 docs=5
no filter, sort { city: 1, joined: 1 }: SORT > COLLSCAN returned=5 keys=0 docs=200000

{ joined: -1 } และ { joined: 1 } (เดินถอยหลัง) ออกจาก index ตรง ๆ ทั้งคู่ เช่นเดียวกับการย้อนกลับแบบเต็ม { city: -1, joined: 1 } ส่วน { city: 1, joined: 1 } ขัดกับ joined: -1 ที่เก็บไว้ index จึงส่งลำดับให้ไม่ได้: plan เป็น SORT > COLLSCAN และอ่านทั้ง 200 000 document

{ city: 1 } แยกข้าง { city: 1, joined: -1 } ซ้ำ prefix ลองสร้างแล้วดูผล:

mongosh/idx2-redundant.js
db.members.createIndex({ city: 1 }); // a "separate" city index
console.log("city query now :", plan(db.members.find({ city: "Berlin" })));
for (let i = 0; i < 3; i++) db.members.find({ city: "Berlin" }).toArray(); // real executions, so $indexStats has something to count
db.members.find({ city: "Berlin", joined: 2023 }).toArray();
const s = db.members.stats().indexSizes, kb = (n) => Math.round(n / 1024);
console.log("index sizes KB :", { city_1: kb(s.city_1), city_1_joined_minus1: kb(s["city_1_joined_-1"]) });
console.log("$indexStats ops :", db.members.aggregate([{ $indexStats: {} }, { $project: { _id: 0, name: 1, ops: "$accesses.ops" } }, { $sort: { name: 1 } }]).toArray());
assert.ok(s.city_1 > 0);
city query now : FETCH > IXSCAN(city_1) returned=20000 keys=20000 docs=20000
index sizes KB : { city_1: 1080, city_1_joined_minus1: 1404 }
$indexStats ops : [
{ name: '_id_', ops: Long('0') },
{ name: 'city_1', ops: Long('3') },
{ name: 'city_1_joined_-1', ops: Long('1') },
{ name: 'fines_1', ops: Long('0') }
]

planner อาจเลือก index เดี่ยวที่เล็กกว่าสำหรับ query ที่ใช้แค่ city มันจึงไม่ไร้ประโยชน์กับ query นั้น - แต่เป็นงานเดียวกับที่ compound index ทำอยู่แล้ว และเสียดิสก์ หน่วยความจำ และการเขียนทุกครั้งที่ insert ใช้ $indexStats ดูว่า index ไหนคุ้มจริง แล้วลบตัวซ้ำถ้า compound เร็วพอ

  • compound index หนึ่งอันแทน index เดี่ยวได้หลายอัน: { city: 1, joined: -1 } รับ query เฉพาะ city อยู่แล้ว
  • ลำดับ field เป็นการตัดสินใจเชิงออกแบบ การสลับเปลี่ยน query ที่ index รับได้: { joined: 2023 } เป็น COLLSCAN บน (city, joined)
  • ทิศทางสำคัญกับการ sort ไม่ใช่ equality ทิศตรงข้ามทั้งชุดฟรี ส่วนทิศทางผสมใช้ index ไม่ได้ (SORT > COLLSCAN)
  • การข้าม field นำหน้าทำให้ prefix ขาด ไม่ว่า field ถัดไปจะคัดได้ดีเพียงไร

เมื่อถูกสั่งให้ “ทำ index ให้ query สมาชิกตามเมืองและปีที่เข้าร่วม” AI มักเขียนหนึ่ง index ต่อหนึ่ง field:

🐛 หา bug ในโค้ดนี้

// AI comment: "one index per field - MongoDB combines them for multi-field queries"
await members.createIndex({ city: 1 });
await members.createIndex({ joined: 1 });
const rows = await members.find({ city: "Berlin", joined: 2023 }).toArray();
เฉลย

คอมเมนต์อธิบาย index intersection ราวกับรับประกัน ในการรันด้านล่าง planner เลือก index เดียวแล้วกรองที่เหลือจาก document ที่ fetch จึงอ่าน key และ document มากกว่า 769 ผลลัพธ์มาก compound index เดียวเปลี่ยนเป็น key = document = ผลลัพธ์:

mongosh/idx2-bug.js
db.members.dropIndexes(); // start from _id only
db.members.createIndex({ city: 1 });
db.members.createIndex({ joined: 1 }); // AI: "one index per field, MongoDB combines them"
console.log("two single indexes:", plan(db.members.find({ city: "Berlin", joined: 2023 })));
db.members.createIndex({ city: 1, joined: 1 }); // the fix: one compound index
console.log("one compound index:", plan(db.members.find({ city: "Berlin", joined: 2023 })));
two single indexes: FETCH > IXSCAN(joined_1) returned=769 keys=7690 docs=7690
one compound index: FETCH > IXSCAN(city_1_joined_1) returned=769 keys=769 docs=769

compound index คือสมุดโทรศัพท์ที่เรียงตามนามสกุลแล้วตามชื่อ คุณหา Smith ทุกคนและ John Smith ทุกคนได้เร็ว แต่หา John ทุกคนโดยไม่อ่านทั้งเล่มไม่ได้ ลำดับที่เก็บคือสัญญา - prefix rule และกฎการ sort เป็นผลของมันทั้งคู่

ทางเลือกข้อดีต้นทุน
index เดี่ยวง่าย; รับ query รูปแบบเดียวindex เดี่ยวสองอันไม่รวมเป็น seek สำหรับ query สอง field
compound indexindex เดียวรับหลายรูปแบบผ่าน prefix และลำดับการ sortใหญ่กว่า เขียนช้ากว่า และไร้ประโยชน์กับ query ที่ข้าม field แรก
index prefix เพิ่ม ({ city: 1 })เล็กกว่า อาจถูกเลือกกับ query ที่ใช้แค่ cityซ้ำงานที่ compound ทำอยู่ และจ่ายต้นทุนเขียน
  • “compound index ช่วย query บน field ใดก็ได้ของมัน” - { joined: 2023 } สแกนทั้ง 200 000 document; แม้ hint ก็อ่านทั้ง 200 000 key
  • “index แยกแต่ละ field รวมกันอัตโนมัติ” - planner ใช้อันเดียวแล้วกรองที่เหลือ
  • “ทิศทางของทุก field ต้องตรงกับ sort” - ตรงข้ามทั้งชุดฟรี มีแค่ทิศทางผสมที่ทำให้ใช้ index ไม่ได้
  • “ต้องมี index เดี่ยวแยกสำหรับ field แรก” - compound index มี prefix นั้นอยู่แล้ว

ตัวอย่างจากของจริง

หน้า compound index ในคู่มือ MongoDB ระบุกฎที่บทนี้วัดไว้: “Compound indexes support queries on all fields included in the index prefix” และแนะนำให้สร้างเมื่อ “your application repeatedly runs a query that contains multiple fields” (Compound Indexes, ตรวจ 2026-09-30)

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

คุณมี compound index บน { city: 1, joined: -1 } query ไหนใช้ seek ได้
prefix rule ของ compound index ว่าอย่างไร
index คือ { city: 1, joined: -1 } sort แบบไหนบน query เฉพาะ Berlin ไม่ต้องมี stage SORT
คุณสร้าง index เดี่ยวบน city และ joined แยกกันสำหรับ query ที่ใช้ทั้งสอง ผลรันแสดงอะไร
ทำไม index { city: 1 } แยกจึงซ้ำซ้อนเมื่อมี { city: 1, joined: -1 }