$group และ accumulator
$group คือที่ที่ aggregation ได้ชื่อของมัน มันยุบ document จำนวนมากเป็นหนึ่ง document ต่อค่าที่ไม่ซ้ำ ของ key ที่คุณเลือก และคำนวณสรุปของแต่ละกลุ่มด้วย accumulator นี่คือ stage ที่ตอบ “แต่ละ genre มีกี่รายการ”, “ราคาเฉลี่ยต่อจำนวนเล่ม”, “title ที่ถูกที่สุดต่อ genre” ถ้าคุณเคยเขียน GROUP BY ใน SQL สัญชาตญาณเหมือนกัน เพียงสะกดต่างกัน
บทนี้เตรียม collection sales ของโมดูล (order 200 000 รายการ { title, genre, quantity, price, buyer }) และ helper aplan() จากบทนำโมดูล output จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch
const genres = ["fiction", "science", "history", "poetry", "comics"];globalThis.aplan = (pipeline) => { // one line per aggregate explain: what the first stage read, then the stages left in the pipeline const e = db.sales.explain("executionStats").aggregate(pipeline); const cursor = e.stages ? e.stages[0].$cursor : e, rest = e.stages ? e.stages.slice(1).map((s) => Object.keys(s)[0]) : []; const chain = (p) => { const out = []; for (p = p.queryPlan ?? p; p; p = p.inputStage) out.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : "")); return out.join(" > "); }; const s = cursor.executionStats; return chain(cursor.queryPlanner.winningPlan) + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined + (rest.length ? " | left in pipeline: " + rest.join(" > ") : "");};for (let s = 0; s < 200000; s += 50000) { db.sales.insertMany(Array.from({ length: 50000 }, (_, k) => { const i = s + k; // 500 titles, 5 genres, quantity 1..5, price 5..25, 1000 buyers (pseudo-random but repeatable) return { title: "T" + (i % 500), genre: genres[(i % 500) % 5], quantity: 1 + ((Math.imul(i, 2654435761) >>> 0) % 5), price: 5 + ((Math.imul(i, 40503) >>> 0) % 21), buyer: "b" + (i % 1000) }; }), { ordered: false });}db.sales.createIndex({ price: 1 });console.log("sales:", db.sales.countDocuments(), "orders");sales: 200000 ordersจัดกลุ่มตาม key
หัวข้อที่มีชื่อว่า “จัดกลุ่มตาม key”_id ของ stage $group คือ key ที่ใช้จัดกลุ่ม - field หรือค่าที่คำนวณซึ่ง document ถูกแบ่งตามนั้น field อื่นทุกตัวเป็น accumulator: expression ที่พับทุก document ของกลุ่มเป็นค่าเดียว ที่นี่เรารวมจำนวนเล่มต่อ genre และนับ order ($sort ท้ายมีไว้ให้ลำดับที่พิมพ์คงที่ - ดูต่อด้านล่าง) แท็บ Node.js รันบน collection สาม order:
flowchart LR In["sales documents"] --> G["$group _id genre"] G --> A["accumulators: $sum quantity, $sum 1"] A --> Out["one document per genre"]
const rows = db.sales.aggregate([ { $group: { _id: "$genre", totalCopies: { $sum: "$quantity" }, orders: { $sum: 1 } } }, { $sort: { _id: 1 } },]).toArray();console.log(rows);assert.strictEqual(rows.reduce((n, r) => n + r.orders, 0), 200000);const sales = db.collection("sales");await sales.insertMany([ { title: "Dune", genre: "fiction", quantity: 3, price: 12, buyer: "Ada" }, { title: "Dune", genre: "fiction", quantity: 4, price: 12, buyer: "Linus" }, { title: "Cosmos", genre: "science", quantity: 5, price: 20, buyer: "Ada" },]);const rows = await sales.aggregate([ { $group: { _id: "$genre", totalCopies: { $sum: "$quantity" }, orders: { $sum: 1 } } }, { $sort: { _id: 1 } },]).toArray();console.log(rows);if (rows[0]._id !== "fiction" || rows[0].totalCopies !== 7 || rows[0].orders !== 2) throw new Error("unexpected group");rows = list(db.sales.aggregate([ {"$group": { "_id": "$genre", "totalCopies": {"$sum": "$quantity"}, "orders": {"$sum": 1}, }}, {"$sort": {"_id": 1}},]))pipeline := mongo.Pipeline{ {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$genre"}, {Key: "totalCopies", Value: bson.D{{Key: "$sum", Value: "$quantity"}}}, {Key: "orders", Value: bson.D{{Key: "$sum", Value: 1}}}, }}}, {{Key: "$sort", Value: bson.D{{Key: "_id", Value: 1}}}},}cur, err := coll.Aggregate(ctx, pipeline)if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let mut cursor = coll .aggregate(vec![ doc! { "$group": { "_id": "$genre", "totalCopies": { "$sum": "$quantity" }, "orders": { "$sum": 1 }, } }, doc! { "$sort": { "_id": 1 } }, ]) .await?;[ { _id: 'comics', totalCopies: 120002, orders: 40000 }, { _id: 'fiction', totalCopies: 120000, orders: 40000 }, { _id: 'history', totalCopies: 119995, orders: 40000 }, { _id: 'poetry', totalCopies: 120001, orders: 40000 }, { _id: 'science', totalCopies: 119995, orders: 40000 }][ { _id: 'fiction', totalCopies: 7, orders: 2 }, { _id: 'science', totalCopies: 5, orders: 1 }]accumulator ที่ใช้บ่อย
หัวข้อที่มีชื่อว่า “accumulator ที่ใช้บ่อย”$sum- รวม expression ตัวเลข หรือนับด้วย$sum: 1$avg- ค่าเฉลี่ย$min/$max- ค่าน้อยสุดและมากสุด$push- ทุกค่าลง array รวมค่าซ้ำ$addToSet- ค่าที่ ไม่ซ้ำ ลง array$first/$last- ค่าจาก document แรก/สุดท้าย ตามลำดับปัจจุบัน$top/$bottom(5.2+) - ค่าจาก document บน/ล่างของsortByที่ระบุชัด
จัดกลุ่มตาม quantity (1-5) และดูโปรไฟล์ของแต่ละกลุ่ม $push และ $addToSet ถูกแปลงเป็นขนาดเพื่อให้ output พอดี: ผู้ซื้อที่ไม่ซ้ำ 1 000 คน แต่ push ราว 40 000 รายการ (เก็บค่าซ้ำ):
const rows = db.sales.aggregate([ { $group: { _id: "$quantity", avgPrice: { $avg: "$price" }, cheapest: { $min: "$price" }, priciest: { $max: "$price" }, buyers: { $addToSet: "$buyer" }, allBuyers: { $push: "$buyer" } } }, { $project: { avgPrice: { $round: ["$avgPrice", 2] }, cheapest: 1, priciest: 1, distinctBuyers: { $size: "$buyers" }, pushedBuyers: { $size: "$allBuyers" } } }, { $sort: { _id: 1 } },]).toArray();console.log(rows.map((r) => JSON.stringify(r)).join("\n"));assert.strictEqual(rows.length, 5);assert.ok(rows.every((r) => r.distinctBuyers <= 1000 && r.pushedBuyers > r.distinctBuyers)); // $push keeps duplicates, $addToSet does not{"_id":1,"cheapest":5,"priciest":25,"avgPrice":14.94,"distinctBuyers":1000,"pushedBuyers":40001}{"_id":2,"cheapest":5,"priciest":25,"avgPrice":14.94,"distinctBuyers":1000,"pushedBuyers":39999}{"_id":3,"cheapest":5,"priciest":25,"avgPrice":14.94,"distinctBuyers":1000,"pushedBuyers":40002}{"_id":4,"cheapest":5,"priciest":25,"avgPrice":14.94,"distinctBuyers":1000,"pushedBuyers":40002}{"_id":5,"cheapest":5,"priciest":25,"avgPrice":14.94,"distinctBuyers":1000,"pushedBuyers":39996}In Compass: stage $group ในแท็บ Aggregations ให้โครงสร้าง _id และ accumulator เดียวกัน ลองเปลี่ยน expression ของ _id แล้วดูจำนวน document ผลลัพธ์เปลี่ยน
output ของ group ไม่เรียงลำดับ
หัวข้อที่มีชื่อว่า “output ของ group ไม่เรียงลำดับ”const ids = db.sales.aggregate([{ $group: { _id: "$title", n: { $sum: 1 } } }]).toArray().map((r) => r._id);console.log("first ten groups, exactly as returned:", ids.slice(0, 10).join(" "));const sorted = [...ids].sort();console.log("returned in sorted order?", ids.every((v, i) => v === sorted[i]));first ten groups, exactly as returned: T455 T50 T153 T381 T35 T164 T335 T372 T416 T129returned in sorted order? falseกลุ่มกลับมาในลำดับที่ server เลือก (ที่นี่เป็นการไล่ hash table) และการรันครั้งหลังหรือเวอร์ชันอื่นอาจต่าง ถ้าต้องการลำดับ ให้เพิ่ม $sort หลัง $group
$first ต้องมี $sort
หัวข้อที่มีชื่อว่า “$first ต้องมี $sort”$first คืนค่าจาก document ที่มาถึงกลุ่มก่อน คู่มือบอกว่า “only meaningful when documents are in a defined order” และให้ “add a preceding $sort stage” ($first, ตรวจ 2026-09-30) title ที่ถูกที่สุดต่อ genre สามวิธี:
const cheapestFirst = db.sales.aggregate([ { $sort: { price: 1, _id: 1 } }, { $group: { _id: "$genre", cheapestTitle: { $first: "$title" }, price: { $first: "$price" } } }, { $sort: { _id: 1 } },]).toArray();const noSort = db.sales.aggregate([ { $group: { _id: "$genre", firstTitleSeen: { $first: "$title" }, price: { $first: "$price" } } }, { $sort: { _id: 1 } },]).toArray();const top = db.sales.aggregate([ { $group: { _id: "$genre", cheapest: { $bottom: { sortBy: { price: -1, _id: 1 }, output: ["$title", "$price"] } } } }, // $bottom of a descending sort = smallest price { $sort: { _id: 1 } },]).toArray();console.log("$sort then $first :", cheapestFirst.map((r) => r._id + " " + r.cheapestTitle + " $" + r.price).join(" | "));console.log("$first, no sort :", noSort.map((r) => r._id + " " + r.firstTitleSeen + " $" + r.price).join(" | "));console.log("$bottom (5.2+) :", top.map((r) => r._id + " " + r.cheapest[0] + " $" + r.cheapest[1]).join(" | "));assert.ok(cheapestFirst.every((r) => r.price === 5));$sort then $first : comics T14 $5 | fiction T0 $5 | history T7 $5 | poetry T28 $5 | science T21 $5$first, no sort : comics T4 $23 | fiction T0 $5 | history T2 $14 | poetry T3 $8 | science T1 $20$bottom (5.2+) : comics T29 $5 | fiction T15 $5 | history T22 $5 | poetry T8 $5 | science T36 $5การนับและยอดรวมทั้งหมด
หัวข้อที่มีชื่อว่า “การนับและยอดรวมทั้งหมด”ตั้ง _id เป็น null ทุก document ตกในกลุ่มเดียว $count และ $sortByCount เป็นทางลัดของสองกลุ่มที่พบบ่อยที่สุด:
console.log("grand total :", db.sales.aggregate([{ $group: { _id: null, revenue: { $sum: { $multiply: ["$price", "$quantity"] } }, orders: { $sum: 1 } } }]).toArray());console.log("$count stage :", db.sales.aggregate([{ $match: { genre: "poetry" } }, { $count: "poetryOrders" }]).toArray());console.log("$sortByCount :", db.sales.aggregate([{ $sortByCount: "$genre" }]).toArray().slice(0, 2), "...");console.log("empty input :", db.sales.aggregate([{ $match: { genre: "none" } }, { $group: { _id: null, n: { $sum: 1 } } }]).toArray(), "<- no group at all, not n: 0");grand total : [ { _id: null, revenue: 8963632, orders: 200000 } ]$count stage : [ { poetryOrders: 40000 } ]$sortByCount : [ { _id: 'poetry', count: 40000 }, { _id: 'history', count: 40000 } ] ...empty input : [] <- no group at all, not n: 0หน่วยความจำ: เมื่อกลุ่มไม่พอดี
หัวข้อที่มีชื่อว่า “หน่วยความจำ: เมื่อกลุ่มไม่พอดี”$group เก็บกลุ่มในหน่วยความจำ (100 MB เป็นค่าเริ่มต้น, internalDocumentSourceGroupMaxMemoryBytes) การรันนี้ push ทุก pad ขนาด 1 KB ของ collection 130 MB ลง 1 000 กลุ่ม ชุดข้อมูลทำงานจึงเกินเพดาน:
for (let s = 0; s < 130000; s += 10000) db.big.insertMany(Array.from({ length: 10000 }, (_, k) => ({ n: s + k, pad: "x".repeat(1000) })), { ordered: false });const p = [{ $group: { _id: { $mod: ["$n", 1000] }, pads: { $push: "$pad" }, docs: { $sum: 1 } } }, { $count: "groups" }];console.log("collection MB:", +(db.big.stats().size / 1048576).toFixed(0), "| $group holds every pad in memory (~130 MB > 100 MB limit)");let t = Date.now();console.log("default ->", db.big.aggregate(p).toArray(), Date.now() - t, "ms");try { db.big.aggregate(p, { allowDiskUse: false }).toArray(); }catch (e) { console.log("allowDiskUse:false ->", e.codeName + ":", e.message.slice(e.message.indexOf("Exceeded"), e.message.indexOf("Exceeded") + 100)); }const e = db.big.explain("executionStats").aggregate(p.slice(0, 1));const find = (s) => (s.stage === "group" ? s : s.inputStage ? find(s.inputStage) : null);const g = find((e.stages ? e.stages[0].$cursor : e).executionStats.executionStages);console.log("explain, group stage:", { usedDisk: g.usedDisk, spills: g.spills, spilledBytesMB: Math.round(g.spilledBytes / 1048576), peakMB: +(g.peakTrackedMemBytes / 1048576).toFixed(0) });collection MB: 129 | $group holds every pad in memory (~130 MB > 100 MB limit)default -> [ { groups: 1000 } ] 192 msallowDiskUse:false -> QueryExceededMemoryLimitNoDiskUseAllowed: Exceeded memory limit for $group, but didn't allow external spilling; pass allowDiskUse:true to optexplain, group stage: { usedDisk: true, spills: 2, spilledBytesMB: 125, peakMB: 101 }เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- หลัง
$groupมีแค่_idและ accumulator; ส่ง field อื่นไปข้างหน้าด้วย$first,$last,$pushหรือ$top $sum: 1นับแถว;$sum: "$quantity"รวมค่า input ว่างไม่สร้างกลุ่มเลย$pushเก็บค่าซ้ำและลำดับ,$addToSetตัดค่าซ้ำและไม่รับประกันลำดับ- การจัดกลุ่มไม่ sort; เพิ่ม
$sort(พร้อม tie-breaker ที่ไม่ซ้ำ) เมื่อลำดับสำคัญ - กลุ่มที่โตเกิน 100 MB ล้นลงดิสก์เป็นค่าเริ่มต้น อย่าพึ่งกับเส้นทางที่ร้อน -
$matchก่อนและเก็บ accumulator ให้เล็ก
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอ “title ที่ถูกที่สุดในแต่ละ genre” AI เขียนว่า:
🐛 หา bug ในโค้ดนี้
// AI comment: "$first takes the cheapest title per genre"db.sales.aggregate([{ $group: { _id: "$genre", cheapestTitle: { $first: "$title" }, price: { $first: "$price" } } },]);เฉลย
ไม่มี $sort $first จึงเป็นแค่ “order แรกที่ server บังเอิญอ่าน” ในการรันข้างบน เวอร์ชันที่ไม่ sort รายงาน comics ที่ $23, history ที่ $14 และ science ที่ $20 ทั้งที่ทุก genre มี order ราคา $5 การ sort ก่อน group แก้ได้ และ $bottom (หรือ $top) บอกความหมายในตัว accumulator ทั้งสองวิธีคืน $5 ทั้งห้า genre ตามที่บล็อก first ข้างบนแสดง โปรดสังเกตว่าคำตอบที่ถูกสองแบบอาจระบุ title ต่างกันเพราะหลาย title ราคา $5 เท่ากัน - เพิ่ม tie-breaker (_id) ให้ผลซ้ำได้
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”$group คือ hash map จาก key ไปยังสถานะของ accumulator ที่ถูกป้อนด้วยสายข้อมูล ผลที่ตามมาสองข้อ: map ไม่มีลำดับ (จึงไม่มี output เรียง) และสถานะอยู่ในหน่วยความจำ (ขนาดของมัน ไม่ใช่ขนาด input ที่ตัดสินว่าจะล้นหรือไม่) accumulator ที่ขึ้นกับลำดับที่มา ($first, $last, $push) แน่นอนได้เท่าลำดับที่คุณกำหนดให้
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
$group ใน pipeline | คืนเฉพาะผลที่ยุบแล้ว: 5 แถวแทน 200 000 | จำกัดอยู่ที่ expression ของ MongoDB; สถานะต้องพอดี 100 MB หรือล้นลงดิสก์ |
| จัดกลุ่มในโค้ดแอป | ใช้ logic ใดก็ได้ | ต้องย้ายทุก document ดิบก่อน |
$sort + $first | เลือกได้แน่นอน | ต้อง sort ทั้งสายก่อน |
$top / $bottom | sort และเลือกใน accumulator เดียว | ใช้ได้ตั้งแต่ MongoDB 5.2 |
$push ค่าขนาดใหญ่ | เก็บทุกอย่าง | working set 130 MB ล้นสองครั้ง |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “field ต้นฉบับรอดจาก
$group” - รอดแค่_idและ accumulator ของคุณ - “
$groupคืนกลุ่มเรียงตาม key” - สิบ title แรกกลับมาเป็น T455 T50 T153 … และไม่เรียงลำดับ - “
$firstคือค่าที่น้อยที่สุด” - มันคือค่าของ document แรก; ที่ไม่ sort ได้ $23 ทั้งที่มี $5 - “
$groupที่เกินหน่วยความจำ error” - มันล้นลงดิสก์ (2 spills) เว้นแต่allowDiskUse: false - “match ว่างได้
n: 0” -$groupที่_id: nullบน document ศูนย์รายการคืน[]
ตัวอย่างจากของจริง
Keller Williams (real estate) - ทีมข้อมูลใช้ Aggregation Pipeline Builder ใน MongoDB Compass สรุปเรคคอร์ดรายการอสังหาฯ กว่า 70 ล้านรายการบน MongoDB Atlas ด้วย pipeline สไตล์
$groupแทนการ export เรคคอร์ดดิบ (mongodb.com/customers/keller-williams).
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (group ต่อ genre คืนยอดรวมชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส