กฎ ESR
บทก่อนทิ้งคำถามไว้: เมื่อสร้าง compound index ให้ query ควรเรียง field อย่างไร คำตอบคือกฎสั้น ๆ จำง่ายชื่อ ESR - Equality, Sort, Range ใส่ field ที่ query จับคู่ตรง ๆ ก่อน แล้วตามด้วย field ที่ sort แล้วท้ายสุดคือ field ที่ query แบบช่วง ทำตามลำดับนี้ index เดียวตอบทั้ง filter, sort และ range ในรอบเดียวโดยไม่ต้อง sort ในหน่วยความจำ
ตัวเลขด้านล่างมาจาก mongo 8.3.11 และ mongosh 2.11.1 บน Mac เครื่องเดียว (Node driver 7.7.0 สำหรับแท็บ Node) ข้อมูล: member 200 000 คน 10 เมือง (Berlin 20 000) fines 0-6 joined 26 ปี score 0-999 บทนี้เตรียมข้อมูลเองและใช้ helper plan() บรรทัดเดียวเดิม (stage ผลลัพธ์ key ของ index document ที่อ่าน)
const cities = ["Berlin", "Paris", "Lima", "Oslo", "Cairo", "Tokyo", "Rome", "Lagos", "Dhaka", "Quito"];globalThis.plan = (cur) => { 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, score: Math.floor(i / 10) % 1000, city: cities[i % 10] }; }), { ordered: false });}const q = { city: "Berlin", fines: { $gt: 2 } };console.log("members:", db.members.countDocuments(), "| Berlin:", db.members.countDocuments({ city: "Berlin" }), "| Berlin with fines > 2:", db.members.countDocuments(q));members: 200000 | Berlin: 20000 | Berlin with fines > 2: 11428query ที่เรากำลังปรับให้เร็ว
หัวข้อที่มีชื่อว่า “query ที่เรากำลังปรับให้เร็ว”สมาชิก Berlin ทุกคนที่ fines เกินสอง เรียงตามปีที่เข้าร่วม สาม field สามบทบาท:
city- แบบ equality: ค่าเดียวเป๊ะjoined- field ที่ sortfines- แบบ range:$gt: 2ช่วงของค่า
db.members .find({ city: "Berlin", fines: { $gt: 2 } }) .sort({ joined: 1 })await db.collection("members") .find({ city: "Berlin", fines: { $gt: 2 } }) .sort({ joined: 1 }) .toArray();list(db.members.find({"city": "Berlin", "fines": {"$gt": 2}}).sort("joined", 1))opts := options.Find().SetSort(bson.D{{Key: "joined", Value: 1}})cur, err := coll.Find(ctx, bson.M{"city": "Berlin", "fines": bson.M{"$gt": 2}}, opts)if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "city": "Berlin", "fines": doc! { "$gt": 2 } }) .sort(doc! { "joined": 1 }) .await?;ใช้ ESR
หัวข้อที่มีชื่อว่า “ใช้ ESR”ESR ให้ { city: 1, joined: 1, fines: 1 } เพื่อเทียบ เรายังสร้างลำดับผิดที่ล่อใจ ซึ่งวาง range ก่อน sort คือ { city: 1, fines: 1, joined: 1 } (Equality, Range, Sort) และตั้งชื่อชัดเจนให้ทั้งคู่:
console.log(db.members.createIndex({ city: 1, joined: 1, fines: 1 }, { name: "ESR" })); // Equality, Sort, Rangeconsole.log(db.members.createIndex({ city: 1, fines: 1, joined: 1 }, { name: "ERS" })); // Equality, Range, SortESRERSแท็บ Node.js สร้าง index แบบ ESR บน collection 5 000 document ของตัวเองและตรวจว่า plan ไม่มี stage SORT:
const c = db.collection("members");await c.insertMany(Array.from({ length: 5000 }, (_, i) => ({ city: ["Berlin", "Oslo"][i % 2], joined: 2000 + (i % 26), fines: i % 7 })));await c.createIndex({ city: 1, joined: 1, fines: 1 }, { name: "ESR" });const e = await c.find({ city: "Berlin", fines: { $gt: 2 } }).sort({ joined: 1 }).explain("queryPlanner");const stages = []; for (let p = e.queryPlanner.winningPlan; p; p = p.inputStage) stages.push(p.stage);console.log(stages.join(" > "));if (stages.includes("SORT")) throw new Error("ESR order should not need a SORT");FETCH > IXSCANflowchart TB E["Equality: city equals Berlin"] --> EX["Index seeks to one narrow city block"] EX --> S["Sort: joined ascending"] S --> SX["Inside that block, entries are already ordered by joined - no extra sort"] SX --> R["Range: fines greater than 2"] R --> RX["Walk the joined order, keeping only fines above 2"] RX --> Out["Results: filtered, sorted, no in-memory sort step"]
วัดผลทั้งสามลำดับ
หัวข้อที่มีชื่อว่า “วัดผลทั้งสามลำดับ”const q = { city: "Berlin", fines: { $gt: 2 } };console.log("ESR {city, joined, fines}:", plan(db.members.find(q).sort({ joined: 1 }).hint("ESR")));console.log("ERS {city, fines, joined}:", plan(db.members.find(q).sort({ joined: 1 }).hint("ERS")));console.log("planner, no hint :", plan(db.members.find(q).sort({ joined: 1 })));const ex = db.members.find(q).sort({ joined: 1 }).explain();console.log("rejected plans :", ex.queryPlanner.rejectedPlans.map((p) => { let s = p; while (s.inputStage) s = s.inputStage; return s.indexName; }));const ids = (n) => db.members.find(q, { _id: 1, joined: 1 }).sort({ joined: 1 }).hint(n).toArray().map((d) => d.joined).join();assert.strictEqual(ids("ESR"), ids("ERS")); // same join-year order from both indexesassert.ok(!plan(db.members.find(q).sort({ joined: 1 }).hint("ESR")).includes("SORT"));assert.ok(plan(db.members.find(q).sort({ joined: 1 }).hint("ERS")).includes("SORT"));ESR {city, joined, fines}: FETCH > IXSCAN(ESR) returned=11428 keys=11455 docs=11428ERS {city, fines, joined}: FETCH > SORT > IXSCAN(ERS) returned=11428 keys=11428 docs=11428planner, no hint : FETCH > IXSCAN(ESR) returned=11428 keys=11455 docs=11428rejected plans : [ 'ERS' ]index แบบ ESR คืนผลลัพธ์ 11 428 รายการเรียงตามปีจาก index โดยตรง: ตรวจ 11 455 key (เกินผลลัพธ์ 27 - server กระโดดข้ามระหว่างค่า joined) และ ไม่มี stage SORT ส่วน index แบบ E-R-S เจอ 11 428 key เท่ากัน แต่ออกมาจัดกลุ่มตาม fines ไม่ใช่ joined plan จึงแทรก FETCH > SORT > IXSCAN: ทั้ง 11 428 entry ถูก sort ในหน่วยความจำก่อน fetch document ปล่อยให้ planner เลือกเอง มันเลือก ESR และแสดง ERS ใน rejectedPlans คำตอบเดียวกัน งานต่างกัน - และ SORT คือส่วนที่โตตามจำนวนผลลัพธ์และชนเพดานหน่วยความจำ
hint() (ที่ใช้ข้างบน) บังคับ index ตามชื่อเพื่อเทียบลำดับแบบเคียงกัน อย่าทิ้งไว้ในโค้ด production เพราะมันทำให้ planner ปรับตัวตามข้อมูลไม่ได้
เมื่อมี limit ช่องว่างกว้างขึ้น
หัวข้อที่มีชื่อว่า “เมื่อมี limit ช่องว่างกว้างขึ้น”const q = { city: "Berlin", fines: { $gt: 2 } };console.log("ESR first 20:", plan(db.members.find(q).sort({ joined: 1 }).limit(20).hint("ESR")));console.log("ERS first 20:", plan(db.members.find(q).sort({ joined: 1 }).limit(20).hint("ERS")));ESR first 20: LIMIT > FETCH > IXSCAN(ESR) returned=20 keys=21 docs=20ERS first 20: FETCH > SORT > IXSCAN(ERS) returned=20 keys=11428 docs=20index ที่เรียงแล้วให้ server หยุดหลัง 20 entry แรก (21 key, 20 document) ส่วนลำดับผิดยังต้องอ่านและ sort ทั้ง 11 428 key เพื่อรู้ว่า 20 รายการแรกคืออะไร
In Compass: สร้าง index ผู้สมัครในแท็บ Indexes แล้วรัน query ในแท็บ Explain Plan เมื่อ ESR ถูกต้องการ sort ทำโดย index เมื่อไม่ถูก plan จะแสดง stage sort ชัดเจน
เมื่อกฎนี้แพ้
หัวข้อที่มีชื่อว่า “เมื่อกฎนี้แพ้”ESR ลดการ sort ไม่ได้ลดจำนวน key ที่อ่าน ถ้า range คัดได้ มาก และ field ที่ sort มีค่าต่างกันมาก การเดินทุก key ตามลำดับ sort เพื่อทดสอบ range อาจแพงกว่าการ sort ผู้รอดไม่กี่รายการ สมาชิก Berlin ที่ score >= 995 มี 100 จาก 20 000 เรียงตาม name (ไม่ซ้ำ):
db.members.createIndex({ city: 1, name: 1, score: 1 }, { name: "ESR_name" }); // Equality, Sort, Rangedb.members.createIndex({ city: 1, score: 1, name: 1 }, { name: "ERS_name" }); // Equality, Range, Sortconst q = { city: "Berlin", score: { $gte: 995 } }; // a very selective range: 100 of 20 000console.log("ESR all 100 :", plan(db.members.find(q).sort({ name: 1 }).hint("ESR_name")));console.log("ERS all 100 :", plan(db.members.find(q).sort({ name: 1 }).hint("ERS_name")));console.log("ESR first 20:", plan(db.members.find(q).sort({ name: 1 }).limit(20).hint("ESR_name")));console.log("ERS first 20:", plan(db.members.find(q).sort({ name: 1 }).limit(20).hint("ERS_name")));console.log("Same query, sort on joined (26 distinct values):");db.members.createIndex({ city: 1, joined: 1, score: 1 }, { name: "ESR_joined" });console.log("ESR joined :", plan(db.members.find(q).sort({ joined: 1 }).hint("ESR_joined")));ESR all 100 : FETCH > IXSCAN(ESR_name) returned=100 keys=20001 docs=100ERS all 100 : FETCH > SORT > IXSCAN(ERS_name) returned=100 keys=100 docs=100ESR first 20: LIMIT > FETCH > IXSCAN(ESR_name) returned=20 keys=4446 docs=20ERS first 20: FETCH > SORT > IXSCAN(ERS_name) returned=20 keys=100 docs=20Same query, sort on joined (26 distinct values):ESR joined : FETCH > IXSCAN(ESR_joined) returned=100 keys=127 docs=100ที่นี่ ESR อ่านทุก key ของ Berlin (20 001) เพื่อหา 100 รายการ ส่วน E-R-S seek ตรงไป 100 รายการแล้ว sort เมื่อมี limit(20) ESR หยุดที่ 4 446 key ยังมากกว่า E-R-S ราว 44 เท่า บรรทัดสุดท้ายแสดงอีกด้าน: เมื่อ sort ด้วย joined ที่มีเพียง 26 ค่า ESR อ่าน 127 key - server กระโดดข้ามระหว่าง 26 ค่า - กรณียกเว้นจึงต้องการ field ที่ sort ซึ่งมี cardinality สูง นี่คือเหตุผลที่กฎเป็นค่าตั้งต้นที่ต้องยืนยันด้วย explain ไม่ใช่กฎตายตัว
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- ESR เป็นค่าตั้งต้น ไม่ใช่กฎตายตัว: ยืนยันด้วย
explainบนข้อมูลของคุณ และเทียบลำดับด้วยhint() - field แบบ equality (หลายตัวก็ได้) ไปก่อนทั้งหมด field sort ตัวเดียวต่อมา และ range ท้ายสุด
- ผลตอบแทนคือกำจัด SORT แบบ blocking (และเพดานหน่วยความจำ) และหยุดเร็วด้วย
limit
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอ index สำหรับ “สมาชิก Berlin ที่ fines เกิน 2 เรียงตามปีที่เข้าร่วม” AI เรียง field ตามลำดับที่ปรากฏใน query:
🐛 หา bug ในโค้ดนี้
// AI comment: "the index contains city, fines and joined,// so the filter AND the sort are both served by the index"await members.createIndex({ city: 1, fines: 1, joined: 1 });const rows = await members.find({ city: "Berlin", fines: { $gt: 2 } }).sort({ joined: 1 }).toArray();เฉลย
การมี field ที่ sort ไม่เท่ากับการเรียงเพื่อมัน เพราะ fines เป็น range key จึงออกมาจัดกลุ่มตาม fines และ plan ยังมี SORT แบบ blocking เทียบ index ของ AI กับแบบ ESR:
// AI: "the index contains city, fines and joined, so filter AND sort are both served by it"const q = { city: "Berlin", fines: { $gt: 2 } };const stages = (name) => { const s = []; for (let p = db.members.find(q).sort({ joined: 1 }).hint(name).explain().queryPlanner.winningPlan; p; p = p.inputStage) s.push(p.stage); return s.join(" > "); };console.log("AI index { city, fines, joined }:", stages("ERS"));console.log("ESR index { city, joined, fines }:", stages("ESR"));AI index { city, fines, joined }: FETCH > SORT > IXSCANESR index { city, joined, fines }: FETCH > IXSCANMental Model
หัวข้อที่มีชื่อว่า “Mental Model”ลองนึกภาพ index เป็นรายการ tuple ที่เรียงแล้ว equality ตรึงพิกัดแรก คุณจึงอยู่ในรายการย่อยที่เรียงหนึ่งอัน ถ้าพิกัดถัดไปคือ field sort รายการย่อยนั้นก็อยู่ในลำดับที่ต้องการแล้ว ถ้าพิกัดถัดไปเป็น range คุณกำลังอ่านหลายช่วงที่เรียงแล้ว (หนึ่งช่วงต่อหนึ่งค่าใน range) ที่ต้องรวมกัน - ซึ่งก็คืองานของ stage SORT
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
| ลำดับแบบ ESR | ไม่มี SORT; หยุดเร็วด้วย limit (21 key สำหรับ 20 แถว) | อาจอ่าน key มากกว่าเมื่อ range คัดมาก (20 001 เทียบ 100) |
| ลำดับ E-R-S (range ก่อน sort) | key น้อยที่สุดเมื่อ range คัดมาก | SORT แบบ blocking ในหน่วยความจำ; หยุดเร็วไม่ได้ (11 428 key สำหรับ 20 แถว) |
| เรียง field ตามใจ | เขียนเร็ว | ทุกรูปแบบ query ต้องมี index เพิ่ม |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ถ้า field sort อยู่ใน index การ sort ก็ฟรี” - ฟรีก็ต่อเมื่อ field ก่อนหน้าเป็น equality; หลัง range stage SORT กลับมา
- “ESR อ่าน key น้อยที่สุดเสมอ” - มันลดการ sort; กรณี range คัดมากอ่าน 20 001 key เทียบกับ 100
- “ลำดับผิดให้ผลลัพธ์ผิด” - ผลลัพธ์เหมือนกัน มีแค่ปริมาณงานที่ต่าง
- “ทิ้ง
hint()ไว้เพื่อความปลอดภัย” - มันตรึง plan และกันไม่ให้ planner ปรับตัว
ตัวอย่างจากของจริง
MongoDB บันทึกลำดับนี้ไว้เป็น “The ESR (Equality, Sort, Range) Guideline” และระบุข้อยกเว้นเดียวกับที่วัดข้างบน: “If the range predicate in your query is very selective, then put it before sort fields” (ESR Guideline, ตรวจ 2026-09-30)
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (query Berlin/fines คืนสมาชิกชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส