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

$facet และ bucket

สอง stage สุดท้ายในโมดูลนี้ตอบคำถามที่ต้องการสรุป หลายอย่าง พร้อมกัน $facet รัน sub-pipeline หลายอันเคียงข้างกันบน input เดียวกัน คุณจึงได้หน้าผลลัพธ์ จำนวนนับรวม และการแยกย่อยในการเดินทางครั้งเดียว $bucket และ $bucketAuto จัดกลุ่ม document เข้าช่วง เหมือนที่ฮิสโทแกรมจัดเรียงค่าเข้าถัง พอใช้คู่กันก็ได้ query สไตล์ “dashboard” คือหนึ่ง request ได้หลายแผงพร้อมกัน

เรากลับมาที่ collection sales แบบแบนราบ ที่แต่ละ document คือออเดอร์เดียวที่มี price, quantity และ genre

$facet รับ object ที่คีย์เป็นชื่อที่คุณเลือก และค่าเป็น sub-pipeline แต่ละ sub-pipeline เห็น document input เดียวกัน — อันที่ไปถึง stage $facet — และผลิต array ผลลัพธ์ของตัวเอง ผลลัพธ์คือหนึ่ง document ที่มีฟิลด์ต่อหนึ่ง facet การใช้งานคลาสสิกคือ “ผลลัพธ์บวกจำนวนนับรวม” สำหรับรายการแบบแบ่งหน้า:

flowchart LR
  In["sales documents"] --> F["$facet"]
  F --> A["pageResults sub-pipeline"]
  F --> B["totalCount sub-pipeline"]
  F --> C["byGenre sub-pipeline"]
  A --> Out["one document with three fields"]
  B --> Out
  C --> Out
หนึ่ง stage หลาย sub-pipeline บน input เดียวกัน รวมเข้าเป็นหนึ่ง document ผลลัพธ์
db.sales.aggregate([
{ $facet: {
topOrders: [
{ $sort: { price: -1 } },
{ $limit: 2 },
{ $project: { _id: 0, title: 1, price: 1 } }
],
totalCount: [
{ $count: "orders" }
],
byGenre: [
{ $group: { _id: "$genre", count: { $sum: 1 } } }
]
} }
])

หนึ่ง document กลับมา แต่ละ facet อยู่ใต้คีย์ของตัวเอง:

[
{
"topOrders": [
{ "title": "Cosmos", "price": 22 },
{ "title": "Foundation", "price": 15 }
],
"totalCount": [ { "orders": 6 } ],
"byGenre": [
{ "_id": "fiction", "count": 4 },
{ "_id": "science", "count": 2 }
]
}
]

ใน Compass: $facet มีให้ใช้ในแท็บ Aggregations และแต่ละ sub-pipeline สร้างและดูตัวอย่างได้ก่อนจะรวมเข้าด้วยกัน เป็นวิธีที่เป็นธรรมชาติมากในการประกอบ query แบบ “ผลลัพธ์บวกจำนวนนับ” สำหรับหน้าจอที่แบ่งหน้า

$bucket จัดเรียง document ลงถังที่คุณกำหนด ขอบเขต ไว้เอง โดยส่ง expression groupBy array ของ boundaries ที่เรียงจากน้อยไปมาก ถัง default สำหรับค่าที่ตกนอกช่วง และ output ของ accumulator ต่อถัง ที่นี่เราจัดออเดอร์เข้าถังตามราคาเป็นแถบ “cheap” “mid” และ “premium”:

db.sales.aggregate([
{ $bucket: {
groupBy: "$price",
boundaries: [0, 10, 20, 100],
default: "other",
output: {
count: { $sum: 1 },
titles: { $push: "$title" }
}
} }
])

_id ของแต่ละถังคือขอบเขตล่างของถังนั้น ส่วน accumulator สรุปออเดอร์ที่ตกลงมาในถัง:

[
{ "_id": 0, "count": 2, "titles": ["Cheap Reads", "Pocket Atlas"] },
{ "_id": 10, "count": 3, "titles": ["Dune", "Foundation", "Neuromancer"] },
{ "_id": 20, "count": 1, "titles": ["Cosmos"] }
]

เมื่อคุณไม่รู้ขอบเขตที่ดีล่วงหน้า $bucketAuto จะเลือกขอบเขตให้เอง เพื่อกระจาย document ลงตามจำนวนถังเป้าหมายให้เท่ากันที่สุด คุณส่งแค่ groupBy กับจำนวน buckets แล้วจะได้ min และ max ของแต่ละถังกลับมาใน _id:

db.sales.aggregate([
{ $bucketAuto: {
groupBy: "$price",
buckets: 3
} }
])

ขอบเขตคำนวณมาให้เรียบร้อย แต่ละถังพาช่วงและจำนวนนับติดมาด้วย:

[
{ "_id": { "min": 8, "max": 12 }, "count": 2 },
{ "_id": { "min": 12, "max": 18 }, "count": 2 },
{ "_id": { "min": 18, "max": 22 }, "count": 2 }
]
  • แต่ละ sub-pipeline ของ $facet เริ่มจาก input เดียวกัน filter ก่อน $facet ถ้าคุณต้องการให้ทุก facet ใช้เซตที่แคบลงร่วมกัน แทนที่จะทำ $match ซ้ำในแต่ละอัน
  • $facet ใช้ index กับ sub-pipeline ข้างในไม่ได้ เพราะรันบน document ที่ stream เข้ามาแล้ว ทำให้ input เล็กไว้ด้วยการ match ตั้งแต่เนิ่น ๆ
  • $bucket ต้องการ boundaries ที่เรียงจากน้อยไปมาก ค่าที่ต่ำกว่าขอบเขตแรกหรือสูงกว่าขอบเขตสุดท้ายจะไปยัง default และหากไม่มี default ค่าเช่นนั้นจะทำให้เกิดข้อผิดพลาด
  • $bucketAuto มุ่งให้ขนาดถังเท่ากันแต่จะไม่แยกค่าที่เหมือนกันออกข้ามถัง ดังนั้นจำนวนนับอาจออกมาไม่เท่ากันเมื่อ document จำนวนมากมีค่าร่วมกัน
ตัวเลือกBenefitCost
$facet (รวมหลาย sub-pipeline ใน request เดียว)ได้ผลลัพธ์บวกจำนวนนับในคำขอเดียว ลด round-trip ระหว่าง client กับ serverแต่ละ facet ต้อง output ไม่เกิน 16MB และ $facet ใช้ index กับ sub-pipeline ข้างในไม่ได้
ยิงหลาย query แยกกัน (แทน $facet)แต่ละ query ใช้ index ได้เต็มที่ ไม่ติด limit ของ $facetเพิ่มจำนวน round-trip ไป server และต้องดูแล consistency ระหว่างหลาย query เอง
$bucket (กำหนด boundaries เอง)ควบคุมขอบเขตของแต่ละถังได้ตรงตาม business logic เช่น ช่วงราคาที่ธุรกิจกำหนดต้องรู้ล่วงหน้าว่าขอบเขตควรเป็นเท่าไร ถ้าข้อมูลกระจายเปลี่ยนไปต้องมาปรับ boundaries เอง
$bucketAuto (คำนวณ boundaries อัตโนมัติ)ไม่ต้องรู้การกระจายของข้อมูลล่วงหน้า จำนวน document ต่อถังสมดุลกันขอบเขตของถังไม่คงที่ระหว่างการรันแต่ละครั้งถ้าข้อมูลเปลี่ยน ทำให้เทียบผลระหว่างช่วงเวลายากขึ้น
  • ใส่ document ขนาดใหญ่เข้า $facet โดยไม่ filter ก่อน แล้วเจอ error เกิน 16MB — แต่ละ sub-pipeline ของ $facet มี output limit 16MB ต่อ facet ถ้า input ใหญ่มากต้อง $match/$limit ก่อนเข้า $facet เสมอ
  • คาดว่า sub-pipeline ของ $facet ใช้ index ได้เหมือน pipeline ปกติ$facet รันบน document ที่ถูก stream มาแล้ว ไม่ใช่จาก collection โดยตรง ดังนั้น sub-pipeline ข้างในใช้ index ไม่ได้ ต้อง match ให้เล็กก่อนเข้า stage นี้
  • ใช้ $bucket แล้วไม่ตั้ง default แล้วแปลกใจว่า pipeline error — ถ้ามีค่าที่ตกนอกช่วง boundaries และไม่ได้ตั้ง default ไว้ $bucket จะโยน error ทันที ไม่ใช่แค่ข้ามค่าที่ไม่ตรงไปเฉย ๆ

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

Analytics dashboard แบบแบ่งหน้า — หน้ารายการสินค้าที่ต้องโชว์ทั้งหน้าปัจจุบันและจำนวนรวมทั้งหมดในคำขอเดียว ใช้ $facet แยกเป็น sub-pipeline pageResults กับ totalCount เพื่อลด round-trip ไม่ต้องยิง query สองรอบ

Keller Williams — รายงานราคาบ้านที่ต้องแบ่งกลุ่มเป็นช่วงราคา (budget/mid-range/luxury) ใช้ $bucket กับขอบเขตที่ทีมธุรกิจกำหนดไว้ตายตัว ส่วนการสำรวจตลาดใหม่ที่ยังไม่รู้การกระจายราคาใช้ $bucketAuto เพื่อให้ระบบแบ่งกลุ่มให้อัตโนมัติ

แต่ละ sub-pipeline ของ stage $facet รับ input อะไร?
การใช้งานทั่วไปของ $facet คือการคืนผลลัพธ์แบบแบ่งหน้าพร้อมกับสิ่งใดใน query เดียว?
ใน $bucket, ค่าที่ตกนอกขอบเขตทั้งหมดไปที่ไหน?
$bucketAuto ต่างจาก $bucket อย่างไร?