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

การ Query ใน MongoDB

การใส่ข้อมูลเข้าไปนั้นง่าย ทักษะที่แยกคนที่แค่ เก็บ document ออกจากคนที่ใช้ฐานข้อมูลได้จริงคือการอ่านข้อมูลกลับมาอย่างแม่นยำ การ query คือศิลปะของการอธิบายอย่างเจาะจงและไม่เกินจำเป็นว่าคุณสนใจ document ชุดย่อยกลุ่มไหน — แล้วจัดรูปสิ่งที่ส่งกลับมาให้คุณได้รับเฉพาะ field ที่ต้องการ ในลำดับที่ต้องการ แบ่งเป็นหน้าเล็กพอที่จะจัดการได้ โมดูลนี้พูดถึงครึ่งหลังของฐานข้อมูลทั้งหมด นั่นคือการอ่าน

เราจะทำงานกับ collection จำลองเล็ก ๆ ของสมาชิกห้องสมุด members และหนังสือที่พวกเขายืม เพราะข้อมูลขนาดเล็กช่วยให้ query เป็นจุดสนใจ เมื่อจบบทคุณจะสามารถ filter ด้วยการเปรียบเทียบและการรวม logic เจาะลึกเข้าไปใน array และ sub-document ที่ฝังอยู่ พร้อมทั้งตัด sort และแบ่งหน้าผลลัพธ์ได้โดยไม่ต้องลาก collection ทั้งก้อนข้ามสายไป

flowchart LR
  Q["Your query"] --> F["Filter: which documents"]
  Q --> P["Projection: which fields"]
  Q --> O["Order: sort"]
  Q --> Pg["Page: limit and skip"]
  F --> R["Result cursor"]
  P --> R
  O --> R
  Pg --> R
  R --> App["Your app"]
คำขออ่านคือการตัดสินใจสี่เรื่องที่เป็นอิสระต่อกัน — document ไหน, field ไหน, ลำดับอย่างไร, จำนวนเท่าไร — ที่รวมกันเป็น cursor เดียว

เมื่อคุณ query MongoDB คุณไม่เคยเขียนลูป คุณส่ง filter document ให้ server ที่ อธิบาย document ที่คุณต้องการ แล้ว server จะหาให้ filter อย่างด้านล่างอ่านได้ว่า “สมาชิกทุกคนที่เข้าร่วมในปี 2023 และตอนนี้ไม่ค้างค่าปรับเลย”

mongosh/q-first-filter.js
db.members.find({ joined: 2023, fines: 0 })

filter ที่เรียบง่ายที่สุดจับคู่ชื่อ field กับค่าหนึ่งค่า และหลายคู่ใน filter เดียวกันคือ AND โดยปริยาย จากจุดนั้นการ query เติบโตได้สี่ทิศทาง และโมดูลนี้จะพาไปทีละทิศทาง นี่คือการตัดสินใจทั้งสี่ในการอ่านที่รันได้หนึ่งครั้ง — document ไหน, field ไหน, ลำดับอย่างไร, จำนวนเท่าไร (mongo 8.3.11, mongosh 2.11.1, Node driver 7.7.0, Mac เครื่องเดียว; แต่ละบทในโมดูลนี้ seed ข้อมูลของตัวเอง):

mongosh/q-overview.js
db.members.insertMany([
{ name: "Ada", joined: 2023, fines: 0, borrowed: ["Compilers"] },
{ name: "Grace", joined: 2021, fines: 3, borrowed: ["Compilers", "Algorithms"] },
{ name: "Linus", joined: 2023, fines: 5, borrowed: [] },
{ name: "Margaret", joined: 2023, fines: 0, borrowed: ["Algorithms"] },
{ name: "Edsger", joined: 2022, fines: 1, borrowed: ["Databases"] },
]);
const page = db.members
.find({ joined: { $gte: 2022 }, fines: { $lte: 1 } }, { _id: 0, name: 1, joined: 1 }) // which documents, which fields
.sort({ joined: -1, name: 1 }) // in what order
.limit(2) // how many
.toArray();
console.log(page);
assert.strictEqual(page.map((d) => d.name).join(), "Ada,Margaret");
[ { name: 'Ada', joined: 2023 }, { name: 'Margaret', joined: 2023 } ]
  • Comparison และ logical operator มากกว่าการจับคู่แบบตรงเป๊ะ: มากกว่า, น้อยกว่า, การเป็นสมาชิกในเซ็ต, การไม่มี field และวิธีรวมเงื่อนไขด้วย AND/OR/NOT แบบชัดเจน เหล่านี้คือเครื่องมือใช้งานประจำวัน
  • การ query array field หนึ่งสามารถเก็บ list ได้ คุณจะได้เรียนการจับคู่ element เดียว การบังคับหลายเงื่อนไขบน element เดียวกัน ด้วย $elemMatch การบังคับให้มีหลายค่าครบทุกค่าด้วย $all และการ filter ตามความยาวของ array
  • การ query nested document document ซ้อนกันได้ dot notation ช่วยให้คุณเข้าถึง field ที่ฝังลึกอยู่ใน object และมีความแตกต่างสำคัญระหว่างการจับคู่ทั้ง sub-document แบบตรงเป๊ะกับการ match แค่ field เดียวข้างใน
  • Projection, การ sort และการแบ่งหน้า เมื่อคุณได้ document ที่ถูกต้องแล้ว คุณตัดสินใจว่าจะส่ง field ไหนกลับมา จะมาในลำดับใด และจะแบ่งผลลัพธ์ก้อนใหญ่ออกเป็นหน้าที่จัดการได้อย่างไร — และทำไมลูกเล่นการแบ่งหน้ายอดนิยมตัวหนึ่งถึงช้าลงเมื่อข้อมูลใหญ่ขึ้น

ทุกบทเรียนแสดง query เดียวกันห้าแบบ — ใน mongosh แล้วตามด้วย driver ของ Node.js, Python, Go และ Rust — คู่กับ output จริงที่ได้เมื่อเรารัน เพื่อให้คุณอ่านรูปร่างของคำตอบได้ ไม่ใช่แค่ตัวคำสั่ง

คำเตือนเก่าแก่สองข้อของโมดูลนี้ถูกกล่าวโดยไม่มีหลักฐาน เราวัดบนสมาชิก 20 000 คนที่มี index บน name (explain("executionStats") โดย keys examined คือจำนวนรายการใน index ที่อ่าน):

mongosh/q-regex-explain.js
db.people.insertMany(Array.from({ length: 20000 }, (_, i) => ({ name: "m" + i, joined: 2020 + (i % 6) })));
db.people.createIndex({ name: 1 });
const stage = (p) => (p.inputStage ? p.stage + " > " + stage(p.inputStage) : p.stage);
const stats = (label, cursor) => {
const e = cursor.explain("executionStats"), es = e.executionStats, wp = e.queryPlanner.winningPlan;
console.log(label.padEnd(34), stage(wp.queryPlan ?? wp).padEnd(15), "| returned", String(es.nReturned).padStart(4), "| keys", String(es.totalKeysExamined).padStart(5), "| docs", String(es.totalDocsExamined).padStart(5));
return es;
};
stats("name = 'm19'", db.people.find({ name: "m19" }));
const anchored = stats("/^m19/ (anchored)", db.people.find({ name: /^m19/ }));
const unanchored = stats("/m19/ (unanchored)", db.people.find({ name: /m19/ }));
stats("/^M19/i (case-insensitive)", db.people.find({ name: /^M19/i }));
const rx = stats("$expr $regexMatch ^m19", db.people.find({ $expr: { $regexMatch: { input: "$name", regex: "^m19" } } }));
assert.strictEqual(anchored.totalKeysExamined, 1112);
assert.strictEqual(unanchored.totalKeysExamined, 20000);
assert.strictEqual(rx.totalDocsExamined, 20000);
name = 'm19' FETCH > IXSCAN | returned 1 | keys 1 | docs 1
/^m19/ (anchored) FETCH > IXSCAN | returned 1111 | keys 1112 | docs 1111
/m19/ (unanchored) FETCH > IXSCAN | returned 1111 | keys 20000 | docs 1111
/^M19/i (case-insensitive) FETCH > IXSCAN | returned 1111 | keys 20000 | docs 1111
$expr $regexMatch ^m19 COLLSCAN | returned 1111 | keys 0 | docs 20000

นี่แก้ถ้อยคำเก่า (“regex ที่ไม่ anchor สแกนทุก document”): planner ใช้ index แต่สำหรับ /m19/ และ /^M19/i ที่ไม่สนตัวพิมพ์ มันไม่มี prefix ให้สร้างช่วง จึงอ่าน ทั้ง 20 000 รายการใน index และ fetch เฉพาะ 1 111 document ที่ตรง — เป็นการสแกน index เต็ม ส่วน /^m19/ ที่ anchor กลายเป็นช่วงแคบ (1 112 key) และ $expr กับ $regexMatch ใช้ index ไม่ได้เลย: ตรวจทั้ง 20 000 document reference ของ $regex ใน MongoDB บอกเช่นกัน: prefix expression ให้ MongoDB “construct a ‘range’ from that prefix” และ index แบบไม่สนตัวพิมพ์ “do not improve performance for $regex queries” ($regex, เรียกอ่านเมื่อ 2026-09-30)

$where รัน JavaScript ต่อ document collection เดียวกัน คำตอบ 1 111 รายการเดียวกัน:

// mongosh/q-where.js @skip-verify takes about 3-4 seconds on 20 000 documents; output below from a real run
let t = Date.now();
const viaRegex = db.people.find({ name: /^m19/ }).toArray().length;
console.log("/^m19/ :", viaRegex, "docs in", Date.now() - t, "ms");
t = Date.now();
const viaWhere = db.people.find({ $where: "this.name.startsWith('m19')" }).toArray().length;
console.log("$where :", viaWhere, "docs in", Date.now() - t, "ms");
console.log("$where plan:", db.people.find({ $where: "this.name.startsWith('m19')" }).explain("executionStats").queryPlanner.winningPlan.stage);
/^m19/ : 1111 docs in 19 ms
$where : 1111 docs in 4186 ms
$where plan: COLLSCAN

$where ใช้เวลาราว 4 วินาที (3.6 ถึง 4.2 วินาทีในสี่การรัน) เทียบ 19 มิลลิวินาทีของ regex และเป็น COLLSCAN ที่ใช้ index ไม่ได้ — ต่างกันราว 200 เท่า คู่มือระบุว่า “$where evaluates JavaScript and cannot take advantage of indexes” และแนะนำ $expr กับ aggregation operator ที่ไม่ใช้ JavaScript ซึ่ง “faster than $where because it does not execute JavaScript”; และยังบอกวิธีปิด JavaScript ฝั่ง server ด้วย security.javascriptEnabled: false / --noscripting ($where, เรียกอ่านเมื่อ 2026-09-30) — ควรทำถ้าไม่มีอะไรในแอปต้องใช้ เพราะ $where กับ input ของผู้ใช้คือช่องโหว่ code injection

ผู้ช่วย AI ที่ถูกขอ “ค้นหาชื่อเร็ว ๆ ด้วย index” มักเขียนการค้นหาแบบมีคำ ไม่สนตัวพิมพ์ แล้วปลอบใจเรื่อง index:

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

// AI comment: "indexed search - name has an index so this is fast"
const hits = await db.collection("people")
.find({ name: /M19/i })
.toArray();
เฉลย

query ทำงานและคืน document ที่ถูก — จึงไม่มีใครสังเกต แต่ /M19/i ไม่มี prefix ที่สนตัวพิมพ์ planner จึงอ่าน ทุกรายการของ index name (ตรวจ 20 000 key เพื่อคืน 1 111 document) เหมือนกับ /m19/: index ถูกใช้แต่แทบไม่ได้ประโยชน์ และต้นทุนโตเป็นเส้นตรงตาม collection ถ้าเจตนาคือ “ขึ้นต้นด้วย” ให้เก็บหรือ query ในตัวพิมพ์เดียวและ anchor นิพจน์:

mongosh/q-regex-fix.js
const before = db.people.find({ name: /M19/i }).explain("executionStats").executionStats;
const after = db.people.find({ name: /^m19/ }).explain("executionStats").executionStats;
console.log("case-insensitive contains:", before.totalKeysExamined, "keys");
console.log("anchored prefix :", after.totalKeysExamined, "keys");
assert.strictEqual(before.totalKeysExamined, 20000);
assert.strictEqual(after.totalKeysExamined, 1112);
case-insensitive contains: 20000 keys
anchored prefix : 1112 keys

(สำหรับการค้นหา “มีคำนี้” กับข้อมูลจำนวนมากจริง ๆ text index หรือ Atlas Search คือเครื่องมือที่ถูก; โมดูล indexes ครอบคลุม $text)

query มีสองต้นทุน: การเลือก (server ต้องดูรายการ index หรือ document กี่รายการเพื่อตัดสิน?) และ การส่งมอบ (กี่ document และกี่ไบต์ถึงคุณ?) filter, projection, sort และ limit ต่างกระทบอย่างใดอย่างหนึ่ง สี่บทที่ตามมาคือ ตามลำดับ เรื่องการเขียนการเลือกที่ server ตอบได้ถูก (operator, array, path ซ้อน) และการตัดการส่งมอบ (projection, sort, pagination) explain แสดงทั้งสองตัวเลข

ตัวเลือกBenefitCost
filter อย่างเดียว ไม่ระบุ projectionเขียนเร็ว query ทำงานทันทีคืน field ที่ไม่ใช้ทุกครั้ง เปลืองแบนด์วิดท์และหน่วยความจำฝั่ง client
filter + projection ที่ระบุ field ชัดเจนลดข้อมูลที่ส่งผ่านเครือข่าย บังคับให้ทีมเห็น field ที่แอปพึ่งพาจริงต้องดูแลให้ตรงกันเมื่อ schema วิวัฒน์
filter แบบจับคู่ตรงเป๊ะเร็วและ index ได้เสมอตอบคำถามช่วง/เซ็ต/นิพจน์ซับซ้อนไม่ได้
filter ซับซ้อนที่มีหลาย operator และ $and/$orตอบคำถามที่ละเอียดกว่ามากอ่านยากขึ้น; กิ่ง $or ที่ไม่มี index ส่งทั้ง query ไปสแกนทั้ง collection (ตรวจ 20 000 document ในบท comparison)
filter แบบ regexจับคู่รูปแบบได้โดยไม่ต้องมี index ชนิดใหม่เฉพาะ prefix ที่ anchor และสนตัวพิมพ์เท่านั้นที่ใช้ index เป็นช่วง (1 112 key); นอกนั้นสแกน index เต็ม (20 000 key)
  • “$where ก็แค่ operator อีกตัว” — มันใช้ราว 4 วินาทีเทียบ 19 ms ของ regex บน 20 000 document ใช้ index ไม่ได้ และประเมิน JavaScript (ช่องโหว่ injection กับ input ผู้ใช้) ให้ใช้ operator มาตรฐานหรือ $expr
  • “regex ที่ไม่ anchor สแกนทุก document” — มันสแกนทุก รายการใน index เมื่อมี index (20 000 key, fetch 1 111 document); ถ้าไม่มี index ก็คือสแกน collection ไม่ว่าแบบไหน ให้ anchor ด้วย ^ สำหรับ “ขึ้นต้นด้วย”
  • “regex ไม่สนตัวพิมพ์ใช้ index ได้ดี” — /^M19/i ตรวจ 20 000 key; แบบที่ anchor และสนตัวพิมพ์ตรวจ 1 112
  • “projection เป็นแค่ของตกแต่ง” — ทุก field ที่ไม่ตัดเดินทางผ่านเครือข่าย (บท projection แสดง exclusion projection รั่ว field)
  • “แต่ละโมดูลแยกอิสระ” — บทหลังพึ่งกฎที่เรียนก่อน (field ที่ขาด, null, ชนิดข้อมูล)

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

หน้า $where ของคู่มือ MongoDB ระบุ trade-off ตรง ๆ: “$where evaluates JavaScript and cannot take advantage of indexes … Using $where alone requires a collection scan” และแนะนำ $expr แทน ($where, เรียกอ่านเมื่อ 2026-09-30); การวัด 4 วินาทีด้านบนคือประโยคนั้นในรูปตัวเลข

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

ใน MongoDB query filter คืออะไร?
filter ที่มีสองคู่ field/ค่า เช่น joined และ fines หมายถึงอะไร?
ข้อใดไม่ใช่หนึ่งในสี่การตัดสินใจที่คำขออ่านรวมกัน?
มี index บน name explain แสดงอะไรสำหรับ find({ name: /m19/ }) บน 20 000 document?
ทำไมควรเลี่ยง $where สำหรับเงื่อนไขอย่าง name.startsWith("m19")?