Projection, sort และ pagination
filter ตัดสินว่า document ไหน จะกลับมา อีกสามตัวควบคุมว่า document เหล่านั้นหน้าตาอย่างไรระหว่างทางไปยังแอปของคุณ: projection ตัด field, sort กำหนดลำดับ และ limit กับ skip หั่นผลลัพธ์เป็นหน้า รวมกันแล้วช่วยไม่ให้คุณลากทั้ง document หรือทั้ง collection ข้ามเครือข่ายทั้งที่ต้องการแค่ชื่อและหนึ่งหน้าสิบรายการ บทนี้ครอบคลุมทั้งสี่ พิสูจน์สองวิธีที่ pagination เงียบ ๆ ผิดพลาด และปิดท้ายด้วยตัวเลขเบื้องหลัง range-based pagination
เรากลับมาที่ members ง่าย ๆ ของห้องสมุด แต่ละคนมี name, ปี joined และ fines ทุก output ด้านล่างจับมาจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว (แท็บ Python, Go และ Rust รันหนึ่งครั้งในโปรแกรม scratch บน server เดียวกัน); เวลามาจากการรันครั้งเดียว มีความหมายเฉพาะสัดส่วน
ข้อมูลที่เรา query
หัวข้อที่มีชื่อว่า “ข้อมูลที่เรา query”ชื่อถูกเลือกให้เผยกฎการเรียง (alan และ grace ตัวพิมพ์เล็ก) และหลายสมาชิกมีค่า fines เท่ากัน (ค่าซ้ำ):
db.members.insertMany([ { name: "Ada", joined: 2024, fines: 0 }, { name: "grace", joined: 2021, fines: 3 }, { name: "Linus", joined: 2023, fines: 5 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Edsger", joined: 2022, fines: 5 }, { name: "Barbara", joined: 2025, fines: 1 }, { name: "alan", joined: 2020, fines: 1 }, { name: "Zed", joined: 2026, fines: 0 },]);globalThis.nm = (c) => c.toArray().map((d) => d.name).join(", ");assert.strictEqual(db.members.countDocuments(), 8);await db.collection("members").insertMany([ { name: "Ada", joined: 2024, fines: 0 }, { name: "grace", joined: 2021, fines: 3 }, { name: "Linus", joined: 2023, fines: 5 }, { name: "Margaret", joined: 2023, fines: 0 }, { name: "Edsger", joined: 2022, fines: 5 }, { name: "Barbara", joined: 2025, fines: 1 }, { name: "alan", joined: 2020, fines: 1 }, { name: "Zed", joined: 2026, fines: 0 },]);if ((await db.collection("members").countDocuments()) !== 8) throw new Error("expected 8");Projection: เลือก field
หัวข้อที่มีชื่อว่า “Projection: เลือก field”projection คือ document ที่สองส่งให้ find ซึ่งระบุ field ที่จะเก็บ ตั้ง field เป็น 1 เพื่อรวมมัน ทุกอย่างที่ไม่ได้ระบุถูกทิ้ง — ยกเว้น _id ที่ถูกคืนเว้นแต่คุณยกเว้นอย่างชัดเจนด้วย 0 ที่นี่เราต้องการเฉพาะชื่อและปีที่เข้าร่วม โดยไม่มี _id:
const docs = db.members.find( { fines: 0 }, { name: 1, joined: 1, _id: 0 }).toArray();console.log(docs);assert.strictEqual(docs.length, 3);assert.strictEqual("_id" in docs[0], false);const docs = await db.collection("members") .find({ fines: 0 }) .project({ name: 1, joined: 1, _id: 0 }) .toArray();console.log(docs);if (docs.length !== 3 || "_id" in docs[0]) throw new Error("unexpected projection");docs = list(db.members.find( {"fines": 0}, {"name": 1, "joined": 1, "_id": 0},))opts := options.Find().SetProjection(bson.M{"name": 1, "joined": 1, "_id": 0})cur, err := coll.Find(ctx, bson.M{"fines": 0}, opts)if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! { "fines": 0 }) .projection(doc! { "name": 1, "joined": 1, "_id": 0 }) .await?;มาถึงเฉพาะ field ที่ขอ และ _id หายไป:
[ { name: 'Ada', joined: 2024 }, { name: 'Margaret', joined: 2023 }, { name: 'Zed', joined: 2026 }]คุณรวม field หรือยกเว้น field ได้ แต่ผสมสองอย่างใน projection เดียวไม่ได้ — ข้อยกเว้นเดียวคือการยกเว้น _id ควบคู่กับลิสต์ที่รวม ดังข้างบน { name: 1, joined: 0 } ล้มเหลวด้วย Cannot do exclusion on field joined in inclusion projection projection ยัง คำนวณ ค่าได้ ({ upper: { $toUpper: "$name" }, owes: { $gt: ["$fines", 0] } } คืน { name: 'Ada', upper: 'ADA', owes: false })
รวมเทียบยกเว้น: อะไรรั่ว
หัวข้อที่มีชื่อว่า “รวมเทียบยกเว้น: อะไรรั่ว”บทเก่าเตือนว่า exclusion projection “รั่ว” field ที่เพิ่มทีหลัง โดยไม่แสดง นี่คือมัน — การเปลี่ยน schema หลังเขียน query (Extra ได้ internalNotes และ cost):
db.members.insertOne({ name: "Extra", joined: 2026, fines: 0, internalNotes: "secret", cost: 12 });console.log("exclusion projection { internalNotes: 0 }:", db.members.findOne({ name: "Extra" }, { _id: 0, internalNotes: 0 }));console.log("inclusion projection { name, joined } :", db.members.findOne({ name: "Extra" }, { _id: 0, name: 1, joined: 1 }));assert.strictEqual("cost" in db.members.findOne({ name: "Extra" }, { internalNotes: 0 }), true);assert.strictEqual("cost" in db.members.findOne({ name: "Extra" }, { name: 1, joined: 1 }), false);exclusion projection { internalNotes: 0 }: { name: 'Extra', joined: 2026, fines: 0, cost: 12 }inclusion projection { name, joined } : { name: 'Extra', joined: 2026 }query แบบยกเว้นซ่อน field เดียวที่ผู้เขียนรู้และปล่อยให้ cost เดินออกไป; query แบบรวมคืนสอง field ที่ระบุพอดี ตลอดไป
การเรียงผลลัพธ์
หัวข้อที่มีชื่อว่า “การเรียงผลลัพธ์”sort เรียงผลลัพธ์ตาม field หนึ่งหรือหลายตัว: 1 คือน้อยไปมาก -1 คือมากไปน้อย ค่าเท่ากันถูกตัดสินด้วย key ตัวหลังที่คุณระบุ ที่นี่เราเรียงทุกคนจากค่าปรับมากสุดไปน้อยสุด:
console.log("fines desc :", nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ fines: -1 })));console.log("fines desc, name asc:", nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ fines: -1, name: 1 })));console.log("name asc (binary) :", nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ name: 1 })));console.log("name asc, en collation:", nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ name: 1 }).collation({ locale: "en" })));assert.strictEqual(nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ name: 1 })).split(", ")[6], "alan");const docs = await db.collection("members") .find() .sort({ fines: -1 }) .toArray();console.log(docs.map((d) => `${d.name}:${d.fines}`).join(" "));if (docs[0].fines !== 5) throw new Error("highest fines should come first");docs = list(db.members.find().sort("fines", -1))opts := options.Find().SetSort(bson.D{{Key: "fines", Value: -1}})cur, err := coll.Find(ctx, bson.M{}, opts)if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! {}) .sort(doc! { "fines": -1 }) .await?;ยอดสูงสุดมาก่อน (Linus และ Edsger เสมอกันที่ 5 จึงเพิ่ม key ที่สองเพื่อให้ลำดับกำหนดได้) และ การเรียง string เริ่มต้นเป็นแบบ binary: ตัวพิมพ์ใหญ่มาก่อนตัวพิมพ์เล็ก Zed จึงมาก่อน alan และ grace ขอลำดับที่รู้จักภาษาด้วย collation:
fines desc : Linus, Edsger, grace, Barbara, alan, Ada, Margaret, Zedfines desc, name asc: Edsger, Linus, grace, Barbara, alan, Ada, Margaret, Zedname asc (binary) : Ada, Barbara, Edsger, Linus, Margaret, Zed, alan, gracename asc, en collation: Ada, alan, Barbara, Edsger, grace, Linus, Margaret, Zedการเสมอในบรรทัดแรก (Linus ก่อน Edsger) คือสิ่งที่ server บังเอิญคืนมา — ไม่ได้ รับประกัน ซึ่งสำคัญทันทีที่คุณแบ่งหน้า (ดู bug ด้านล่าง)
Pagination: limit และ skip
หัวข้อที่มีชื่อว่า “Pagination: limit และ skip”limit จำกัดจำนวน document ที่ได้รับ; skip ทิ้งจำนวนนั้นจากด้านหน้าก่อน เมื่อรวมกับ sort ที่เสถียรก็ได้หน้า สำหรับหน้าที่สองขนาดสอง เรียงตามชื่อ คุณ skip สองแรกแล้วเอาสอง:
const page2 = db.members.find({ name: { $ne: "Extra" } }, { name: 1 }) .sort({ name: 1 }) .skip(2) .limit(2);console.log(nm(page2));// the order you chain the methods does not matter:console.log(nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).limit(2).sort({ name: 1 }).skip(2)));assert.strictEqual(nm(db.members.find({ name: { $ne: "Extra" } }, { name: 1 }).sort({ name: 1 }).skip(2).limit(2)), "Edsger, Linus");const docs = await db.collection("members") .find() .sort({ name: 1 }) .skip(2) .limit(2) .toArray();console.log(docs.map((d) => d.name));if (docs.length !== 2) throw new Error("expected a page of 2");docs = list( db.members.find().sort("name", 1).skip(2).limit(2))opts := options.Find(). SetSort(bson.D{{Key: "name", Value: 1}}). SetSkip(2). SetLimit(2)cur, err := coll.Find(ctx, bson.M{}, opts)if err != nil { return err}var docs []bson.Mif err := cur.All(ctx, &docs); err != nil { return err}let mut cursor = coll .find(doc! {}) .sort(doc! { "name": 1 }) .skip(2) .limit(2) .await?;คุณได้สมาชิกลำดับสามและสี่ตามชื่อ — หน้าที่สอง (Edsger, Linus ในลำดับ binary) การต่อ .limit(2).sort(...).skip(2) ในลำดับใดก็ให้หน้าเดียวกัน ซึ่งเป็นประเด็นของหัวข้อถัดไป:
Edsger, LinusEdsger, Linusชิ้นส่วนรวมกันอย่างไร
หัวข้อที่มีชื่อว่า “ชิ้นส่วนรวมกันอย่างไร”server ใช้ขั้นตอนเหล่านี้ตามลำดับตายตัว ไม่ว่าคุณจะต่อเมธอดในลำดับใด (พิสูจน์ด้านบน): กรองก่อน แล้วเรียงผลที่ตรง แล้ว skip แล้ว limit การรู้ลำดับอธิบายว่าทำไมต้องมี sort เพื่อให้หน้าเสถียร และทำไม skip ใหญ่ ๆ สิ้นเปลือง
flowchart LR C["Collection"] --> F["find filter"] F --> S["sort"] S --> SK["skip n"] SK --> L["limit k"] L --> R["Cursor of k documents"]
วน cursor
หัวข้อที่มีชื่อว่า “วน cursor”find คืน cursor ไม่ใช่ list driver ดึง document เป็น batch ตามที่คุณวน (ดูจำนวน round trip ในบท find) คุณจึง stream ผลลัพธ์ใหญ่ได้โดยไม่ต้องโหลดทั้งหมดเข้าหน่วยความจำ การเรียก toArray (หรือเทียบเท่า) แค่ระบายทั้ง cursor ให้:
const cursor = db.members.find({ name: { $ne: "Extra" } }).sort({ name: 1 })const out = [];while (cursor.hasNext()) { out.push(cursor.next().name)}console.log(out.join(", "));assert.strictEqual(out.length, 8);const cursor = db.collection("members").find().sort({ name: 1 });let n = 0;for await (const doc of cursor) { console.log(doc.name); n++;}if (n !== 8) throw new Error("expected 8");cursor = db.members.find().sort("name", 1)for doc in cursor: print(doc["name"])cur, err := coll.Find(ctx, bson.M{})if err != nil { return err}defer cur.Close(ctx)for cur.Next(ctx) { var doc bson.M if err := cur.Decode(&doc); err != nil { return err } fmt.Println(doc["name"])}let mut cursor = coll.find(doc! {}).sort(doc! { "name": 1 }).await?;while let Some(doc) = cursor.try_next().await? { println!("{:?}", doc.get_str("name"));}Ada, Barbara, Edsger, Linus, Margaret, Zed, alan, grace(ตัวอย่าง shell ในบทเก่าใช้ printjson ซึ่งพิมพ์บล็อกหลายบรรทัดต่อ document; console.log เก็บ output ให้กระชับ)
ทำไม skip ใหญ่ ๆ ช้า
หัวข้อที่มีชื่อว่า “ทำไม skip ใหญ่ ๆ ช้า”skip(n) ไม่ได้กระโดดไปยัง document ลำดับที่ n — server ยังต้อง เดินผ่าน n รายการแรกที่ตรงก่อนจะคืนอะไรได้ ที่หน้าที่สองไม่มีปัญหา แต่ที่หน้า 90,000 ฐานข้อมูลต้องเดิน 900,000 รายการใน index แล้วทิ้ง เราวัดแล้ว: document เล็ก ๆ 1 000 000 รายการ index บน i และหน้าละ 10 ที่สี่ความลึก (รันครั้งเดียว, explain("executionStats") สำหรับงานที่ทำ):
const c = db.collection("events");const N = 1000000;for (let s = 0; s < N; s += 100000) { await c.insertMany(Array.from({ length: 100000 }, (_, k) => ({ i: s + k, bucket: (s + k) % 10, pad: "x".repeat(20) })), { ordered: false });}await c.createIndex({ i: 1 });const keys = async (cur) => { const e = await cur.explain("executionStats"); return e.executionStats.totalKeysExamined; };const rows = [];for (const skip of [0, 1000, 100000, 900000]) { const q = () => c.find().sort({ i: 1 }).skip(skip).limit(10); const t = Date.now(); await q().toArray(); const ms = Date.now() - t; rows.push({ page: `skip(${skip})`, ms, keysExamined: await keys(q()) });}const range = () => c.find({ i: { $gt: 900000 } }).sort({ i: 1 }).limit(10);const t = Date.now(); await range().toArray();rows.push({ page: "range i > 900000", ms: Date.now() - t, keysExamined: await keys(range()) });console.table(rows);if (rows[3].keysExamined < 900000 || rows[4].keysExamined > 20) throw new Error("unexpected skip cost");┌─────────┬────────────────────┬─────┬──────────────┐│ (index) │ page │ ms │ keysExamined │├─────────┼────────────────────┼─────┼──────────────┤│ 0 │ 'skip(0)' │ 6 │ 10 ││ 1 │ 'skip(1000)' │ 1 │ 1010 ││ 2 │ 'skip(100000)' │ 26 │ 100010 ││ 3 │ 'skip(900000)' │ 252 │ 900010 ││ 4 │ 'range i > 900000' │ 3 │ 10 │└─────────┴────────────────────┴─────┴──────────────┘หน้าคืน 10 document เสมอ แต่งานโตตาม offset: เดิน 900 010 รายการใน index เพื่อส่งหน้าสุดท้าย เทียบ 10 สำหรับหน้าเดียวกันที่ไปถึงด้วย range เวลาต่างกันไปในแต่ละรอบ (การรันก่อนหน้าให้ 8, 2, 33, 239 และ 1 ms) แต่คอลัมน์ keys-examined แน่นอน ทางแก้คือ range-based pagination: แทนที่จะ skip ให้จำค่าสุดท้ายที่เห็นแล้วขอ document หลังจาก นั้น:
const lastSeen = "Linus"; // the last name the client receivedconst next = db.members.find({ name: { $gt: lastSeen } }, { name: 1 }) .sort({ name: 1 }) .limit(2);const names = nm(next);console.log(names);assert.strictEqual(names, "Margaret, Zed");Margaret, Zedวิธีนี้อ่านเฉพาะหน้าที่คุณต้องการเสมอ ไม่ว่าลึกแค่ไหน — โดยที่ sort key ไม่ซ้ำ (หรือคุณเพิ่ม tie-breaker ที่ไม่ซ้ำ หัวข้อถัดไป) และมี index ราคาคือคุณกระโดดไป “หน้า 37” ไม่ได้ คุณได้ “ถัดไป” และ “ก่อนหน้า”
ค่าซ้ำทำให้ pagination พัง
หัวข้อที่มีชื่อว่า “ค่าซ้ำทำให้ pagination พัง”การ sort บน field ที่มีค่าซ้ำไม่กำหนดลำดับระหว่างตัวซ้ำ ระหว่างสอง query ของหน้า server อาจเรียงตัวที่เสมอกันต่างกัน ทำให้ document ปรากฏสองหน้าและอีกอันไม่ปรากฏเลย เราแบ่งหน้า 20 000 document (grade วนจาก 0 ถึง 4 ทุกค่าจึงมี 4 000 ตัวเสมอ) เป็น 20 หน้าละ 1 000:
const c = db.collection("ties");await c.insertMany(Array.from({ length: 20000 }, (_, i) => ({ i, grade: i % 5 })));async function pages(sort) { const seen = []; for (let p = 0; p < 20; p++) { (await c.find({}, { projection: { i: 1 } }).sort(sort).skip(p * 1000).limit(1000).toArray()).forEach((d) => seen.push(d.i)); } const distinct = new Set(seen).size; return { rows: seen.length, distinct, missing: 20000 - distinct };}const tied = await pages({ grade: 1 });const broken = await pages({ grade: 1, _id: 1 });await c.createIndex({ grade: 1 });const indexed = await pages({ grade: 1 });console.log("sort { grade: 1 } (ties, no index) :", tied);console.log("sort { grade: 1, _id: 1 } (tie-break):", broken);console.log("sort { grade: 1 } (with index) :", indexed);if (broken.missing !== 0) throw new Error("a unique tie-breaker must give exact pages");sort { grade: 1 } (ties, no index) : { rows: 20000, distinct: 15084, missing: 4916 }sort { grade: 1, _id: 1 } (tie-break): { rows: 20000, distinct: 20000, missing: 0 }sort { grade: 1 } (with index) : { rows: 20000, distinct: 20000, missing: 0 }ถ้าไม่มี tie-breaker ที่ไม่ซ้ำ 4 916 จาก 20 000 document ไม่เคยถูกส่งเลย (และอันอื่นถูกส่งสองครั้ง) — ด้วยจำนวนแถวที่ถูกต้องและไม่มี error การเพิ่ม _id เป็น key เรียงสุดท้ายแก้ได้ทั้งหมด เมื่อมี index การรันก็เกิดว่าแม่นเช่นกัน แต่เพียงเพราะลำดับการสแกน index เสถียรในกรณีเงียบนี้; อย่าพึ่งพา
การ sort ผลลัพธ์ใหญ่: หน่วยความจำและดิสก์
หัวข้อที่มีชื่อว่า “การ sort ผลลัพธ์ใหญ่: หน่วยความจำและดิสก์”sort ที่ไม่มี index ให้บริการทำงานในหน่วยความจำ เพดานเริ่มต้นคือ internalQueryMaxBlockingSortMemoryUsageBytes = 100 MB (104857600 บน server ของคอร์ส อ่านด้วย getParameter) ตั้งแต่ MongoDB 6.0 allowDiskUseByDefault: true ให้ sort ที่เกินเพดาน spill ลงดิสก์ แทนที่จะล้มเหลว บทเก่าบอกว่ามัน “error ทันที” เพื่อดูความจริง เราเริ่ม mongod ชั่วคราว ที่ลดเพดานเหลือ 1 MB (--setParameter ไม่ทำบน harness server ที่ใช้ร่วมกัน) แล้ว sort collection ขนาด 5 MB:
// mongosh/psp-sort-spill.js @skip-verify needs mongod started with --setParameter internalQueryMaxBlockingSortMemoryUsageBytes=1048576console.log(db.adminCommand({ getParameter: 1, internalQueryMaxBlockingSortMemoryUsageBytes: 1, allowDiskUseByDefault: 1 }));db.docs.insertMany(Array.from({ length: 5000 }, (_, i) => ({ i, r: Math.random(), pad: "x".repeat(1000) })));console.log("collection size MB:", (db.docs.stats().size / 1048576).toFixed(1));let t = Date.now();console.log("default sort ->", db.docs.find({}, { i: 1 }).sort({ r: 1 }).toArray().length, "docs in", Date.now() - t, "ms");try { db.docs.find({}, { i: 1 }).sort({ r: 1 }).allowDiskUse(false).toArray(); } catch (e) { console.log(e.name, e.code, e.message); }const es = db.docs.find({}, { i: 1 }).sort({ r: 1 }).explain("executionStats").executionStats;const sortStage = (s) => (s.stage === "SORT" ? s : sortStage(s.inputStage));const st = sortStage(es.executionStages.queryPlan ?? es.executionStages);console.log("explain: SORT usedDisk:", st.usedDisk, "spills:", st.spills, "spilledRecords:", st.spilledRecords);db.docs.createIndex({ r: 1 });t = Date.now();console.log("with index on r:", db.docs.find({}, { i: 1 }).sort({ r: 1 }).allowDiskUse(false).toArray().length, "docs in", Date.now() - t, "ms, no error");{ allowDiskUseByDefault: true, internalQueryMaxBlockingSortMemoryUsageBytes: Long('1048576'), ok: 1}collection size MB: 5.0default sort -> 5000 docs in 22 msMongoServerError 292 Executor error during find command: test.docs :: caused by :: Sort exceeded memory limit of 1048576 bytes, but did not opt in to external sorting.explain: SORT usedDisk: true spills: 6 spilledRecords: 5000with index on r: 5000 docs in 16 ms, no errorดังนั้น: โดยค่าเริ่มต้น sort ที่เกินเพดาน สำเร็จโดย spill (usedDisk: true, 6 spill, 5 000 record); มันล้มเหลวเฉพาะเมื่อคุณ opt out ด้วย .allowDiskUse(false); และ index บน sort key ลบขั้น sort ทิ้งเลย sort ที่ spill ยังช้ากว่า sort ที่มี index ดังนั้นให้มี index รองรับ sort key เมื่อ collection โตขึ้น
ใน Compass: drop-down Options ของ query bar เพิ่มกล่อง Project, Sort, Limit และ Skip ข้าง Filter คุณจึงสร้าง query ทั้งหมดได้โดยไม่ต้องเขียน chain ของเมธอดเอง (เอกสาร Compass, mongodb.com/docs/compass/current/query/filter, เรียกอ่านเมื่อ 2026-09-30)
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- sort ก่อนแบ่งหน้าเสมอ และทำให้ sort เป็นลำดับสมบูรณ์ ถ้าไม่ sort ลำดับไม่ถูกกำหนด; ถ้า sort key ไม่ซ้ำไม่ได้ หน้าอาจซ้ำหรือข้าม document (ขาด 4 916 ด้านบน) เพิ่ม
_id(หรือ field ที่ไม่ซ้ำ) เป็น key เรียงสุดท้าย _idถูกรวมเว้นแต่คุณยกเว้น เพิ่ม_id: 0ใน projection เมื่อไม่ต้องการ นี่เป็นกรณีเดียวที่ผสมการยกเว้นกับลิสต์ที่รวมได้- อย่าผสมรวมกับยกเว้น นอกจากนั้น (
Cannot do exclusion on field joined in inclusion projection) - ใช้ inclusion projection สำหรับทุกอย่างที่เป็น public ตัวอย่าง exclusion รั่ว
cost - ต้นทุน
skipใหญ่โตตาม offset (900 010 key สำหรับหน้าสุดท้าย); ใช้ range-based pagination บน key ที่ index และไม่ซ้ำสำหรับหน้าลึกหรือ infinite scroll - การเรียง string เริ่มต้นเป็น binary:
Zedก่อนalanใช้ collation ({ locale: "en" }) เพื่อลำดับที่เป็นมิตร - ลำดับการต่อเมธอดไม่สำคัญ; server กรอง เรียง skip limit เสมอ
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”ผู้ช่วย AI ที่ถูกขอ “แบ่งหน้ารายชื่อสมาชิก หน้าละ 1 000” จะเขียนเวอร์ชันตำรา:
🐛 หา bug ในโค้ดนี้
// AI comment: "stable pagination: sort by grade, then skip/limit"async function getPage(db, page) {return db.collection("members") .find({}) .sort({ grade: 1 }) .skip(page * 1000) .limit(1000) .toArray();}เฉลย
grade ไม่ซ้ำ ลำดับระหว่าง grade เท่ากันจึงไม่ถูกกำหนดและอาจต่างกันระหว่าง query หน้าหนึ่งกับอีกหน้า ในการรันด้านบน รูปแบบนี้เมื่อรันบน 20 000 document ส่งจำนวนแถวถูก (20 000) แต่มีเพียง 15 084 document ที่ไม่ซ้ำ — สมาชิก 4 916 คนไม่ปรากฏบนหน้าใดเลย และอีกหลายคนปรากฏสองหน้า ไม่มี error ไม่มีคำเตือน ทางแก้คือลำดับ สมบูรณ์ — เพิ่ม key ที่ไม่ซ้ำไว้ท้ายสุด:
const c = db.collection("paged");await c.insertMany(Array.from({ length: 5000 }, (_, i) => ({ i, grade: i % 5 })));
async function getPage(page) { return c.find({}, { projection: { i: 1 } }) .sort({ grade: 1, _id: 1 }) // grade, then a unique tie-breaker .skip(page * 1000) .limit(1000) .toArray();}
const seen = new Set();for (let p = 0; p < 5; p++) (await getPage(p)).forEach((d) => seen.add(d.i));console.log({ pages: 5, distinctDocuments: seen.size });if (seen.size !== 5000) throw new Error("every document should appear on exactly one page");{ pages: 5, distinctDocuments: 5000 }Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”การอ่านคือ pipeline เล็ก ๆ: filter -> sort -> skip -> limit -> project โดย cursor ส่งผลลัพธ์เป็น batch projection ปั้นแต่ละ document, sort ปั้นลำดับ, และ skip/limit หั่น ทุกขั้นที่ต้องมอง document ที่จะไม่คืน (skip ลึก, sort ที่ไม่มี index) มีต้นทุนตามสัดส่วนของสิ่งที่ทิ้ง pagination ถูกต้องเมื่อ sort เป็นลำดับ สมบูรณ์ เท่านั้น และถูกเมื่อหน้าถัดไปไปถึงได้ด้วย range บน key ที่มี index เท่านั้น
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| ตัวเลือก | Benefit | Cost |
|---|---|---|
Inclusion projection ({ name: 1, joined: 1 }) | ควบคุม field ที่ออกไปอย่างชัดเจน — ปลอดภัยสำหรับ response สาธารณะ | ต้องอัปเดต query ทุกครั้งที่ต้องการ field ใหม่ |
Exclusion projection ({ internalNotes: 0 }) | สะดวก เขียนสั้น ไม่ต้องลิสต์ทุก field ที่ต้องการ | field ที่เพิ่มใน schema ภายหลังรั่วออกไปอัตโนมัติ (cost ด้านบน) |
| pagination แบบ skip/limit | เขียนง่าย เหมาะกับหน้าตื้นและ UI แบบเลขหน้า | skip เดินผ่านทุก document ที่ข้าม: 900 010 key สำหรับหน้าสุดท้ายของ 1M; ต้องมี sort สมบูรณ์ |
| pagination แบบ range/cursor | อ่านเฉพาะ document ที่ต้องการเสมอ (10 key, 1 ms) — สเกลตามข้อมูล | กระโดดไปหน้า N ตรง ๆ ไม่ได้; ต้องการ field ที่เรียงได้ มี index และไม่ซ้ำพอ |
| sort โดยไม่มี index | ใช้ได้กับทุก field | sort ในหน่วยความจำ; เกินเพดานจะ spill ลงดิสก์ (ช้า) หรือ error ด้วย allowDiskUse(false) |
| sort ด้วย collation | ลำดับที่เป็นมิตรตามภาษา | option เพิ่มในทุก query (และ index) ที่ต้องตรงกัน |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “ต่อ
.limit()ก่อน.sort()แปลว่า limit ก่อน” — server ใช้ filter, sort, skip, limit ตามลำดับตายตัว; ทั้งสอง chain คืนหน้าเดียวกัน - “แบ่งหน้าด้วย skip/limit ถูกต้องถ้า sort” — ก็ต่อเมื่อ sort สมบูรณ์; เมื่อมีค่าซ้ำ 4 916 จาก 20 000 document หายไป
- “sort ที่เกินเพดาน error” — ตั้งแต่ 6.0 spill ลงดิสก์เป็นค่าเริ่มต้น; error เฉพาะกับ
allowDiskUse(false) - “
limitทำให้ query ถูก” — มันจำกัดแถวที่คืน ไม่ใช่การสแกน; sort ที่ไม่มี index ยังประมวลผลทั้งหมด - “Exclusion projection ปลอดภัย” — การเปลี่ยน schema ภายหลังทำให้ field รั่ว
- “การเรียง string เริ่มต้นเป็นตามตัวอักษร” — เป็น binary:
Zedก่อนalan
ตัวอย่างจากของจริง
reference ของ
cursor.skip()ในคู่มือ MongoDB เตือนเองว่า skip “requires the server to scan from the beginning of the input results set before beginning to return results. As the offset increases,skip()will become slower” และชี้ไปยัง range query สำหรับ pagination (cursor.skip, เรียกอ่านเมื่อ 2026-09-30) — รูปแบบที่เราวัดได้ 900 010 key
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (projection, sort, skip/limit และ loop ของ cursor คืนสมาชิกชุดเดียวกัน) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส