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

$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

mongosh/grp-seed.js
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

_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"]
document จำนวนมากพับเป็น document ผลลัพธ์หนึ่งรายการต่อ key ของกลุ่มที่ไม่ซ้ำ
mongosh/grp-by-genre.js
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);
[
{ _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 }
]
  • $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 รายการ (เก็บค่าซ้ำ):

mongosh/grp-accumulators.js
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 ผลลัพธ์เปลี่ยน

mongosh/grp-unordered.js
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 T129
returned in sorted order? false

กลุ่มกลับมาในลำดับที่ server เลือก (ที่นี่เป็นการไล่ hash table) และการรันครั้งหลังหรือเวอร์ชันอื่นอาจต่าง ถ้าต้องการลำดับ ให้เพิ่ม $sort หลัง $group

$first คืนค่าจาก document ที่มาถึงกลุ่มก่อน คู่มือบอกว่า “only meaningful when documents are in a defined order” และให้ “add a preceding $sort stage” ($first, ตรวจ 2026-09-30) title ที่ถูกที่สุดต่อ genre สามวิธี:

mongosh/grp-first.js
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 เป็นทางลัดของสองกลุ่มที่พบบ่อยที่สุด:

mongosh/grp-count.js
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 กลุ่ม ชุดข้อมูลทำงานจึงเกินเพดาน:

mongosh/grp-spill.js
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 ms
allowDiskUse:false -> QueryExceededMemoryLimitNoDiskUseAllowed: Exceeded memory limit for $group, but didn't allow external spilling; pass allowDiskUse:true to opt
explain, 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 ให้เล็ก

เมื่อถูกขอ “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) ให้ผลซ้ำได้

$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 / $bottomsort และเลือกใน accumulator เดียวใช้ได้ตั้งแต่ MongoDB 5.2
$push ค่าขนาดใหญ่เก็บทุกอย่างworking set 130 MB ล้นสองครั้ง
  • “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 คืนยอดรวมชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

field _id ของ stage $group กำหนดอะไร
นับจำนวน document ในแต่ละกลุ่มอย่างไร
accumulator ใดเก็บเฉพาะค่าที่ไม่ซ้ำลง array
การตั้ง _id ของ $group เป็น null มีผลอย่างไร
$group ใช้ $first เลือก "title ที่ถูกที่สุดต่อ genre" โดยไม่มี $sort ผิดตรงไหน
$group ถือข้อมูลราว 130 MB ค่าเริ่มต้นของ MongoDB 8 เกิดอะไรขึ้น