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

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

mongosh/uw-seed.js
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 numbers
db.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

ให้ $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"]
document เดียวที่มี array สองสมาชิกกลายเป็นสอง document หนึ่งต่อสมาชิก
mongosh/uw-basic.js
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);
[
{ _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 ที่ขายดีที่สุด:

mongosh/uw-group.js
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

กฎของคู่มือ: ถ้า path หายหรือเป็น array ว่าง $unwind เพิกเฉยต่อ document และถ้าค่าไม่ใช่ array แต่ไม่ใช่ missing, null หรือว่าง $unwind “outputs a single document using the value as-is” ($unwind, ตรวจ 2026-09-30) ทั้งสี่ document กรณีขอบ ทั้งสองแบบ:

mongosh/uw-edge-cases.js
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 และกรณีว่างที่เก็บไว้):

mongosh/uw-index.js
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 } ]

ถ้าคำตอบเป็นต่อ order (ไม่ใช่ต่อสมาชิก) ให้ทำงานกับ array ตรง ๆ ด้วย $sum, $map หรือ $reduce; สายข้อมูลคง 50 000 document แทนที่จะพองเป็น 149 862 ทั้งสามแบบด้านล่างให้ยอดรวมเท่ากัน:

mongosh/uw-no-unwind.js
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 ms
all three give identical per-order totals

$unwind คูณสายข้อมูล จึงควรทำให้เล็กก่อน optimizer ยก $match ที่ไม่แตะ array ขึ้นมาให้ (บรรทัดสุดท้าย: buyer ที่เขียนหลัง $unwind ยังอ่านเพียง 50 document ผ่าน index) filter บน field ของ สมาชิก ย้ายไม่ได้ แต่คุณเพิ่ม pre-filter ซ้ำซ้อนบน array ให้ stage อ่าน collection ทิ้ง order ทั้งใบ และเมื่อมี multikey index ก็ข้ามการอ่านได้:

mongosh/uw-match-order.js
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

เมื่อถูกขอ “จำนวน 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:

mongosh/uw-bug.js
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 }

$unwind เปลี่ยนความหมายของ “แถว”: ก่อนหน้ามัน แถวคือ order หลังมัน แถวคือรายการสินค้าที่จำ order ของตัวเองได้ ทุก stage หลังมันนับรายการสินค้า พับกลับไปยังระดับที่ต้องการด้วย $group บน _id ของ parent หรือหลีกเลี่ยงการเปลี่ยนแถวโดยทำงานกับ array ตรง ๆ

ทางเลือกข้อดีต้นทุน
$unwind + $groupสรุปข้ามสมาชิก; อ่านง่ายสายโต 3 เท่า (149 862 document); 211 ms เทียบ 146 ms
$sum / $reduce บน arrayจำนวนแถวไม่เปลี่ยน; เร็วที่สุดexpression ซับซ้อนกว่า
preserveNullAndEmptyArraysdocument ที่ array ว่างอยู่ในรายงานใช้รูปแบบ object; stage ถัดไปต้องรับมือกับ field ที่หาย
pre-filter ก่อน unwindunwind 10 415 แทน 149 863เงื่อนไขซ้ำที่ต้องดูแลให้ตรงกัน
  • “$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 ชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

$unwind: "$items" ทำอะไรกับ document ที่มี array items สามสมาชิก
ค่าเริ่มต้น document ที่ array ว่าง หาย หรือเป็น null จะเป็นอย่างไร
$unwind ทำอะไรเมื่อ field เป็น object ธรรมดา (ไม่ใช่ array)
ต้องการจำนวนรวมต่อ order วิธีใดถูกที่สุด
หลัง $match { buyer } แล้ว $unwind แล้ว $group ตาม buyer ด้วย $sum: 1 ทำไมจำนวน "orders" ผิด
ลดงานของ $unwind ที่ตามด้วย filter บน field ของสมาชิกได้อย่างไร