Projection, sort และการแบ่งหน้า
filter ตัดสินว่า document ไหน จะส่งกลับมา ตัวควบคุมอีกสามตัวตัดสินว่า document เหล่านั้นจะหน้าตาเป็นอย่างไรระหว่างทางมาถึงแอปของคุณ: projection ตัด field, sort กำหนดลำดับ และ limit กับ skip แบ่งผลลัพธ์ออกเป็นหน้า ใช้ร่วมกันแล้วคุณจะไม่ต้องลาก document ทั้งก้อนหรือทั้ง collection ข้ามเครือข่ายในเวลาที่คุณต้องการแค่ชื่อกับหนึ่งหน้าสิบรายการ บทเรียนนี้ครอบคลุมทั้งสี่ และจบด้วยลูกเล่นการแบ่งหน้าหนึ่งตัวที่ค่อย ๆ ช้าลงอย่างเงียบ ๆ
เรากลับมาที่สมาชิกห้องสมุด members แบบง่าย แต่ละคนมี name, ปี joined และ fines
Projection: การเลือก field
หัวข้อที่มีชื่อว่า “Projection: การเลือก field”projection คือ document ตัวที่สองที่ส่งให้ find ซึ่งระบุว่าจะเก็บ field ไหน ตั้ง field เป็น 1 เพื่อเอาติดมา ส่วนอะไรที่ไม่ได้ระบุจะถูกตัดทิ้งหมด — ยกเว้น _id ซึ่งจะส่งกลับมาเว้นแต่คุณจะกำหนดให้แยกออกอย่างชัดเจนด้วย 0 ตรงนี้เราต้องการเฉพาะชื่อกับปีที่เข้าร่วม โดยไม่เอา _id:
db.members.find( { fines: 0 }, { name: 1, joined: 1, _id: 0 })const docs = await db.collection("members") .find({ fines: 0 }) .project({ name: 1, joined: 1, _id: 0 }) .toArray();docs = list(db.members.find( {"fines": 0}, {"name": 1, "joined": 1, "_id": 0},))opts := options.Find().SetProjection(bson.M{"name": 1, "joined": 1, "_id": 0})cur, err := coll.Find(ctx, bson.M{"fines": 0}, 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! { "fines": 0 }) .projection(doc! { "name": 1, "joined": 1, "_id": 0 }) .await?;เฉพาะ field ที่ขอเท่านั้นที่มาถึง และ _id หายไป:
[ { "name": "Margaret", "joined": 2023 }, { "name": "Ada", "joined": 2024 }]คุณอาจรวม field หรือแยก field ออกได้ แต่ผสมทั้งสองใน projection เดียวไม่ได้ — ข้อยกเว้นเดียวคือการแยก _id ออกควบคู่ไปกับ list ที่รวม field ดังตัวอย่างข้างต้น
การ sort ผลลัพธ์
หัวข้อที่มีชื่อว่า “การ sort ผลลัพธ์”sort จัดลำดับผลลัพธ์ตาม field หนึ่งหรือมากกว่า: 1 คือจากน้อยไปมาก, -1 คือจากมากไปน้อย เมื่อเสมอกันจะตัดสินด้วย key ที่คุณระบุทีหลัง ตรงนี้เราเรียงทุกคนจากค่าปรับมากที่สุดไปน้อยที่สุด:
db.members.find().sort({ fines: -1 })const docs = await db.collection("members") .find() .sort({ fines: -1 }) .toArray();docs = list(db.members.find().sort("fines", -1))opts := options.Find().SetSort(bson.D{{Key: "fines", Value: -1}})cur, err := coll.Find(ctx, bson.M{}, 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! {}) .sort(doc! { "fines": -1 }) .await?;ยอดค้างสูงสุดมาก่อน:
[ { "name": "Linus", "joined": 2023, "fines": 5 }, { "name": "Grace", "joined": 2021, "fines": 3 }, { "name": "Margaret", "joined": 2023, "fines": 0 }]การแบ่งหน้า: limit และ skip
หัวข้อที่มีชื่อว่า “การแบ่งหน้า: limit และ skip”limit จำกัดจำนวน document ที่คุณได้รับ ส่วน skip ทิ้งจำนวนเท่านั้นจากด้านหน้าก่อน พอจับคู่กับ sort ที่เสถียร ก็ได้การแบ่งหน้าออกมา สำหรับหน้าที่สองที่มีสองรายการ เรียงตามชื่อ คุณ skip สองตัวแรกแล้วเอาสองตัว:
db.members.find() .sort({ name: 1 }) .skip(2) .limit(2)const docs = await db.collection("members") .find() .sort({ name: 1 }) .skip(2) .limit(2) .toArray();docs = list( db.members.find().sort("name", 1).skip(2).limit(2))opts := options.Find(). SetSort(bson.D{{Key: "name", Value: 1}}). SetSkip(2). SetLimit(2)cur, err := coll.Find(ctx, bson.M{}, 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! {}) .sort(doc! { "name": 1 }) .skip(2) .limit(2) .await?;คุณจะได้สมาชิกคนที่สามและสี่ตามลำดับชื่อ — หน้าที่สอง:
[ { "name": "Grace", "joined": 2021, "fines": 3 }, { "name": "Linus", "joined": 2023, "fines": 5 }]ชิ้นส่วนเหล่านี้ประกอบกันอย่างไร
หัวข้อที่มีชื่อว่า “ชิ้นส่วนเหล่านี้ประกอบกันอย่างไร”server ใช้ stage เหล่านี้ในลำดับที่ตายตัว ไม่ว่าคุณจะต่อ method เรียงยังไง คือ filter ก่อน แล้ว sort สิ่งที่ match แล้ว skip แล้วค่อย limit การรู้ลำดับนี้อธิบายว่าทำไมจึงต้องมี sort เพื่อหน้าที่เสถียร และทำไม skip ขนาดใหญ่จึงสิ้นเปลือง
flowchart LR C["Collection"] --> F["find filter"] F --> S["sort"] S --> SK["skip n"] SK --> L["limit k"] L --> R["Cursor of k documents"]
การวนซ้ำ cursor
หัวข้อที่มีชื่อว่า “การวนซ้ำ cursor”find ส่งคืน cursor ไม่ใช่ list driver ดึง document มาทีละ batch ขณะที่คุณวนซ้ำ ดังนั้นคุณสามารถ stream ผลลัพธ์ก้อนใหญ่ได้โดยไม่ต้องโหลดทั้งหมดเข้า memory ในคราวเดียว การเรียก toArray (หรือสิ่งเทียบเท่า) เพียงแค่ดึง cursor ออกให้คุณจนหมด:
const cursor = db.members.find().sort({ name: 1 })while (cursor.hasNext()) { printjson(cursor.next())}const cursor = db.collection("members").find().sort({ name: 1 });for await (const doc of cursor) { console.log(doc.name);}cursor = db.members.find().sort("name", 1)for doc in cursor: print(doc["name"])cur, err := coll.Find(ctx, bson.M{})if err != nil { return err}defer cur.Close(ctx)for cur.Next(ctx) { var doc bson.M if err := cur.Decode(&doc); err != nil { return err } fmt.Println(doc["name"])}let mut cursor = coll.find(doc! {}).sort(doc! { "name": 1 }).await?;while let Some(doc) = cursor.try_next().await? { println!("{:?}", doc.get_str("name"));}ทำไม skip ขนาดใหญ่จึงช้า
หัวข้อที่มีชื่อว่า “ทำไม skip ขนาดใหญ่จึงช้า”skip(n) ไม่ได้กระโดดไปยัง document ตัวที่ n — server ยังต้อง เดินผ่าน n ตัวที่ match ก่อน ถึงจะส่งอะไรกลับมาได้ หน้าที่สองยังไม่เท่าไร แต่ในหน้าที่ 5,000 ฐานข้อมูลต้องไถผ่าน document หลายแสนตัวเพียงเพื่อโยนทิ้ง วิธีแก้คือ range-based pagination: แทนที่จะ skip ให้จำค่าสุดท้ายที่เห็น แล้วขอ document ถัดจากค่านั้น หากต้องการไปต่อจากสมาชิกคนสุดท้ายชื่อ “Linus” คุณจะ filter ไปข้างหน้าแทนการ skip:
db.members.find({ name: { $gt: "Linus" } }) .sort({ name: 1 }) .limit(2)วิธีนี้อ่านเฉพาะหน้าที่คุณต้องการเสมอ ไม่ว่าคุณจะลึกแค่ไหน เราจะกลับมาที่ range-based pagination และ index ที่ทำให้เร็วในโมดูลถัด ๆ ไป — สำหรับตอนนี้ แค่รู้ว่า skip ใช้ได้ดีกับหน้าตื้น ๆ และเป็นปัญหากับหน้าที่ลึก
ใน Compass: แท็บ Documents มีตัวควบคุมเฉพาะสำหรับทั้งหมดนี้ — กล่อง Project สำหรับรายชื่อ field, กล่อง Sort สำหรับลำดับ และช่อง skip/limit ข้างแถบ filter ดังนั้นคุณสร้าง query ทั้งก้อนได้โดยไม่ต้องเขียน method chain ด้วยมือ
เคล็ดลับและจุดที่พลาดง่าย
หัวข้อที่มีชื่อว่า “เคล็ดลับและจุดที่พลาดง่าย”- sort ก่อนแบ่งหน้าเสมอ หากไม่มี
sortที่ชัดเจน ลำดับของผลลัพธ์ไม่รับประกัน ดังนั้นskip/limitอาจส่ง document ที่ซ้ำหรือขาดหายระหว่างหน้า การ sort ที่เสถียรบน field ที่มีความเป็นเอกลักษณ์พอคือสิ่งที่ทำให้การแบ่งหน้าถูกต้อง _idติดมาเสมอ เว้นแต่คุณจะตัดออกเอง ใส่_id: 0ลงใน projection เมื่อไม่ต้องการ นี่คือกรณีเดียวที่คุณอาจผสมการแยกออกกับ list ที่รวม field ได้- อย่าผสมการรวมและการแยกออก นอกเหนือจากนั้น projection คือ list ของ field ที่จะเก็บ หรือ list ที่จะทิ้ง ไม่ใช่ทั้งสอง
- ต้นทุนของ
skipขนาดใหญ่เพิ่มขึ้นตามหมายเลขหน้า สำหรับหน้าที่ลึก ให้เปลี่ยนไปใช้ range-based pagination บน field ที่มี index ซึ่งอ่านเฉพาะ document ที่คุณส่งกลับจริง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
inclusion projection ({ name: 1, joined: 1 }) | ควบคุมชัดเจนว่า field ไหนออกไป ปลอดภัยสำหรับ response สาธารณะ | ต้องแก้ query ทุกครั้งที่ต้องการ field ใหม่ |
exclusion projection ({ internalNotes: 0 }) | สะดวก เขียนสั้น ไม่ต้อง list field ที่ต้องการทั้งหมด | field ใหม่ที่เพิ่มเข้า schema ทีหลังจะหลุดออกไปเองโดยไม่ตั้งใจ |
| skip/limit pagination | เขียนง่าย เหมาะกับหน้าตื้น ๆ และ UI แบบเลขหน้า | skip ต้องเดินผ่าน document ที่ถูกข้ามทุกครั้ง ช้าลงเรื่อย ๆ เมื่อหน้าลึกขึ้น |
| range/cursor-based pagination | อ่านเฉพาะ document ที่ต้องการเสมอ ไม่ว่าจะลึกแค่ไหน scale ได้ดีกับข้อมูลใหญ่ | กระโดดไปหน้า N ตรง ๆ ไม่ได้ ต้องมี field ที่ sort ได้และมี index รองรับ |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- ใช้ exclusion projection สำหรับ response สาธารณะแล้วลืมว่าวิธีนี้จะ “รั่ว” field ใหม่ออกไปให้อัตโนมัติ — เมื่อทีม backend เพิ่ม field ภายใน (เช่น cost หรือ flag ภายใน) เข้า schema ทีหลัง exclusion projection ที่เขียนไว้เดิมจะส่ง field นั้นออกไปทันทีโดยไม่มีใครตั้งใจ สำหรับ API สาธารณะควรใช้ inclusion projection เพื่อควบคุมชัดเจนกว่า
- sort บน field ที่ไม่มี index กับ collection ขนาดใหญ่ — server ต้องทำ in-memory sort ซึ่งมีเพดานหน่วยความจำจำกัด ถ้าข้อมูลที่ต้อง sort เกินเพดานนั้น query จะ error ตรง ๆ ต้องมี index รองรับ sort key เมื่อ collection โตขึ้น
- คิดว่า
limit()เพียงอย่างเดียวทำให้ query เร็วเสมอ — ถ้าไม่มี filter หรือ index ที่ดี server ยังต้องสแกนและ sort document จำนวนมากก่อนจะตัดเหลือแค่จำนวนที่limitขอlimitไม่ได้ลดงานสแกน แค่ตัดจำนวนที่ส่งกลับมาเท่านั้น
💡 ตัวอย่างจากของจริง
Infinite scroll ของ social feed — ใช้ cursor-based pagination ด้วย field เช่น timestamp หรือ
_idแทน skip/limit เพราะผู้ใช้เลื่อนลึกได้เรื่อย ๆ โดยไม่ยอมให้ query ช้าลงตามความลึกของหน้าREST API ของ SaaS ที่ expose ข้อมูลลูกค้า — ใช้ inclusion projection เสมอสำหรับ endpoint สาธารณะ เพื่อไม่ให้ field ภายใน เช่น cost หรือ internal flag รั่วไหลออกไปเมื่อทีม backend เพิ่ม field ใหม่ในอนาคต