กฎ ESR
บทเรียนก่อนทิ้งคำถามค้างไว้: เมื่อคุณสร้าง compound index สำหรับ query หนึ่ง ฟิลด์ควรเรียงลำดับอย่างไร คำตอบคือกฎสั้นๆ ที่จำง่ายชื่อ ESR — Equality, Sort, Range ระบุฟิลด์ที่ query ใช้สำหรับการจับคู่แบบเป๊ะก่อน แล้วตามด้วยฟิลด์ที่คุณเรียง แล้วตามด้วยฟิลด์ใดที่คุณ query เป็นช่วง ทำตามลำดับนั้นแล้ว index อันเดียวสามารถตอบสนองทั้งตัวกรอง การเรียง และการสแกนช่วงในรอบเดียว โดยไม่ต้องเรียงในหน่วยความจำเพิ่ม และไม่มีการอ่านที่สูญเปล่า
มาลองทำกันให้ตลอดด้วย query รูปธรรมกับ members ในห้องสมุดของเรา
query ที่เรากำลังเพิ่มประสิทธิภาพ
หัวข้อที่มีชื่อว่า “query ที่เรากำลังเพิ่มประสิทธิภาพ”สมมติว่าเราต้องการสมาชิกทุกคนใน city ที่กำหนด ที่มี fines มากกว่าสอง เรียงตามปีที่เข้าร่วม query นี้แตะสามฟิลด์ในสามรูปแบบที่ต่างกัน:
city— การจับคู่แบบ equality:cityเท่ากับค่าเป๊ะค่าหนึ่งjoined— ฟิลด์ sort: ผลลัพธ์กลับมาเรียงตามปีที่เข้าร่วมfines— เป็น range:finesมากกว่าสอง เป็นช่วงค่ามากกว่าค่าเดียว
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}let cur = coll .find(doc! { "city": "Berlin", "fines": { "$gt": 2 } }) .sort(doc! { "joined": 1 }) .await?;การนำ ESR ไปใช้
หัวข้อที่มีชื่อว่า “การนำ ESR ไปใช้”ESR บอกว่า: ฟิลด์ equality ก่อน, ฟิลด์ sort ที่สอง, ฟิลด์ range ท้ายสุด นั่นให้ index { city: 1, joined: 1, fines: 1 }:
db.members.createIndex({ city: 1, joined: 1, fines: 1 })await db.collection("members").createIndex({ city: 1, joined: 1, fines: 1 });db.members.create_index([("city", 1), ("joined", 1), ("fines", 1)])_, err := coll.Indexes().CreateOne( ctx, mongo.IndexModel{ Keys: bson.D{ {Key: "city", Value: 1}, {Key: "joined", Value: 1}, {Key: "fines", Value: 1}, }, },)if err != nil { return err}coll.create_index( IndexModel::builder() .keys(doc! { "city": 1, "joined": 1, "fines": 1 }) .build(),).await?;นี่คือเหตุผลที่ลำดับนี้ใช้ได้:
flowchart TB E["Equality: city equals Berlin"] --> EX["Index seeks to one narrow city block"] EX --> S["Sort: joined ascending"] S --> SX["Inside that block, docs are already ordered by joined — no extra sort"] SX --> R["Range: fines greater than 2"] R --> RX["Walk the matching join order, keeping only fines above 2"] RX --> Out["Results: filtered, sorted, no in-memory sort step"]
equality บน city ตรึงการค้นหาไว้ที่ชิ้นต่อเนื่องชิ้นเดียวของ index ภายในชิ้นนั้น document ถูกวางเรียงตามลำดับ joined อยู่แล้ว ดังนั้น MongoDB จึงคืน document แบบเรียงโดยไม่ต้องมีขั้นตอนเรียงแยกต่างหาก range บน fines มาท้ายสุดเพราะ range ทำให้ฟิลด์ของ index ที่ตามมาไม่เรียงลำดับ — ถ้า fines มาก่อน joined ปีที่เข้าร่วมภายในช่วงนั้นจะกระจัดกระจาย และ MongoDB จะต้องเรียง document ในหน่วยความจำหลังการอ่าน
ทำไมลำดับอื่นจึงแพ้
หัวข้อที่มีชื่อว่า “ทำไมลำดับอื่นจึงแพ้”เปรียบเทียบกับลำดับที่ดูน่าเลือกแต่ผิด { city: 1, fines: 1, joined: 1 } ซึ่งวาง range ไว้ก่อน sort equality บน city ยังคงใช้ได้ แต่ fines: { $gt: 2 } เป็น range และเมื่อ index เดินผ่าน range แล้ว ทุกฟิลด์ที่ตามมาจะไม่อยู่ในช่วงที่เรียงต่อเนื่องเดียวอีกต่อไป ดังนั้น joined จึงใช้สำหรับการเรียงไม่ได้ และ MongoDB ก็ถอยกลับไปเรียงในหน่วยความจำแบบ blocking ของชุดผลลัพธ์ทั้งหมด query ยังคงคืนคำตอบที่ถูกต้อง — เพียงแต่ช้ากว่าและใช้หน่วยความจำมากกว่า การเรียงที่ซ่อนอยู่นั้นแหละคือสิ่งที่ ESR มีไว้เพื่อหลีกเลี่ยง
วิธีจำที่มีประโยชน์: range “ผลาญ” การเรียงทั้งหมดของฟิลด์ที่ตามมา จึงต้องวางไว้ท้ายสุด หลังจากที่คุณได้การบีบด้วย equality และการเรียงมาฟรีแล้ว
ใน Compass: สร้าง index ผู้ท้าชิงในแท็บ Indexes แล้วรัน query ในแท็บ Explain Plan เมื่อ ESR ถูกตอบสนองคุณจะเห็นการเรียงถูกจัดการโดย index เมื่อไม่ใช่ plan จะแสดงขั้นตอน sort ที่ชัดเจนซึ่งกินผลลัพธ์เข้าไป
เคล็ดลับและจุดพลาด
หัวข้อที่มีชื่อว่า “เคล็ดลับและจุดพลาด”- ESR เป็นค่าตั้งต้น ไม่ใช่กฎตายตัว เป็นจุดตั้งต้นที่ถูกต้องสำหรับ query รูปแบบ “filter, sort, range” ที่พบบ่อย แต่ยืนยันด้วย
explainบนข้อมูลจริงของคุณเสมอ - ฟิลด์ equality มีได้มากกว่าหนึ่ง และทั้งหมดอยู่ด้านหน้า เช่นเดียวกับฟิลด์ range หลายฟิลด์ที่ท้ายสุด ส่วนฟิลด์ sort ฟิลด์เดียวอยู่ตรงกลาง
- ผลตอบแทนของการทำ ESR ให้ถูกคือการกำจัดการเรียงในหน่วยความจำ plan ที่มีขั้นตอน sort ชัดเจนบนชุดผลลัพธ์ขนาดใหญ่คือสัญญาณว่าลำดับฟิลด์ของคุณกำลังต่อสู้กับ query
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
| เรียงฟิลด์ตาม ESR อย่างเคร่งครัด | index อันเดียวรองรับได้หลาย query shape ที่มี pattern filter/sort/range คล้ายกัน ลดจำนวน index ที่ต้องดูแล | ต้องมีวินัยตรวจสอบทุกครั้งที่ query pattern เปลี่ยน ไม่ใช่แค่สร้างแล้วจบ |
| เรียงฟิลด์แบบเฉพาะกิจตาม query ที่เขียนอยู่ตรงหน้า | เขียนเร็ว ไม่ต้องคิดเยอะตอนเริ่มต้น | index ผูกติดกับ query shape เดียว เมื่อ query เปลี่ยนต้องสร้าง index ใหม่เพิ่มเรื่อยๆ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- วางฟิลด์ range ไว้ก่อนฟิลด์ sort — range ทำให้ลำดับของฟิลด์ index ที่ตามมาไม่ต่อเนื่องอีกต่อไป ผลคือ MongoDB ต้องเรียงผลลัพธ์ในหน่วยความจำแม้จะมี index ก็ตาม
- คิดว่า ESR เป็นกฎตายตัวที่ใช้ได้กับทุก query — ESR เป็นจุดเริ่มต้นที่ดีสำหรับ pattern filter-sort-range ทั่วไปเท่านั้น ต้องยืนยันด้วย
explain()เสมอ โดยเฉพาะ query ที่ซับซ้อนกว่านั้น - ใส่ฟิลด์ equality หลายตัวปนกับฟิลด์ sort โดยไม่จัดกลุ่ม — ฟิลด์ equality ทั้งหมดต้องอยู่ด้านหน้าสุดของ index ด้วยกัน ถ้าแทรกฟิลด์ sort หรือ range ไว้ระหว่างฟิลด์ equality จะทำลาย prefix ที่ query equality หลายฟิลด์ต้องการ
💡 ตัวอย่างจากของจริง
ระบบ order management ที่ query ตาม
{ status: equality, createdAt: sort, totalAmount: range }— ทีมออกแบบ index ตาม ESR เป็น{ status: 1, createdAt: 1, totalAmount: 1 }ทำให้ dashboard ที่กรองสถานะออเดอร์ เรียงตามวันที่ และกรองช่วงยอดเงิน ทำงานโดยไม่มีขั้นตอน sort ในหน่วยความจำเลยทีมที่ debug query ช้าลงหลัง requirement เปลี่ยน — เดิม query กรองเฉพาะ equality กับ sort ทำงานเร็ว แต่หลังเพิ่มเงื่อนไข range ใหม่เข้าไปโดยไม่ทบทวนลำดับ index เดิม ทำให้ query กลับไปมีขั้นตอน sort ที่ชัดเจนใน
explain()ต้องสร้าง index ใหม่ตาม ESR ให้ตรงกับ query pattern ที่เปลี่ยนไป