พื้นฐาน 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
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 กรองสายข้อมูล - วางไว้ก่อน
หัวข้อที่มีชื่อว่า “$match กรองสายข้อมูล - วางไว้ก่อน”$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"]
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=13422order 13 422 จาก 200 000 รายการตรง และ plan อ่าน index key 13 422 ตัวกับ document 13 422 รายการพอดี
$project ปรับรูปและคำนวณ
หัวข้อที่มีชื่อว่า “$project ปรับรูปและคำนวณ”$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:
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)));const sales = db.collection("sales");await sales.insertMany([ { title: "Dune", genre: "fiction", quantity: 3, price: 12, buyer: "Ada" }, { title: "Foundation", genre: "fiction", quantity: 1, price: 15, buyer: "Linus" }, { title: "Cosmos", genre: "science", quantity: 2, price: 6, buyer: "Ada" },]);const rows = await sales.aggregate([ { $match: { price: { $gte: 10 } } }, { $project: { _id: 0, title: 1, revenue: { $multiply: ["$price", "$quantity"] } } },]).toArray();console.log(rows);if (rows.length !== 2 || rows[0].revenue !== 36) throw new Error("unexpected projection");rows = list(db.sales.aggregate([ {"$match": {"price": {"$gte": 10}}}, {"$project": {"_id": 0, "title": 1, "revenue": {"$multiply": ["$price", "$quantity"]}}},]))pipeline := mongo.Pipeline{ {{Key: "$match", Value: bson.D{{Key: "price", Value: bson.D{{Key: "$gte", Value: 10}}}}}}, {{Key: "$project", Value: bson.D{ {Key: "_id", Value: 0}, {Key: "title", Value: 1}, {Key: "revenue", Value: bson.D{{Key: "$multiply", Value: bson.A{"$price", "$quantity"}}}}, }}},}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! { "$match": { "price": { "$gte": 10 } } }, doc! { "$project": { "_id": 0, "title": 1, "revenue": { "$multiply": ["$price", "$quantity"] }, } }, ]) .await?;[ { title: 'T46', revenue: 50 }, { title: 'T53', revenue: 125 }, { title: 'T60', revenue: 75 }][ { title: 'Dune', revenue: 36 }, { title: 'Foundation', revenue: 15 }]$set, $unset และกฎ include/exclude
หัวข้อที่มีชื่อว่า “$set, $unset และกฎ include/exclude”$project ที่ระบุรายการ field คือ inclusion projection: ทุกอย่างที่ไม่ได้ระบุหายไป $set (alias $addFields) เพิ่มหรือเขียนทับ field และเก็บที่เหลือ; $unset ลบ field ที่ระบุชื่อ $project เดียวผสม inclusion กับ exclusion ไม่ได้ ยกเว้น _id:
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, pricemixing 1 and 0: Location31254 - Invalid $project :: caused by :: Cannot do exclusion on field buyer in inclusion projectionIn Compass: เพิ่ม stage $match แล้ว $project ในแท็บ Aggregations ตัวอย่างสดใต้แต่ละ stage แสดงจำนวนที่ลดลงหลัง $match และ field ที่เปลี่ยนหลัง $project
ทำไมลำดับ stage สำคัญ - และ optimizer แก้อะไรให้
หัวข้อที่มีชื่อว่า “ทำไมลำดับ stage สำคัญ - และ optimizer แก้อะไรให้”เอกสารรุ่นเก่าบอกว่า $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 ที่แต่ละอันได้จริง:
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=13422B $match AFTER $project : PROJECTION_DEFAULT > FETCH > IXSCAN(price_1) keys=13422 docs=13422C $match on a computed field : PROJECTION_DEFAULT > COLLSCAN keys=0 docs=200000 | left in pipeline: $matchD $match after $group (on _id) : GROUP > PROJECTION_COVERED > IXSCAN(price_1) keys=13422 docs=0E $match on a $group total : GROUP > COLLSCAN keys=0 docs=200000 | left in pipeline: $matchB 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 ทำให้เห็นความต่าง:
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 msallowDiskUse: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 แรกรับไปไม่ได้
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอ “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 ตรงกับคำตอบพอดี):
const rev = { $multiply: ["$price", "$quantity"] };const buggy = [{ $project: { title: 1, revenue: rev } }, { $match: { revenue: { $gte: 100 } } }]; // filter on a computed fieldconst 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 itconst 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: $matchcoarse pre-filter on the index : PROJECTION_DEFAULT > FETCH > IXSCAN(price_1) keys=57143 docs=57143 | left in pipeline: $matchstored + indexed revenue : PROJECTION_SIMPLE > FETCH > IXSCAN(revenue_1) keys=14111 docs=14111rows returned by all three: 14111 14111 14111Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”คิดว่า 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 ใหม่ต้องเพิ่มทุกที่ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
$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 ชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส