Unwind array
array อยู่ทุกที่ใน document ของ MongoDB - tag ของโพสต์ รายการสินค้าใน order บทเรียนในคอร์ส เพื่อ สรุป ข้ามสมาชิกของมัน คุณมักต้องดูทีละรายการ $unwind ทำสิ่งนั้น: รับ document ที่มี field แบบ array และส่ง หนึ่ง document ต่อหนึ่งสมาชิก โดยคัดลอกส่วนที่เหลือของ document ไปทุกอัน
บทนี้ใช้ collection ของตัวเองคือ orders แทน sales: order 50 000 รายการ { buyer, items: [ { title, qty } ] } ที่มีรายการสินค้า 1-5 ชิ้น (รวม 149 862) บวกกรณีขอบที่ทำมือสี่อันที่มี _id เป็น empty, missing, null และ scalar จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch
const titles = Array.from({ length: 50 }, (_, i) => "T" + i);const rnd = (() => { let x = 42; return () => (x = (Math.imul(x, 1103515245) + 12345) >>> 0) / 4294967296; })(); // repeatable pseudo-random numbersdb.orders.insertMany(Array.from({ length: 50000 }, (_, i) => ({ buyer: "b" + (i % 1000), items: Array.from({ length: 1 + Math.floor(rnd() * 5) }, () => ({ title: titles[Math.floor(rnd() * 50)], qty: 1 + Math.floor(rnd() * 3) })),})), { ordered: false });db.orders.insertMany([ // the awkward shapes, one of each { _id: "empty", buyer: "edge", items: [] }, { _id: "missing", buyer: "edge" }, { _id: "null", buyer: "edge", items: null }, { _id: "scalar", buyer: "edge", items: { title: "Solo", qty: 1 } },]);console.log("orders:", db.orders.countDocuments(), "| line items in the array orders:", db.orders.aggregate([{ $match: { buyer: { $ne: "edge" } } }, { $group: { _id: null, n: { $sum: { $size: "$items" } } } }]).toArray()[0].n);orders: 50004 | line items in the array orders: 149862หนึ่ง document ต่อสมาชิก array
หัวข้อที่มีชื่อว่า “หนึ่ง document ต่อสมาชิก array”ให้ $unwind ระบุ path ไปยัง array (พร้อม prefix $) และมันจะกระจาย document ออก ทุก document ที่ส่งออกเหมือนกันหมด ยกเว้น field array ถูกแทนด้วยสมาชิกหนึ่งตัว order ที่มีสองรายการสินค้ากลายเป็นสอง document และ order แบบ array 50 000 รายการกลายเป็น 149 862 (หนึ่งต่อรายการสินค้า) แท็บ Node.js ต่อด้วยคู่คลาสสิก: unwind แล้ว $group รวมจำนวนต่อ title:
flowchart LR In["one order with items array of two"] --> U["$unwind items"] U --> D1["order, items = Dune"] U --> D2["order, items = Foundation"]
db.small.insertOne({ _id: "o1", buyer: "Ada", items: [{ title: "Dune", qty: 2 }, { title: "Foundation", qty: 1 }] });console.log(db.small.aggregate([{ $unwind: "$items" }]).toArray());console.log("50 000 array orders become", db.orders.aggregate([{ $match: { buyer: { $ne: "edge" } } }, { $unwind: "$items" }, { $count: "n" }]).toArray()[0].n, "documents");assert.strictEqual(db.small.aggregate([{ $unwind: "$items" }]).toArray().length, 2);const orders = db.collection("orders");await orders.insertMany([ { _id: "o1", buyer: "Ada", items: [{ title: "Dune", qty: 2 }, { title: "Foundation", qty: 1 }] }, { _id: "o2", buyer: "Grace", items: [{ title: "Dune", qty: 3 }] },]);const rows = await orders.aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.title", sold: { $sum: "$items.qty" } } }, { $sort: { sold: -1 } },]).toArray();console.log(rows);if (rows[0]._id !== "Dune" || rows[0].sold !== 5) throw new Error("unexpected totals");rows = list(db.orders.aggregate([ {"$unwind": "$items"}, {"$group": {"_id": "$items.title", "sold": {"$sum": "$items.qty"}}}, {"$sort": {"sold": -1}},]))pipeline := mongo.Pipeline{ {{Key: "$unwind", Value: "$items"}}, {{Key: "$group", Value: bson.D{ {Key: "_id", Value: "$items.title"}, {Key: "sold", Value: bson.D{{Key: "$sum", Value: "$items.qty"}}}, }}}, {{Key: "$sort", Value: bson.D{{Key: "sold", 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! { "$unwind": "$items" }, doc! { "$group": { "_id": "$items.title", "sold": { "$sum": "$items.qty" } } }, doc! { "$sort": { "sold": -1 } }, ]) .await?;[ { _id: 'o1', buyer: 'Ada', items: { title: 'Dune', qty: 2 } }, { _id: 'o1', buyer: 'Ada', items: { title: 'Foundation', qty: 1 } }]50 000 array orders become 149862 documents[ { _id: 'Dune', sold: 5 }, { _id: 'Foundation', sold: 1 } ]$unwind แทบไม่เคยเป็นคำสุดท้าย: หน้าที่ปกติคือคลี่ให้ $group ถัดไปสรุปข้ามทุกสมาชิกได้ บนชุดข้อมูลเต็ม สามอันดับ title ที่ขายดีที่สุด:
const rows = db.orders.aggregate([ { $unwind: "$items" }, { $group: { _id: "$items.title", sold: { $sum: "$items.qty" } } }, { $sort: { sold: -1, _id: 1 } }, { $limit: 3 },]).toArray();console.log(rows);assert.strictEqual(rows.length, 3);[ { _id: 'T41', sold: 6271 }, { _id: 'T18', sold: 6215 }, { _id: 'T19', sold: 6179 }]In Compass: เพิ่ม stage $unwind ในแท็บ Aggregations แล้วชี้ไปที่ field array ตัวอย่างแสดงจำนวน document เพิ่มขึ้น - ตรงข้ามกับ $match และ $group
ว่าง หาย null และ scalar
หัวข้อที่มีชื่อว่า “ว่าง หาย null และ scalar”กฎของคู่มือ: ถ้า path หายหรือเป็น array ว่าง $unwind เพิกเฉยต่อ document และถ้าค่าไม่ใช่ array แต่ไม่ใช่ missing, null หรือว่าง $unwind “outputs a single document using the value as-is” ($unwind, ตรวจ 2026-09-30) ทั้งสี่ document กรณีขอบ ทั้งสองแบบ:
const ids = ["empty", "missing", "null", "scalar"];const run = (unwind) => Object.fromEntries(ids.map((id) => [id, db.orders.aggregate([{ $match: { _id: id } }, { $unwind: unwind }]).toArray().length]));console.log("default :", run("$items"));console.log("preserveNullAndEmptyArrays: true:", run({ path: "$items", preserveNullAndEmptyArrays: true }));console.log("scalar output :", JSON.stringify(db.orders.aggregate([{ $match: { _id: "scalar" } }, { $unwind: "$items" }]).toArray()[0].items));assert.deepStrictEqual(Object.values(run("$items")), [0, 0, 0, 1]);default : { empty: 0, missing: 0, null: 0, scalar: 1 }preserveNullAndEmptyArrays: true: { empty: 1, missing: 1, null: 1, scalar: 1 }scalar output : {"title":"Solo","qty":1}| items | ค่าเริ่มต้น | preserveNullAndEmptyArrays: true |
|---|---|---|
[] | ทิ้ง | เก็บ, ไม่มี items |
| หาย | ทิ้ง | เก็บ |
null | ทิ้ง | เก็บ |
| object / scalar | เก็บหนึ่งครั้งตามเดิม | เก็บหนึ่งครั้งตามเดิม |
includeArrayIndex เพิ่มตำแหน่งของสมาชิกใน field ใหม่ (เริ่มที่ 0; null สำหรับ input ที่ไม่ใช่ array และกรณีว่างที่เก็บไว้):
db.small.insertOne({ _id: "o3", items: ["a", "b", "c"] });console.log(db.small.aggregate([{ $match: { _id: "o3" } }, { $unwind: { path: "$items", includeArrayIndex: "pos" } }, { $project: { _id: 0, items: 1, pos: 1 } }]).toArray());console.log("scalar / preserved empty:", db.orders.aggregate([{ $match: { _id: { $in: ["scalar", "empty"] } } }, { $unwind: { path: "$items", includeArrayIndex: "pos", preserveNullAndEmptyArrays: true } }, { $project: { pos: 1 } }, { $sort: { _id: 1 } }]).toArray());[ { items: 'a', pos: Long('0') }, { items: 'b', pos: Long('1') }, { items: 'c', pos: Long('2') }]scalar / preserved empty: [ { _id: 'empty', pos: null }, { _id: 'scalar', pos: null } ]บางครั้งคุณไม่ต้อง unwind
หัวข้อที่มีชื่อว่า “บางครั้งคุณไม่ต้อง unwind”ถ้าคำตอบเป็นต่อ order (ไม่ใช่ต่อสมาชิก) ให้ทำงานกับ array ตรง ๆ ด้วย $sum, $map หรือ $reduce; สายข้อมูลคง 50 000 document แทนที่จะพองเป็น 149 862 ทั้งสามแบบด้านล่างให้ยอดรวมเท่ากัน:
const time = (label, p) => { const t = Date.now(); const r = db.orders.aggregate(p).toArray(); console.log(label.padEnd(26), r.length, "rows in", Date.now() - t, "ms"); return r; };const a = time("$unwind + $group", [{ $match: { buyer: { $ne: "edge" } } }, { $unwind: "$items" }, { $group: { _id: "$_id", units: { $sum: "$items.qty" } } }]);const b = time("$sum over the array", [{ $match: { buyer: { $ne: "edge" } } }, { $project: { units: { $sum: "$items.qty" } } }]);const c = time("$reduce over the array", [{ $match: { buyer: { $ne: "edge" } } }, { $project: { units: { $reduce: { input: "$items", initialValue: 0, in: { $add: ["$$value", "$$this.qty"] } } } } }]);const m = (r) => Object.fromEntries(r.map((d) => [d._id, d.units]));assert.deepStrictEqual(m(a), m(b)); assert.deepStrictEqual(m(b), m(c));console.log("all three give identical per-order totals");$unwind + $group 50000 rows in 206 ms$sum over the array 50000 rows in 149 ms$reduce over the array 50000 rows in 158 msall three give identical per-order totalsกรองก่อน unwind
หัวข้อที่มีชื่อว่า “กรองก่อน unwind”$unwind คูณสายข้อมูล จึงควรทำให้เล็กก่อน optimizer ยก $match ที่ไม่แตะ array ขึ้นมาให้ (บรรทัดสุดท้าย: buyer ที่เขียนหลัง $unwind ยังอ่านเพียง 50 document ผ่าน index) filter บน field ของ สมาชิก ย้ายไม่ได้ แต่คุณเพิ่ม pre-filter ซ้ำซ้อนบน array ให้ stage อ่าน collection ทิ้ง order ทั้งใบ และเมื่อมี multikey index ก็ข้ามการอ่านได้:
const stats = (p) => { // what the collection scan read, and how many documents each later stage emitted const e = db.orders.explain("executionStats").aggregate(p), c = e.stages ? e.stages[0].$cursor : e; return { docsRead: c.executionStats.totalDocsExamined, emitted: (e.stages ?? []).slice(1).map((s) => Object.keys(s)[0] + " " + s.nReturned).join(", ") };};const late = [{ $unwind: "$items" }, { $match: { "items.title": "T7" } }, { $group: { _id: null, units: { $sum: "$items.qty" } } }];const early = [{ $match: { "items.title": "T7" } }, { $unwind: "$items" }, { $match: { "items.title": "T7" } }, { $group: { _id: null, units: { $sum: "$items.qty" } } }];console.log("filter on an element, after $unwind :", stats(late));console.log("pre-filter, $unwind, filter again :", stats(early));console.log("same answer:", JSON.stringify(db.orders.aggregate(late).toArray()), "=", JSON.stringify(db.orders.aggregate(early).toArray()));db.orders.createIndex({ "items.title": 1 });console.log("with a multikey index on items.title :", stats(early));const byBuyer = [{ $unwind: "$items" }, { $match: { buyer: "b7" } }];db.orders.createIndex({ buyer: 1 });console.log("filter on buyer written AFTER $unwind, index on buyer:", stats(byBuyer));assert.deepStrictEqual(db.orders.aggregate(late).toArray(), db.orders.aggregate(early).toArray());filter on an element, after $unwind : { docsRead: 50004, emitted: '$unwind 149863, $match 2940, $group 1' }pre-filter, $unwind, filter again : { docsRead: 50004, emitted: '$unwind 10415, $match 2940, $group 1' }same answer: [{"_id":null,"units":5945}] = [{"_id":null,"units":5945}]with a multikey index on items.title : { docsRead: 2864, emitted: '$unwind 10415, $match 2940, $group 1' }filter on buyer written AFTER $unwind, index on buyer: { docsRead: 50, emitted: '$unwind 156' }เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”$unwindคูณ document (50 000 กลายเป็น 149 862) ทำให้สายเล็กก่อนมันและพับหลังมัน- array ว่าง หาย และ
nullหายไปเงียบ ๆ; จำนวนที่ขาดหลัง unwind มักเป็นเพราะเรื่องนี้ - ใช้preserveNullAndEmptyArrays - หลัง unwind
"$items.qty"อ่านจำนวนของสมาชิกนั้น; array หายไปแล้ว - การนับ parent หลัง unwind คือการนับสมาชิก: ใช้
$addToSetของ_idหรือนับก่อน unwind
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอ “จำนวน order ของผู้ซื้อคนหนึ่ง” AI unwind ก่อนเพราะรายการสินค้า “ต้องนับอยู่ดี”:
🐛 หา bug ในโค้ดนี้
// AI comment: "count orders per buyer"db.orders.aggregate([{ $match: { buyer: "b7" } },{ $unwind: "$items" },{ $group: { _id: "$buyer", orders: { $sum: 1 } } },]);เฉลย
หลัง $unwind มีหนึ่ง document ต่อ รายการสินค้า $sum: 1 จึงนับรายการ: ผู้ซื้อ b7 มี 50 order แต่ pipeline บอก 156 ตัวเลขดูสมเหตุสมผลและไม่มี error ซึ่งเป็นเหตุผลที่มันหลุดขึ้นระบบ นับก่อน unwind (countDocuments) หรือรวบรวม parent ที่ไม่ซ้ำด้วย $addToSet แล้วเอา $size:
const buggy = db.orders.aggregate([{ $match: { buyer: "b7" } }, { $unwind: "$items" }, { $group: { _id: "$buyer", orders: { $sum: 1 } } }]).toArray()[0].orders;const right = db.orders.countDocuments({ buyer: "b7" });const fixedGroup = db.orders.aggregate([{ $match: { buyer: "b7" } }, { $unwind: "$items" }, { $group: { _id: "$buyer", orderIds: { $addToSet: "$_id" } } }, { $project: { orders: { $size: "$orderIds" } } }]).toArray()[0].orders;console.log({ buggyOrdersForB7: buggy, actualOrders: right, fixedWithAddToSet: fixedGroup });assert.ok(buggy > right); assert.strictEqual(fixedGroup, right);{ buggyOrdersForB7: 156, actualOrders: 50, fixedWithAddToSet: 50 }Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”$unwind เปลี่ยนความหมายของ “แถว”: ก่อนหน้ามัน แถวคือ order หลังมัน แถวคือรายการสินค้าที่จำ order ของตัวเองได้ ทุก stage หลังมันนับรายการสินค้า พับกลับไปยังระดับที่ต้องการด้วย $group บน _id ของ parent หรือหลีกเลี่ยงการเปลี่ยนแถวโดยทำงานกับ array ตรง ๆ
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ทางเลือก | ข้อดี | ต้นทุน |
|---|---|---|
$unwind + $group | สรุปข้ามสมาชิก; อ่านง่าย | สายโต 3 เท่า (149 862 document); 211 ms เทียบ 146 ms |
$sum / $reduce บน array | จำนวนแถวไม่เปลี่ยน; เร็วที่สุด | expression ซับซ้อนกว่า |
preserveNullAndEmptyArrays | document ที่ array ว่างอยู่ในรายงาน | ใช้รูปแบบ object; stage ถัดไปต้องรับมือกับ field ที่หาย |
| pre-filter ก่อน unwind | unwind 10 415 แทน 149 863 | เงื่อนไขซ้ำที่ต้องดูแลให้ตรงกัน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “
$unwindเก็บ document ที่ array ว่าง” - ถูกทิ้งเว้นแต่คุณขอ - “
$unwindต้องใช้ array” - scalar/object ออกมาหนึ่งครั้งตามเดิม - “การนับแถวหลัง unwind นับ order” - มันนับ 156 รายการสินค้าสำหรับ 50 order
- “ยอดรวมต่อ order ต้อง unwind” -
$sumบน array ให้ยอดเท่ากัน และเร็วกว่า
ตัวอย่างจากของจริง
หน้า
$unwindในคู่มือ MongoDB บันทึกกฎขอบทั้งสองที่วัดที่นี่: เมื่อ path หายหรือ array ว่าง “$unwind, by default, ignores the input document” และpreserveNullAndEmptyArraysคือสวิตช์เก็บไว้ ($unwind, ตรวจ 2026-09-30)
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (pipeline unwind-group คืนยอดรวมต่อ title ชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส