$group และ accumulator
$group คือจุดที่ aggregation สมชื่อจริง ๆ เพราะยุบ document จำนวนมากให้เหลือหนึ่ง document ต่อค่าที่แตกต่างกันแต่ละค่า ของคีย์ที่คุณเลือก แล้วคำนวณค่าสรุปของแต่ละกลุ่มด้วย accumulator
นี่คือ stage ที่ตอบคำถามอย่าง “แต่ละ genre ขายได้กี่เล่ม” “ราคาเฉลี่ยต่อผู้ซื้อเท่าไร” หรือ “ออเดอร์ที่ใหญ่ที่สุดของแต่ละชื่อเรื่องคืออันไหน” ถ้าคุณเคยเขียน GROUP BY ใน SQL แนวคิดก็อันเดียวกัน ต่างแค่วิธีเขียน
เรายังใช้ collection sales จากบทเรียนก่อนหน้าต่อ
การจัดกลุ่มตามคีย์
หัวข้อที่มีชื่อว่า “การจัดกลุ่มตามคีย์”_id ของ stage $group คือคีย์การจัดกลุ่ม — ฟิลด์ (หรือค่าที่คำนวณ) ที่ใช้แบ่ง document เป็นถัง ทุกฟิลด์อื่นใน stage คือ accumulator: expression ที่ยุบ document ทั้งหมดในกลุ่มให้เป็นค่าเดียว ที่นี่เรารวมจำนวนที่ขายต่อ genre และนับออเดอร์ด้วย:
flowchart LR In["sales documents"] --> G["$group _id genre"] G --> A["accumulators: $sum quantity, $sum 1"] A --> Out["one document per genre"]
db.sales.aggregate([ { $group: { _id: "$genre", totalCopies: { $sum: "$quantity" }, orders: { $sum: 1 } } }])const rows = await db.collection("sales").aggregate([ { $group: { _id: "$genre", totalCopies: { $sum: "$quantity" }, orders: { $sum: 1 }, } },]).toArray();rows = list(db.sales.aggregate([ {"$group": { "_id": "$genre", "totalCopies": {"$sum": "$quantity"}, "orders": {"$sum": 1}, }},]))pipeline := mongo.Pipeline{ {{"$group", bson.D{ {"_id", "$genre"}, {"totalCopies", bson.D{{"$sum", "$quantity"}}}, {"orders", bson.D{{"$sum", 1}}}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$group": { "_id": "$genre", "totalCopies": { "$sum": "$quantity" }, "orders": { "$sum": 1 } } },];let mut cursor = coll.aggregate(pipeline).await?;ได้ document กลับมาหนึ่งอันต่อหนึ่ง genre พร้อมค่าสรุปของแต่ละกลุ่ม:
[ { "_id": "fiction", "totalCopies": 11, "orders": 4 }, { "_id": "science", "totalCopies": 5, "orders": 2 }]accumulator ที่ใช้บ่อย
หัวข้อที่มีชื่อว่า “accumulator ที่ใช้บ่อย”accumulator จำนวนหยิบมือเดียวครอบคลุมความต้องการส่วนใหญ่ ทุกตัวรับ expression ที่ประเมินกับแต่ละ document ในกลุ่ม:
$sum— บวก expression ตัวเลข หรือใช้นับด้วย$sum: 1$avg— ค่าเฉลี่ยของ expression ตัวเลข$min/$max— ค่าน้อยที่สุดและมากที่สุดที่พบ$push— เก็บทุกค่าเข้า array รวมค่าซ้ำด้วย$addToSet— เก็บค่าที่ แตกต่างกัน เข้า array
stage $count เฉพาะทางก็มีอยู่สำหรับกรณีพิเศษ “แค่นับ document” ที่นี่เราทำโปรไฟล์แต่ละ genre ให้สมบูรณ์ขึ้น — ราคาเฉลี่ย ช่วงราคา และเซตของผู้ซื้อ:
db.sales.aggregate([ { $group: { _id: "$genre", avgPrice: { $avg: "$price" }, cheapest: { $min: "$price" }, priciest: { $max: "$price" }, buyers: { $addToSet: "$buyer" } } }])const rows = await db.collection("sales").aggregate([ { $group: { _id: "$genre", avgPrice: { $avg: "$price" }, cheapest: { $min: "$price" }, priciest: { $max: "$price" }, buyers: { $addToSet: "$buyer" }, } },]).toArray();rows = list(db.sales.aggregate([ {"$group": { "_id": "$genre", "avgPrice": {"$avg": "$price"}, "cheapest": {"$min": "$price"}, "priciest": {"$max": "$price"}, "buyers": {"$addToSet": "$buyer"}, }},]))pipeline := mongo.Pipeline{ {{"$group", bson.D{ {"_id", "$genre"}, {"avgPrice", bson.D{{"$avg", "$price"}}}, {"cheapest", bson.D{{"$min", "$price"}}}, {"priciest", bson.D{{"$max", "$price"}}}, {"buyers", bson.D{{"$addToSet", "$buyer"}}}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$group": { "_id": "$genre", "avgPrice": { "$avg": "$price" }, "cheapest": { "$min": "$price" }, "priciest": { "$max": "$price" }, "buyers": { "$addToSet": "$buyer" } } },];let mut cursor = coll.aggregate(pipeline).await?;ตอนนี้แต่ละ genre มีโปรไฟล์ครบถ้วน และ buyers เก็บแต่ละชื่อไว้เพียงครั้งเดียว:
[ { "_id": "fiction", "avgPrice": 12.5, "cheapest": 9, "priciest": 15, "buyers": ["Ada", "Linus"] }, { "_id": "science", "avgPrice": 20, "cheapest": 18, "priciest": 22, "buyers": ["Grace"] }]ใน Compass: stage $group ในแท็บ Aggregations ให้โครงสร้าง _id และ accumulator เดียวกันกับคุณ การดูตัวอย่างมีประโยชน์ตรงนี้ — เปลี่ยน expression ของ _id แล้วเฝ้าดูจำนวน document ผลลัพธ์เปลี่ยนไปเมื่อการจัดกลุ่มหยาบขึ้นหรือแยกย่อยลง
ยอดรวมทั้งหมดด้วย _id null
หัวข้อที่มีชื่อว่า “ยอดรวมทั้งหมดด้วย _id null”ตั้งค่า _id ของกลุ่มเป็น null แล้วทุก document จะตกลงในกลุ่ม เดียว ให้ยอดรวมทั้งหมดทั่วทั้ง stream นี่คือวิธีที่คุณคำนวณ “รายได้รวม” หรือ “ค่าเฉลี่ยโดยรวม” โดยไม่แยกตามคีย์:
db.sales.aggregate([ { $group: { _id: null, totalRevenue: { $sum: { $multiply: ["$price", "$quantity"] } }, totalOrders: { $sum: 1 } } }])const rows = await db.collection("sales").aggregate([ { $group: { _id: null, totalRevenue: { $sum: { $multiply: ["$price", "$quantity"] } }, totalOrders: { $sum: 1 }, } },]).toArray();rows = list(db.sales.aggregate([ {"$group": { "_id": None, "totalRevenue": {"$sum": {"$multiply": ["$price", "$quantity"]}}, "totalOrders": {"$sum": 1}, }},]))pipeline := mongo.Pipeline{ {{"$group", bson.D{ {"_id", nil}, {"totalRevenue", bson.D{{"$sum", bson.D{{"$multiply", bson.A{"$price", "$quantity"}}}}}}, {"totalOrders", bson.D{{"$sum", 1}}}, }}},}cursor, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}let pipeline = vec![ doc! { "$group": { "_id": null, "totalRevenue": { "$sum": { "$multiply": ["$price", "$quantity"] } }, "totalOrders": { "$sum": 1 } } },];let mut cursor = coll.aggregate(pipeline).await?;document สรุปเดียวแทนทั้ง collection:
[ { "_id": null, "totalRevenue": 248, "totalOrders": 6 }]ทิปและกับดัก
หัวข้อที่มีชื่อว่า “ทิปและกับดัก”- หลังจาก
$groupมีแต่ฟิลด์ที่คุณสร้างขึ้นเท่านั้นที่ดำรงอยู่ ฟิลด์ document ต้นฉบับหายไป เว้นแต่คุณจะพาติดมาด้วย accumulator อย่าง$pushหรือ$first $sum: 1คือสำนวน “นับแถวในกลุ่มนี้” ส่วน$sum: "$quantity"บวกค่าของฟิลด์แทน$pushเก็บค่าซ้ำและลำดับไว้ ส่วน$addToSetลบค่าซ้ำและไม่รับรองลำดับ หยิบ$addToSetมาใช้เมื่อคุณต้องการลิสต์ที่ แตกต่างกัน- การจัดกลุ่มไม่เรียงลำดับ ถ้าคุณต้องการกลุ่มที่ใหญ่ที่สุดอยู่ก่อน ให้เพิ่ม stage
$sortหลัง$group
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
$group ใน pipeline | คำนวณสรุปฝั่ง server ส่งกลับแค่ผลลัพธ์ที่ยุบแล้ว ประหยัด bandwidth และ memory ฝั่ง client | ต้องคิดเป็น expression ของ MongoDB (accumulator) ซึ่งอาจจำกัดกว่า logic ที่เขียนใน application code |
| จัดกลุ่มใน application code (ดึง document ทั้งหมดมาแล้วสรุปเอง) | ยืดหยุ่นเต็มที่ ใช้ library หรือ logic ใดก็ได้ | ต้องส่ง document ดิบทั้งหมดผ่านเครือข่ายก่อน ช้าและกิน memory มากเมื่อข้อมูลใหญ่ |
accumulator ที่แม่นยำ เช่น $sum, $avg | ผลลัพธ์ถูกต้อง 100% เหมาะกับตัวเลขทางการเงินหรือรายงาน | ต้องสแกน/ประมวลผล document ทุกตัวในกลุ่ม ซึ่งหนักกว่าการประมาณค่า |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”- คิดว่าฟิลด์ต้นฉบับยังอยู่หลัง
$groupโดยอัตโนมัติ — หลัง$groupมีแค่_idกับ accumulator ที่คุณประกาศไว้เท่านั้น ฟิลด์อื่นของ document ต้นฉบับหายไป เว้นแต่จะดึงมาด้วย$first,$lastหรือ$pushเอง - ใช้
$pushเมื่อจริง ๆ ต้องการค่าที่ไม่ซ้ำ —$pushเก็บค่าซ้ำและรักษาลำดับ ถ้าต้องการ list ที่ไม่มีค่าซ้ำต้องใช้$addToSetไม่ใช่มา dedupe เองในภายหลัง - คาดหวังว่าผลลัพธ์จาก
$groupจะเรียงลำดับ —$groupไม่รับประกันลำดับของกลุ่มที่ออกมา ต้องเติม$sortต่อท้ายเองถ้าต้องการลำดับที่แน่นอน
💡 ตัวอย่างจากของจริง
Analytics dashboard — การคำนวณ “ยอดขายรวมต่อวัน” หรือ “จำนวนผู้ใช้ที่ active ต่อสัปดาห์” มักทำด้วย
$groupที่มี_idเป็นวันที่/สัปดาห์ และ accumulator อย่าง$sumแทนที่จะดึง event ดิบทั้งหมดมานับใน application serverKeller Williams — รายงานยอดขายอสังหาริมทรัพย์ต่อนายหน้าใช้
$groupพร้อม$sumและ$avgเพื่อสรุปมูลค่าธุรกรรมและราคาขายเฉลี่ยต่อคน โดยไม่ต้องดึงประวัติธุรกรรมทั้งหมดออกมาคำนวณนอกฐานข้อมูล