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

พื้นฐาน pipeline - $match และ $project

สอง stage ทำงานหลักของ aggregation ก่อนถึงของหรู: $match ทิ้ง document ที่ไม่ต้องการ และ $project ปรับรูป document ที่เก็บไว้ บทเรียนละเอียดที่ซ่อนอยู่คือ ตำแหน่งของ stage เปลี่ยนความเร็วและความถูกต้องของ pipeline - และที่น่าแปลกกว่าคือ server แก้บางลำดับให้คุณอยู่แล้ว

บทนี้เตรียม 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/pb-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

$match เก็บเฉพาะ document ที่ตรงเงื่อนไข ด้วยไวยากรณ์เดียวกับ find ($gt, $in, $and…) เมื่อเป็น stage แรก engine วางแผนเหมือน find จึงใช้ index ข้าม document ที่ไม่ตรงได้ แทนการไหลทั้ง collection ผ่าน stage ถัดไป:

flowchart LR
  In["all sales"] --> M["$match: price >= 24 (uses index)"]
  M --> P["$project: keep title, compute revenue"]
  P --> Out["slim, reshaped documents"]
กรองก่อนเพื่อให้ index ตัดสายข้อมูล แล้วค่อยปรับรูปสิ่งที่เหลือ
mongosh/pb-match.js
const rows = db.sales.aggregate([{ $match: { price: { $gte: 24 } } }, { $limit: 2 }]).toArray();
console.log(rows);
console.log(aplan([{ $match: { price: { $gte: 24 } } }]));
assert.ok(rows.every((d) => d.price >= 24));
[
{
_id: ObjectId('6abcb8d411b1b6194d7740a3'),
title: 'T46',
genre: 'science',
quantity: 2,
price: 25,
buyer: 'b46'
},
{
_id: ObjectId('6abcb8d411b1b6194d7740aa'),
title: 'T53',
genre: 'poetry',
quantity: 5,
price: 25,
buyer: 'b53'
}
]
FETCH > IXSCAN(price_1) keys=13422 docs=13422

order 13 422 จาก 200 000 รายการตรง และ plan อ่าน index key 13 422 ตัวกับ document 13 422 รายการพอดี

$project กำหนดว่า field ไหนออกจาก stage และสร้าง field ใหม่ จาก expression 1 เก็บ field, 0 ตัดทิ้ง, expression คือคำนวณ การอ้างอิง field ใน expression ใช้ prefix $: "$price" คือค่าของ field, "price" คือ string ตามตัวอักษร ที่นี่เราเก็บ title และคำนวณ revenue = price x quantity; แท็บ Node.js รันบน collection สาม order:

mongosh/pb-project.js
const rows = db.sales.aggregate([
{ $match: { price: { $gte: 24 } } },
{ $project: { _id: 0, title: 1, revenue: { $multiply: ["$price", "$quantity"] } } },
{ $limit: 3 },
]).toArray();
console.log(rows);
assert.ok(rows.every((d) => d.revenue >= 24 && !("_id" in d)));
[
{ title: 'T46', revenue: 50 },
{ title: 'T53', revenue: 125 },
{ title: 'T60', revenue: 75 }
]
[
{ title: 'Dune', revenue: 36 },
{ title: 'Foundation', revenue: 15 }
]

$project ที่ระบุรายการ field คือ inclusion projection: ทุกอย่างที่ไม่ได้ระบุหายไป $set (alias $addFields) เพิ่มหรือเขียนทับ field และเก็บที่เหลือ; $unset ลบ field ที่ระบุชื่อ $project เดียวผสม inclusion กับ exclusion ไม่ได้ ยกเว้น _id:

mongosh/pb-set.js
const one = (extra) => db.sales.aggregate([{ $match: { title: "T7" } }, { $limit: 1 }, ...extra]).toArray()[0];
console.log("$project keeps only what you list :", Object.keys(one([{ $project: { title: 1, revenue: { $multiply: ["$price", "$quantity"] } } }])).join(", "));
console.log("$set adds, keeps everything else :", Object.keys(one([{ $set: { revenue: { $multiply: ["$price", "$quantity"] } } }])).join(", "));
console.log("$unset removes named fields :", Object.keys(one([{ $unset: ["buyer", "genre"] }])).join(", "));
try { db.sales.aggregate([{ $project: { title: 1, buyer: 0 } }]).toArray(); }
catch (e) { console.log("mixing 1 and 0:", e.codeName + " -", e.message); }
$project keeps only what you list : _id, title, revenue
$set adds, keeps everything else : _id, title, genre, quantity, price, buyer, revenue
$unset removes named fields : _id, title, quantity, price
mixing 1 and 0: Location31254 - Invalid $project :: caused by :: Cannot do exclusion on field buyer in inclusion projection

In Compass: เพิ่ม stage $match แล้ว $project ในแท็บ Aggregations ตัวอย่างสดใต้แต่ละ stage แสดงจำนวนที่ลดลงหลัง $match และ field ที่เปลี่ยนหลัง $project

เอกสารรุ่นเก่าบอกว่า $match หลัง $project หรือ $group ใช้ index ไม่ได้เลย จริงเพียงครึ่งเดียว optimizer ของ MongoDB เขียน pipeline ใหม่ก่อน: สำหรับ projection ที่ตามด้วย $match มัน “moves any filters in the $match stage that do not require values computed in the projection stage to a new $match stage before the projection” (pipeline optimization, ตรวจ 2026-09-30) ห้าลำดับของคำถามแบบเดียวกัน พร้อม plan ที่แต่ละอันได้จริง:

mongosh/pb-order.js
const rev = { $multiply: ["$price", "$quantity"] };
console.log("A $match first :", aplan([{ $match: { price: { $gte: 24 } } }, { $project: { revenue: rev } }]));
console.log("B $match AFTER $project :", aplan([{ $project: { revenue: rev, price: 1 } }, { $match: { price: { $gte: 24 } } }]));
console.log("C $match on a computed field :", aplan([{ $project: { revenue: rev } }, { $match: { revenue: { $gte: 100 } } }]));
console.log("D $match after $group (on _id) :", aplan([{ $group: { _id: "$price", n: { $sum: 1 } } }, { $match: { _id: { $gte: 24 } } }]));
console.log("E $match on a $group total :", aplan([{ $group: { _id: "$genre", n: { $sum: 1 } } }, { $match: { n: { $gt: 5 } } }]));
const e = db.sales.explain().aggregate([{ $project: { revenue: rev, price: 1 } }, { $match: { price: { $gte: 24 } } }]);
console.log("B optimizedPipeline:", e.queryPlanner.optimizedPipeline, "| parsedQuery:", JSON.stringify(e.queryPlanner.parsedQuery));
A $match first : PROJECTION_DEFAULT > FETCH > IXSCAN(price_1) keys=13422 docs=13422
B $match AFTER $project : PROJECTION_DEFAULT > FETCH > IXSCAN(price_1) keys=13422 docs=13422
C $match on a computed field : PROJECTION_DEFAULT > COLLSCAN keys=0 docs=200000 | left in pipeline: $match
D $match after $group (on _id) : GROUP > PROJECTION_COVERED > IXSCAN(price_1) keys=13422 docs=0
E $match on a $group total : GROUP > COLLSCAN keys=0 docs=200000 | left in pipeline: $match
B optimizedPipeline: true | parsedQuery: {"price":{"$gte":24}}
  • A, B - plan เดียวกัน: filter บน price ที่เก็บไว้ถูกย้ายมาไว้หน้า $project (optimizedPipeline: true, parsedQuery คือ filter ของ price)
  • C - filter ต้องใช้ revenue ซึ่งมีหลัง $project เท่านั้น จึงค้างอยู่ข้างหลัง: COLLSCAN บน 200 000 document และ $match ยังเหลือใน pipeline
  • D - $match บน key ของ $group (_id) ก็ถูกย้ายไปไว้ก่อน $group และ plan ถึงกับ covered (docs=0)
  • E - filter บน accumulator (n) รันได้หลังกลุ่มเกิดแล้วเท่านั้น: COLLSCAN และ $match ค้างอยู่ข้างหลัง

กฎจริงจึงไม่ใช่ “เขียน $match ก่อนเสมอ” (แม้เป็นสไตล์ที่ชัดที่สุด) แต่คือ “filter จะติดอยู่ท้ายก็ต่อเมื่อขึ้นกับ field ที่คำนวณหรือค่ารวม”

หน่วยความจำ: 100 MB ต่อ stage และใช้ดิสก์เป็นค่าเริ่มต้น

หัวข้อที่มีชื่อว่า “หน่วยความจำ: 100 MB ต่อ stage และใช้ดิสก์เป็นค่าเริ่มต้น”

stage แบบ blocking ($sort, $group) เก็บชุดข้อมูลทำงานในหน่วยความจำได้ถึง 100 MB ตั้งแต่ MongoDB 6.0 allowDiskUseByDefault เป็น true stage ที่เกินเพดานจึงล้นลงดิสก์แทนที่จะล้มเหลว ส่ง { allowDiskUse: false } เพื่อได้พฤติกรรมแบบเดิม collection ขนาด 130 MB ทำให้เห็นความต่าง:

mongosh/pb-memory.js
console.log(db.adminCommand({ getParameter: 1, allowDiskUseByDefault: 1, internalQueryMaxBlockingSortMemoryUsageBytes: 1, internalDocumentSourceGroupMaxMemoryBytes: 1 }));
for (let s = 0; s < 130000; s += 10000) db.big.insertMany(Array.from({ length: 10000 }, (_, k) => ({ n: s + k, r: Math.random(), pad: "x".repeat(1000) })), { ordered: false });
console.log("big collection MB:", +(db.big.stats().size / 1048576).toFixed(0), "(over the 100 MB per-stage limit)");
let t = Date.now();
console.log("default ->", db.big.aggregate([{ $sort: { r: 1 } }, { $project: { n: 1 } }]).toArray().length, "rows in", Date.now() - t, "ms");
try { db.big.aggregate([{ $sort: { r: 1 } }, { $project: { n: 1 } }], { allowDiskUse: false }).toArray(); }
catch (e) { console.log("allowDiskUse:false ->", e.codeName + ":", e.message.slice(0, 130)); }
{
allowDiskUseByDefault: true,
internalDocumentSourceGroupMaxMemoryBytes: Long('104857600'),
internalQueryMaxBlockingSortMemoryUsageBytes: Long('104857600'),
ok: 1,
'$clusterTime': {
clusterTime: Timestamp({ t: 1790752983, i: 53993 }),
signature: {
hash: Binary.createFromBase64('AAAAAAAAAAAAAAAAAAAAAAAAAAA=', 0),
keyId: Long('0')
}
},
operationTime: Timestamp({ t: 1790752983, i: 53993 })
}
big collection MB: 130 (over the 100 MB per-stage limit)
default -> 130000 rows in 444 ms
allowDiskUse:false -> QueryExceededMemoryLimitNoDiskUseAllowed: Executor error during aggregate command on namespace: b2_bbc59d.big :: caused by :: Sort exceeded memory limit of 104857600 bytes,
  • $match แต่เนิ่น ๆ บน field ที่เก็บไว้ใช้ index ได้; optimizer ยกมันข้าม $project/$set และข้าม $group บน key ได้ แต่ไม่ข้าม field ที่คำนวณหรือ accumulator
  • ผสม inclusion (1) กับ exclusion (0) ใน $project เดียวเป็น error ยกเว้น _id
  • "$price" คือค่าของ field, "price" คือ string ตามตัวอักษร
  • ใช้ explain (aplan ข้างบน) ดูว่าอะไรถูกยก: left in pipeline แสดงสิ่งที่ stage แรกรับไปไม่ได้

เมื่อถูกขอ “order ที่มูลค่าอย่างน้อย 100 ด้วย pipeline ที่ใช้ index ได้” AI เขียนว่า:

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

// AI comment: "price is indexed, and $match is the only filter - the index handles it"
db.sales.aggregate([
{ $project: { title: 1, revenue: { $multiply: ["$price", "$quantity"] } } },
{ $match: { revenue: { $gte: 100 } } },
]);
เฉลย

revenue มีหลัง $project เท่านั้น optimizer จึงยก filter ไม่ได้: plan C ข้างบนสแกนทั้ง 200 000 document และไม่มี index บน price ใดตอบการทดสอบ price x quantity ได้ ทางแก้สองแบบ วัดผลด้านล่างทั้งคู่: เพิ่ม pre-filter หยาบที่ index รับได้ (revenue >= 100 แปลว่า price >= 20 เพราะ quantity <= 5 ลดการอ่านเหลือ 57 143 document) หรือเก็บ revenue ไว้ใน document ทำ index แล้ว match บน field ที่เก็บ (14 111 key ตรงกับคำตอบพอดี):

mongosh/pb-bug.js
const rev = { $multiply: ["$price", "$quantity"] };
const buggy = [{ $project: { title: 1, revenue: rev } }, { $match: { revenue: { $gte: 100 } } }]; // filter on a computed field
const coarse = [{ $match: { price: { $gte: 20 } } }, ...buggy]; // revenue >= 100 implies price >= 20 (quantity <= 5)
console.log("buggy (filter on a computed field):", aplan(buggy));
console.log("coarse pre-filter on the index :", aplan(coarse));
db.sales.updateMany({}, [{ $set: { revenue: rev } }]); // store the value once ...
db.sales.createIndex({ revenue: 1 }); // ... and index it
const fixed = [{ $match: { revenue: { $gte: 100 } } }, { $project: { title: 1, revenue: 1 } }];
console.log("stored + indexed revenue :", aplan(fixed));
const n = (p) => db.sales.aggregate(p).toArray().length;
console.log("rows returned by all three:", n(buggy), n(coarse), n(fixed));
assert.ok(n(buggy) === n(coarse) && n(coarse) === n(fixed));
buggy (filter on a computed field): PROJECTION_DEFAULT > COLLSCAN keys=0 docs=200000 | left in pipeline: $match
coarse pre-filter on the index : PROJECTION_DEFAULT > FETCH > IXSCAN(price_1) keys=57143 docs=57143 | left in pipeline: $match
stored + indexed revenue : PROJECTION_SIMPLE > FETCH > IXSCAN(revenue_1) keys=14111 docs=14111
rows returned by all three: 14111 14111 14111

คิดว่า optimizer เป็นเพื่อนร่วมงานที่ย้าย filter ของคุณไปก่อนเมื่อพิสูจน์ได้ว่า filter ไม่ต้องใช้สิ่งที่เกิดระหว่างทาง ส่วนที่เหลือคุณควบคุม: filter ที่ขึ้นกับค่าที่คำนวณหรือยอดกลุ่มต้องอยู่ที่ที่ค่ามีอยู่ ดังนั้นยอมรับการสแกน หรือ pre-filter บนสิ่งที่เก็บไว้ หรือเก็บค่านั้น

ทางเลือกข้อดีต้นทุน
$match ก่อนบน field ที่เก็บไว้ใช้ index: 13 422 key ไม่ใช่ 200 000 documentเงื่อนไขต้องเป็น field ที่เก็บไว้
$match บน field ที่คำนวณแสดงคำถามจริงตรง ๆCOLLSCAN 200 000 document
เก็บและทำ index ค่าที่คำนวณ14 111 key; ตรงพอดีต้องดูแลตอนเขียน และมี index เพิ่ม
$project ระบุ field ชัดเจนรูปผลลัพธ์แม่นยำfield ใหม่ต้องเพิ่มทุกที่
  • “$match หลัง $project ใช้ index ไม่ได้” - plan B ใช้ price_1 (13 422 key)
  • “$match หลัง $group ช้าเสมอ” - filter บน key ของกลุ่มถูกยกไปก่อน (plan D, covered)
  • “ลำดับ stage จึงไม่สำคัญ” - filter บน revenue หรือ n ค้างท้ายและสแกน 200 000 document
  • “$sort ใหญ่ล้มเหลวที่ 100 MB” - มันล้นลงดิสก์และเสร็จ (130 000 แถวใน 449 ms) เว้นแต่ allowDiskUse: false

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

คู่มือ MongoDB บันทึกพฤติกรรมของ optimizer ที่วัดที่นี่ไว้เป็นกฎที่มีชื่อ (“$match … moves any filters … that do not require values computed in the projection stage”) พร้อมกรณีย้อนกลับที่ค่าที่คำนวณตรึง filter ไว้กับที่ (pipeline optimization, ตรวจ 2026-09-30) Keller Williams ใช้ pipeline สไตล์นี้กับข้อมูลรายการอสังหาฯ บน Atlas (mongodb.com/customers/keller-williams)

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

ทำไมจึงควรวาง $match เป็น stage แรกของ pipeline
ใน stage $project "$price" หมายถึงอะไร
รูปแบบ $project ใดใช้ใน stage เดียวไม่ได้
pipeline คือ [ $project (เพิ่ม revenue เก็บ price), แล้ว $match { price >= 24 } ] $match ยังใช้ index ของ price หรือไม่
แก้ $match ที่ช้าบนค่าที่คำนวณอย่าง price x quantity อย่างไร
$sort ขนาด 130 MB รันด้วยค่าเริ่มต้นบน MongoDB 8 เกิดอะไรขึ้น