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

Facet และ bucket

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

บทนี้เตรียม collection sales ของโมดูล (order 200 000 รายการ { title, genre, quantity, price, buyer } มี index บน price) และ helper aplan() จากบทนำโมดูล จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch

mongosh/fb-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

$facet รับ object ที่ key เป็นชื่อที่คุณตั้ง และ value เป็น sub-pipeline คู่มือบอกว่าแต่ละอัน “passed the exact same set of input documents” ($facet, ตรวจ 2026-09-30) ผลลัพธ์คือ document เดียวที่มี field ต่อหนึ่ง facet ด้านล่าง: order นิยายราคาสูงสุดสองรายการ ยอดรวม และการแยกตามจำนวนเล่ม ทั้งหมดสำหรับ order นิยายที่ราคา 24 ขึ้นไป แท็บ Node.js สร้างหน้าผลลัพธ์พร้อมจำนวนรวมบน collection 100 order ของตัวเอง:

flowchart LR
  In["sales documents"] --> F["$facet"]
  F --> A["topOrders sub-pipeline"]
  F --> B["totalCount sub-pipeline"]
  F --> C["byQuantity sub-pipeline"]
  A --> Out["one document with three fields"]
  B --> Out
  C --> Out
หนึ่ง stage หลาย sub-pipeline บน input เดียวกัน รวมเป็น document ผลลัพธ์เดียว
mongosh/fb-facet.js
const [r] = db.sales.aggregate([
{ $match: { genre: "fiction", price: { $gte: 24 } } },
{ $facet: {
topOrders: [{ $sort: { price: -1, _id: 1 } }, { $limit: 2 }, { $project: { _id: 0, title: 1, price: 1 } }],
totalCount: [{ $count: "orders" }],
byQuantity: [{ $group: { _id: "$quantity", count: { $sum: 1 } } }, { $sort: { _id: 1 } }],
} },
]).toArray();
console.log(r);
assert.strictEqual(r.byQuantity.reduce((n, g) => n + g.count, 0), r.totalCount[0].orders);
{
topOrders: [ { title: 'T60', price: 25 }, { title: 'T95', price: 25 } ],
totalCount: [ { orders: 2684 } ],
byQuantity: [
{ _id: 1, count: 537 },
{ _id: 2, count: 536 },
{ _id: 3, count: 539 },
{ _id: 4, count: 536 },
{ _id: 5, count: 536 }
]
}
{"page":[{"title":"T19","price":24},{"title":"T39","price":24},{"title":"T59","price":24}],"total":[{"n":50}]}

In Compass: $facet มีในแท็บ Aggregations และแต่ละ sub-pipeline สร้างและดูตัวอย่างได้ก่อนรวม

คู่มือ: ถ้า $facet เป็น stage แรกมันทำ COLLSCAN ส่วน $match หรือ $sort ก่อน มัน “can make use of indexes and the $facet will not trigger a COLLSCAN” ส่วน sub-pipeline เองไม่เคยได้ index ของ collection:

mongosh/fb-facet-index.js
const facets = { a: [{ $count: "n" }], b: [{ $group: { _id: "$genre", c: { $sum: 1 } } }] };
console.log("$match before $facet :", aplan([{ $match: { price: { $gte: 24 } } }, { $facet: facets }]));
console.log("$match inside a facet :", aplan([{ $facet: { a: [{ $match: { price: { $gte: 24 } } }, { $count: "n" }] } }]));
console.log("$sort+$limit in facet :", aplan([{ $facet: { top: [{ $sort: { price: 1 } }, { $limit: 5 }] } }]));
console.log("same sort+limit, find :", (() => { const s = db.sales.find().sort({ price: 1 }).limit(5).explain("executionStats").executionStats; return "keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined; })());
$match before $facet : PROJECTION_SIMPLE > FETCH > IXSCAN(price_1) keys=13422 docs=13422 | left in pipeline: $facet
$match inside a facet : PROJECTION_SIMPLE > COLLSCAN keys=0 docs=200000 | left in pipeline: $facet
$sort+$limit in facet : COLLSCAN keys=0 docs=200000 | left in pipeline: $facet
same sort+limit, find : keys=5 docs=5

ผลลัพธ์ $facet ทั้งหมดคือ document เดียว จึงอยู่ใต้ขีดจำกัด BSON (“If it exceeds 16 mebibytes, the aggregation produces an error”; บทเดิมบอกว่าขีดจำกัดเป็นต่อ facet) การคืนทุกอย่างจาก facet เดียวบน order 200 000 รายการ (ราว 20.9 MB) ล้มเหลว และการจำกัด facet แก้ได้:

mongosh/fb-16mb.js
try { db.sales.aggregate([{ $facet: { everything: [{ $match: {} }] } }]).toArray(); }
catch (e) { console.log(e.codeName + ":", e.message.slice(e.message.indexOf("Serializing")).slice(0, 110)); }
const ok = db.sales.aggregate([{ $facet: { everything: [{ $match: {} }, { $limit: 1000 }, { $project: { title: 1 } }], n: [{ $count: "n" }] } }]).toArray()[0];
console.log("bounded facet:", ok.everything.length, "rows +", JSON.stringify(ok.n));
BSONObjectTooLarge: Serializing Document failed :: caused by :: Size 20942912 exceeds maximum 16793600
bounded facet: 1000 rows + [{"n":200000}]

$bucket แบ่ง document เป็นถังตาม boundaries ที่คุณให้: expression groupBy array boundaries แบบเรียงขึ้น default (ไม่บังคับ) สำหรับค่านอกช่วง และ output ของ accumulator _id ของแต่ละ bucket คือขอบล่าง (รวมค่านั้น; ขอบบนไม่รวม) ถ้าไม่มี default ค่านอก boundaries เป็น error:

mongosh/fb-bucket.js
const b = { groupBy: "$price", boundaries: [5, 10, 20], output: { orders: { $sum: 1 }, avgQty: { $avg: "$quantity" } } };
console.log("with default :", JSON.stringify(db.sales.aggregate([{ $bucket: { ...b, default: "other" } }, { $project: { orders: 1, avgQty: { $round: ["$avgQty", 2] } } }]).toArray()));
try { db.sales.aggregate([{ $bucket: b }]).toArray(); } catch (e) { console.log("no default :", e.codeName + " -", e.message.slice(e.message.indexOf("$switch"))); }
const total = db.sales.aggregate([{ $bucket: { ...b, default: "other" } }]).toArray().reduce((n, x) => n + x.orders, 0);
assert.strictEqual(total, 200000);
with default : [{"_id":5,"orders":43721,"avgQty":3},{"_id":10,"orders":99136,"avgQty":3},{"_id":"other","orders":57143,"avgQty":3}]
no default : Location7158303 - $switch could not find a matching branch for an input, and no default was specified.

$bucketAuto เลือก boundaries เพื่อกระจาย document ตามจำนวน bucket ที่ขอ มันคืน min และ max ของแต่ละ bucket ใน _id แต่ขนาดเท่ากันได้เท่าที่ข้อมูลอนุญาต:

mongosh/fb-bucketauto.js
const rows = db.sales.aggregate([{ $bucketAuto: { groupBy: "$price", buckets: 4 } }]).toArray();
console.log(rows.map((r) => "[" + r._id.min + ", " + r._id.max + (r === rows[rows.length - 1] ? "]" : ")") + " " + r.count).join(" "));
const wholeValues = rows.every((r, i) => r.count === db.sales.countDocuments({ price: i === rows.length - 1 ? { $gte: r._id.min, $lte: r._id.max } : { $gte: r._id.min, $lt: r._id.max } }));
console.log("every bucket equals a whole range of price values (no value split across buckets):", wholeValues, "- so sizes cannot be forced to 50 000 each");
assert.ok(wholeValues);
assert.strictEqual(rows.reduce((n, r) => n + r.count, 0), 200000);
[5, 11) 57144 [11, 17) 57143 [17, 23) 57142 [23, 25] 28571
every bucket equals a whole range of price values (no value split across buckets): true - so sizes cannot be forced to 50 000 each

$facet ประหยัด round trip ไม่ใช่งานฝั่ง server: มันรันทุก sub-pipeline บน input Dashboard สามแผงเป็น facet เดียวเทียบกับสามคำขอแยกบนเครื่องเดียวกัน:

mongosh/fb-facet-vs-queries.js
const ms = (f) => { const t = Date.now(); f(); return Date.now() - t; };
const facet = ms(() => db.sales.aggregate([{ $match: { genre: "fiction" } }, { $facet: {
page: [{ $sort: { price: 1, _id: 1 } }, { $limit: 10 }], total: [{ $count: "n" }], byQty: [{ $group: { _id: "$quantity", c: { $sum: 1 } } }] } }]).toArray());
db.sales.createIndex({ genre: 1, price: 1, _id: 1 });
const facetIdx = ms(() => db.sales.aggregate([{ $match: { genre: "fiction" } }, { $facet: {
page: [{ $sort: { price: 1, _id: 1 } }, { $limit: 10 }], total: [{ $count: "n" }], byQty: [{ $group: { _id: "$quantity", c: { $sum: 1 } } }] } }]).toArray());
const separate = ms(() => {
db.sales.find({ genre: "fiction" }).sort({ price: 1, _id: 1 }).limit(10).toArray();
db.sales.countDocuments({ genre: "fiction" });
db.sales.aggregate([{ $match: { genre: "fiction" } }, { $group: { _id: "$quantity", c: { $sum: 1 } } }]).toArray();
});
console.log({ facetOneRequest: facet, facetWithCompoundIndex: facetIdx, threeSeparateRequests: separate });
{
facetOneRequest: 85,
facetWithCompoundIndex: 79,
threeSeparateRequests: 53
}

ในเครื่องเดียว คำขอแยกที่ใช้ index เร็วกว่า (ไม่มี latency เครือข่ายให้ประหยัด) บนเครือข่ายจริง round trip เพิ่มสองครั้งแพงกว่าส่วนต่างนั้น และคำขอเดียวอ่าน snapshot ที่สอดคล้องกันหนึ่งชุด - จึงควรวัดด้วย latency ของคุณเองก่อนเลือก

  • กรอง ก่อน $facet เพื่อให้ทุก facet ใช้ input ที่แคบลงและมี index รองรับร่วมกัน อย่าเขียน $match ซ้ำในแต่ละ facet
  • ผลลัพธ์ facet เป็น document เดียวภายใต้ขีดจำกัด BSON 16 MiB: จำกัด facet ใหญ่ด้วย $limit/$project/$group
  • $sort + $limit ภายใน facet ใช้ index ไม่ได้; รายการแบบแบ่งหน้ามักดีกว่าเป็น find + การนับ
  • boundaries ของ $bucket ต้องเรียงขึ้น; ค่านอกช่วงต้องมี default ไม่งั้น stage ล้มเหลว
  • $bucketAuto ไม่แยกค่าที่เหมือนกัน ขนาดจึงไม่เท่ากันเมื่อค่าซ้ำ

เมื่อถูกขอรายการแบบแบ่งหน้าพร้อมยอดรวม “ในคำขอเดียว” AI ใส่ทุกอย่างใน $facet:

🐛 หา bug ในโค้ดนี้

// AI comment: "one request for the page and the total"
db.sales.aggregate([
{ $facet: {
items: [{ $sort: { price: 1, _id: 1 } }, { $skip: 100 }, { $limit: 10 }],
total: [{ $count: "n" }],
} },
]);
เฉลย

มันคืนแถวถูกต้อง แต่ $facet เป็น stage แรก จึงสแกนทั้ง collection และ sort ในหน่วยความจำ: อ่าน 200 000 document และใช้ 134 ms เทียบกับ 10 document และ 4 ms สำหรับ find ธรรมดาที่ใช้ index { price: 1, _id: 1 } บวก estimatedDocumentCount() fence ยืนยันว่าทั้งสองคืนสิบแถวเดียวกัน ใช้ find (หรือ pipeline $match/$sort/$skip/$limit) สำหรับหน้า และนับแยก

mongosh/fb-bug.js
db.sales.createIndex({ price: 1, _id: 1 }); // the index a paged listing sorted by price would use
const buggy = [{ $facet: { items: [{ $sort: { price: 1, _id: 1 } }, { $skip: 100 }, { $limit: 10 }], total: [{ $count: "n" }] } }]; // "results + total in one request"
const time = (f) => { const t = Date.now(); const r = f(); return [r, Date.now() - t]; };
const [a, aMs] = time(() => db.sales.aggregate(buggy).toArray()[0]);
const [b, bMs] = time(() => ({ items: db.sales.find().sort({ price: 1, _id: 1 }).skip(100).limit(10).toArray(), total: db.sales.estimatedDocumentCount() }));
console.log("$facet, sort inside :", a.total[0].n, "total,", a.items.length, "rows in", aMs, "ms");
console.log("find + estimatedCount:", b.total, "total,", b.items.length, "rows in", bMs, "ms");
const st = db.sales.explain("executionStats").aggregate(buggy);
const c = st.stages ? st.stages[0].$cursor : st;
console.log("the facet read", c.executionStats.totalDocsExamined, "documents; the find read", db.sales.find().sort({ price: 1, _id: 1 }).skip(100).limit(10).explain("executionStats").executionStats.totalDocsExamined);
assert.deepStrictEqual(a.items.map((d) => d._id), b.items.map((d) => d._id));
$facet, sort inside : 200000 total, 10 rows in 134 ms
find + estimatedCount: 200000 total, 10 rows in 4 ms
the facet read 200000 documents; the find read 10

$facet คือการกระจายออก: input ถูกอ่านครั้งเดียวและส่งให้หลาย mini-pipeline อิสระ ซึ่งผลลัพธ์ถูกเย็บเป็น document เดียว ทุกอย่างก่อนหน้ามันใช้ index ของ collection ได้; ภายในมันใช้ไม่ได้ และ document ที่เย็บมีเพดานขนาดตายตัว

ทางเลือกข้อดีต้นทุน
$facetหลายสรุป คำขอเดียว snapshot เดียวผลลัพธ์จำกัด 16 MiB; sub-pipeline ใช้ index ไม่ได้
query แยกแต่ละอันใช้ index ที่ดีที่สุด (53 ms เทียบ 85 ms ในเครื่อง)round trip มากกว่า; ผลลัพธ์จากคนละช่วงเวลา
$bucketboundaries ตรงกับช่วงตามธุรกิจคุณดูแล boundaries เอง; ต้องมี default สำหรับค่านอกช่วง
$bucketAutoไม่ต้องรู้การกระจายขนาดไม่เท่ากัน; boundaries ขยับตามข้อมูล
  • “ขีดจำกัด 16 MiB เป็นต่อ facet” - ใช้กับ document ผลลัพธ์ทั้งหมด
  • “$facet ใช้ index ไม่ได้เลย” - $match ก่อนหน้ามันใช้ได้ (13 422 key); มีแต่ภายในที่ใช้ไม่ได้
  • “$facet เดียวถูกกว่าสาม query” - ประหยัด round trip ไม่ใช่งาน (85 ms เทียบ 53 ms ที่นี่)
  • “$bucket ข้ามค่านอกช่วง” - มันล้มเหลวถ้าไม่มี default
  • “$bucketAuto ให้ bucket เท่ากัน” - 57 144 / 57 143 / 57 142 / 28 571

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

หน้า $facet ในคู่มือระบุทั้งสองขีดจำกัดที่พิสูจน์ที่นี่: “the $facet stage does not make use of indexes if it is the first stage in the pipeline” และ “the final output document is subject to the 16 mebibyte BSON document size limit” ($facet, ตรวจ 2026-09-30) Keller Williams ใช้ pipeline สรุปสไตล์นี้กับข้อมูลรายการอสังหาฯ บน Atlas (mongodb.com/customers/keller-williams)

แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (facet คืนหน้าและยอดรวม) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

sub-pipeline แต่ละอันของ $facet รับอะไรเป็น input
index ช่วย pipeline ที่มี $facet ตรงไหน
$facet: { everything: [ { $match: {} } ] } บน 200 000 order ล้มเหลว ทำไม
$bucket ที่มี boundaries [5, 10, 20] และไม่มี default เจอราคา 25 เกิดอะไรขึ้น
ทำไม bucket ของ $bucketAuto ขนาดไม่เท่ากัน
รายการแบบแบ่งหน้าสร้างด้วย $facet เดียวที่มี $sort/$skip/$limit และ $count มีต้นทุนเท่าไรเมื่อเทียบกับ find + estimatedDocumentCount