$facet และ bucket
สอง stage สุดท้ายในโมดูลนี้ตอบคำถามที่ต้องการสรุป หลายอย่าง พร้อมกัน $facet รัน sub-pipeline หลายอันเคียงข้างกันบน input เดียวกัน คุณจึงได้หน้าผลลัพธ์ จำนวนนับรวม และการแยกย่อยในการเดินทางครั้งเดียว $bucket และ $bucketAuto จัดกลุ่ม document เข้าช่วง เหมือนที่ฮิสโทแกรมจัดเรียงค่าเข้าถัง พอใช้คู่กันก็ได้ query สไตล์ “dashboard” คือหนึ่ง request ได้หลายแผงพร้อมกัน
เรากลับมาที่ collection sales แบบแบนราบ ที่แต่ละ document คือออเดอร์เดียวที่มี price, quantity และ genre
$facet รัน sub-pipeline แบบขนาน
หัวข้อที่มีชื่อว่า “$facet รัน sub-pipeline แบบขนาน”$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
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 } } } ] } }])const rows = await db.collection("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 } } }, ], } },]).toArray();rows = list(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}}}, ], }},]))pipeline := mongo.Pipeline{ {{"$facet", bson.D{ {"topOrders", bson.A{ bson.D{{"$sort", bson.D{{"price", -1}}}}, bson.D{{"$limit", 2}}, bson.D{{"$project", bson.D{{"_id", 0}, {"title", 1}, {"price", 1}}}}, }}, {"totalCount", bson.A{ bson.D{{"$count", "orders"}}, }}, {"byGenre", bson.A{ bson.D{{"$group", bson.D{{"_id", "$genre"}, {"count", bson.D{{"$sum", 1}}}}}}, }}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$facet": { "topOrders": [ { "$sort": { "price": -1 } }, { "$limit": 2 }, { "$project": { "_id": 0, "title": 1, "price": 1 } } ], "totalCount": [ { "$count": "orders" } ], "byGenre": [ { "$group": { "_id": "$genre", "count": { "$sum": 1 } } } ] } },];let mut cursor = coll.aggregate(pipeline).await?;หนึ่ง 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 จัดกลุ่มเข้าช่วงที่ระบุชัดเจน
หัวข้อที่มีชื่อว่า “$bucket จัดกลุ่มเข้าช่วงที่ระบุชัดเจน”$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" } } } }])const rows = await db.collection("sales").aggregate([ { $bucket: { groupBy: "$price", boundaries: [0, 10, 20, 100], default: "other", output: { count: { $sum: 1 }, titles: { $push: "$title" }, }, } },]).toArray();rows = list(db.sales.aggregate([ {"$bucket": { "groupBy": "$price", "boundaries": [0, 10, 20, 100], "default": "other", "output": { "count": {"$sum": 1}, "titles": {"$push": "$title"}, }, }},]))pipeline := mongo.Pipeline{ {{"$bucket", bson.D{ {"groupBy", "$price"}, {"boundaries", bson.A{0, 10, 20, 100}}, {"default", "other"}, {"output", bson.D{ {"count", bson.D{{"$sum", 1}}}, {"titles", bson.D{{"$push", "$title"}}}, }}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$bucket": { "groupBy": "$price", "boundaries": [0, 10, 20, 100], "default": "other", "output": { "count": { "$sum": 1 }, "titles": { "$push": "$title" } } } },];let mut cursor = coll.aggregate(pipeline).await?;_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 เลือกขอบเขตให้คุณ
หัวข้อที่มีชื่อว่า “$bucketAuto เลือกขอบเขตให้คุณ”เมื่อคุณไม่รู้ขอบเขตที่ดีล่วงหน้า $bucketAuto จะเลือกขอบเขตให้เอง เพื่อกระจาย document ลงตามจำนวนถังเป้าหมายให้เท่ากันที่สุด คุณส่งแค่ groupBy กับจำนวน buckets แล้วจะได้ min และ max ของแต่ละถังกลับมาใน _id:
db.sales.aggregate([ { $bucketAuto: { groupBy: "$price", buckets: 3 } }])const rows = await db.collection("sales").aggregate([ { $bucketAuto: { groupBy: "$price", buckets: 3, } },]).toArray();rows = list(db.sales.aggregate([ {"$bucketAuto": { "groupBy": "$price", "buckets": 3, }},]))pipeline := mongo.Pipeline{ {{"$bucketAuto", bson.D{ {"groupBy", "$price"}, {"buckets", 3}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$bucketAuto": { "groupBy": "$price", "buckets": 3 } },];let mut cursor = coll.aggregate(pipeline).await?;ขอบเขตคำนวณมาให้เรียบร้อย แต่ละถังพาช่วงและจำนวนนับติดมาด้วย:
[ { "_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 จำนวนมากมีค่าร่วมกัน
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
$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-pipelinepageResultsกับtotalCountเพื่อลด round-trip ไม่ต้องยิง query สองรอบKeller Williams — รายงานราคาบ้านที่ต้องแบ่งกลุ่มเป็นช่วงราคา (budget/mid-range/luxury) ใช้
$bucketกับขอบเขตที่ทีมธุรกิจกำหนดไว้ตายตัว ส่วนการสำรวจตลาดใหม่ที่ยังไม่รู้การกระจายราคาใช้$bucketAutoเพื่อให้ระบบแบ่งกลุ่มให้อัตโนมัติ