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

Projection, sort และการแบ่งหน้า

filter ตัดสินว่า document ไหน จะส่งกลับมา ตัวควบคุมอีกสามตัวตัดสินว่า document เหล่านั้นจะหน้าตาเป็นอย่างไรระหว่างทางมาถึงแอปของคุณ: projection ตัด field, sort กำหนดลำดับ และ limit กับ skip แบ่งผลลัพธ์ออกเป็นหน้า ใช้ร่วมกันแล้วคุณจะไม่ต้องลาก document ทั้งก้อนหรือทั้ง collection ข้ามเครือข่ายในเวลาที่คุณต้องการแค่ชื่อกับหนึ่งหน้าสิบรายการ บทเรียนนี้ครอบคลุมทั้งสี่ และจบด้วยลูกเล่นการแบ่งหน้าหนึ่งตัวที่ค่อย ๆ ช้าลงอย่างเงียบ ๆ

เรากลับมาที่สมาชิกห้องสมุด members แบบง่าย แต่ละคนมี name, ปี joined และ fines

projection คือ document ตัวที่สองที่ส่งให้ find ซึ่งระบุว่าจะเก็บ field ไหน ตั้ง field เป็น 1 เพื่อเอาติดมา ส่วนอะไรที่ไม่ได้ระบุจะถูกตัดทิ้งหมด — ยกเว้น _id ซึ่งจะส่งกลับมาเว้นแต่คุณจะกำหนดให้แยกออกอย่างชัดเจนด้วย 0 ตรงนี้เราต้องการเฉพาะชื่อกับปีที่เข้าร่วม โดยไม่เอา _id:

db.members.find(
{ fines: 0 },
{ name: 1, joined: 1, _id: 0 }
)

เฉพาะ field ที่ขอเท่านั้นที่มาถึง และ _id หายไป:

[
{ "name": "Margaret", "joined": 2023 },
{ "name": "Ada", "joined": 2024 }
]

คุณอาจรวม field หรือแยก field ออกได้ แต่ผสมทั้งสองใน projection เดียวไม่ได้ — ข้อยกเว้นเดียวคือการแยก _id ออกควบคู่ไปกับ list ที่รวม field ดังตัวอย่างข้างต้น

sort จัดลำดับผลลัพธ์ตาม field หนึ่งหรือมากกว่า: 1 คือจากน้อยไปมาก, -1 คือจากมากไปน้อย เมื่อเสมอกันจะตัดสินด้วย key ที่คุณระบุทีหลัง ตรงนี้เราเรียงทุกคนจากค่าปรับมากที่สุดไปน้อยที่สุด:

db.members.find().sort({ fines: -1 })

ยอดค้างสูงสุดมาก่อน:

[
{ "name": "Linus", "joined": 2023, "fines": 5 },
{ "name": "Grace", "joined": 2021, "fines": 3 },
{ "name": "Margaret", "joined": 2023, "fines": 0 }
]

limit จำกัดจำนวน document ที่คุณได้รับ ส่วน skip ทิ้งจำนวนเท่านั้นจากด้านหน้าก่อน พอจับคู่กับ sort ที่เสถียร ก็ได้การแบ่งหน้าออกมา สำหรับหน้าที่สองที่มีสองรายการ เรียงตามชื่อ คุณ skip สองตัวแรกแล้วเอาสองตัว:

db.members.find()
.sort({ name: 1 })
.skip(2)
.limit(2)

คุณจะได้สมาชิกคนที่สามและสี่ตามลำดับชื่อ — หน้าที่สอง:

[
{ "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"]
การอ่านไหลผ่าน find แล้ว sort แล้ว skip แล้ว limit — ตามลำดับนั้น — ก่อนที่ 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())
}

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 ที่คุณส่งกลับจริง ๆ
ตัวเลือกBenefitCost
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 ใหม่ในอนาคต

ใน projection { name: 1, _id: 0 } ส่งกลับอะไร?
sort({ fines: -1 }) ทำอะไร?
ทำไมคุณจึงควร sort ก่อนใช้ skip และ limit เสมอ?
ทำไม skip(n) ขนาดใหญ่จึงช้า?