Anti-pattern
schema MongoDB ที่แย่ส่วนใหญ่ไม่ได้แย่เพราะมีคนเมินกฎ - แต่แย่เพราะการตัดสินใจที่ดูสมเหตุสมผลเสื่อมเมื่อเวลาผ่านไป array ที่ embed ซึ่งโอเคที่สิบสมาชิกกลายเป็นภาระที่หมื่นสมาชิก บทสุดท้ายนี้รวบรวม anti-pattern ที่เกิดซ้ำบ่อยที่สุด: หน้าตาเป็นอย่างไร ทำไมเจ็บ (พร้อมตัวเลขเมื่อมี) และวิธีแก้ที่ชัดเจน
จับจาก mongo 8.3.11, mongosh 2.11.1 และ Node driver 7.7.0 บน Mac เครื่องเดียว; แท็บ Python, Go และ Rust ถูกรันอย่างละครั้งในโปรแกรม scratch บทนี้เตรียม collection people ที่มีชื่อ 50 003 รายการสำหรับสาธิต collation ส่วนอื่นเตรียมสิ่งที่ต้องใช้เอง
globalThis.plan = (cur) => { const e = cur.explain("executionStats"), s = e.executionStats, stages = []; for (let p = e.queryPlanner.winningPlan; p; p = p.inputStage) stages.push(p.stage + (p.indexName ? "(" + p.indexName + ")" : "")); return stages.join(" > ") + " returned=" + s.nReturned + " keys=" + s.totalKeysExamined + " docs=" + s.totalDocsExamined;};const names = ["Ada", "Grace", "Linus", "Margaret", "Edsger"];db.people.insertMany(Array.from({ length: 50000 }, (_, i) => ({ name: (i % 500 === 0 ? ["ADA", "ada", "Ada"][(i / 500) % 3] : names[i % 5]) + i, city: "c" + (i % 100) })));db.people.insertMany([{ name: "Ada" }, { name: "ada" }, { name: "ADA" }]);console.log("people:", db.people.countDocuments());people: 50003array ขนาดใหญ่ที่ไม่จำกัด
หัวข้อที่มีชื่อว่า “array ขนาดใหญ่ที่ไม่จำกัด”anti-pattern ที่พบบ่อยที่สุดคือการ embed array ที่โตไม่จำกัด - ทุกคอมเมนต์ของโพสต์ยอดนิยม ทุกเหตุการณ์ในประวัติของผู้ใช้ บทก่อนวัดไว้แล้ว: update เล็ก ๆ จาก 1.4 ms เป็น 7.6 ms เมื่อ array ถึง 15 MB และการเขียนล้มเหลวที่ push ที่ 2 090 ด้วย Resulting document after update is larger than 16777216 วิธีแก้: ย้ายลูกไป collection ของมันและ reference แม่จากลูกแต่ละตัว (พร้อม index) หรือใช้ pattern Bucket จำกัดการโตต่อช่วง โพสต์เก็บตัวนับ และคอมเมนต์อยู่แยกเพื่อแบ่งหน้าได้:
{ "_id": "post_310", "title": "Why documents win", "commentCount": 8421 }{ "_id": "cmt_9", "postId": "post_310", "user": "Grace", "text": "Great read." }document เดี่ยวที่บวม
หัวข้อที่มีชื่อว่า “document เดี่ยวที่บวม”ที่เกี่ยวข้องกันคือความอยากยัด ทุกอย่าง ของ entity ลง document เดียว - ผู้ใช้พร้อมบันทึกกิจกรรมทั้งหมดข้างใน การอ่านแค่อีเมลยังแตะ document ขนาดใหญ่ และ cache ถือมันทั้งหมด ผู้ใช้ 300 คน กิจกรรมคนละ 1 000 รายการ เทียบกับผู้ใช้ชุดเดียวกันที่ย้ายกิจกรรมออก:
const event = { at: new Date(), kind: "page_view", url: "/some/path/with/query?x=1", ua: "Mozilla/5.0 (X11; Linux x86_64) AppleWebKit/537.36", tags: ["a", "b", "c"] };db.usersFat.insertMany(Array.from({ length: 300 }, (_, i) => ({ _id: i, email: "u" + i + "@example.test", activity: Array.from({ length: 1000 }, () => event) })));db.usersSlim.insertMany(Array.from({ length: 300 }, (_, i) => ({ _id: i, email: "u" + i + "@example.test", activityCount: 1000 })));db.usersActivity.insertMany(Array.from({ length: 300 }, (_, i) => ({ userId: i, activity: Array.from({ length: 1000 }, () => event) }))); // the cold data, moved outconst size = (c) => { const b = db[c].stats().size; return b > 1048576 ? +(b / 1048576).toFixed(1) + " MB" : Math.round(b / 1024) + " KB"; };const time = (f) => { const t = Date.now(); f(); return Date.now() - t; };console.log("data size - fat:", size("usersFat"), "| slim:", size("usersSlim"), "(+ the moved-out activity collection:", size("usersActivity") + ")");console.log("3 000 reads of just the email (projection { email: 1 }):");console.log({ fat: time(() => { for (let i = 0; i < 3000; i++) db.usersFat.findOne({ _id: i % 300 }, { email: 1 }); }) + " ms", slim: time(() => { for (let i = 0; i < 3000; i++) db.usersSlim.findOne({ _id: i % 300 }, { email: 1 }); }) + " ms" });data size - fat: 49.8 MB | slim: 18 KB (+ the moved-out activity collection: 49.8 MB)3 000 reads of just the email (projection { email: 1 }):{ fat: '891 ms', slim: '787 ms' }การอ่านบนเครื่องที่ warm พร้อม projection ต่างกันไม่มาก (891 ms เทียบ 787 ms สำหรับอ่าน 3 000 ครั้ง) - projection ประหยัดเครือข่าย ไม่ใช่งานของ server - แต่ข้อมูลที่อยู่ประจำคือ 49.8 MB เทียบ 18 KB และรอยเท้านั้น (cache, I/O, backup) คือต้นทุนถาวร วิธีแก้: เก็บ field ที่ร้อนใน document หลักและย้ายข้อมูลย่อยที่เย็นหรือใหญ่ไปยัง collection ที่ reference การ embed ข้อมูลเย็นไม่ได้อะไรเลย
reference มากเกินไป
หัวข้อที่มีชื่อว่า “reference มากเกินไป”ความผิดพลาดตรงข้ามคือแยกจนหน้าจอเดียวต้อง lookup เป็นโหล - ปัญหา join แบบ relational อีกครั้ง order ลูกค้า สินค้าสามรายการ และหมวดของมัน นับด้วย profiler:
db.categories.insertMany([{ _id: "cat_1", name: "Databases" }, { _id: "cat_2", name: "Systems" }]);db.customers.insertOne({ _id: "cust_77", name: "Grace Hopper", city: "Cambridge" });db.prods.insertMany([{ _id: "BK-1", title: "Distributed Systems", categoryId: "cat_2" }, { _id: "BK-2", title: "Designing Data-Intensive Applications", categoryId: "cat_1" }, { _id: "BK-3", title: "Compilers", categoryId: "cat_2" }]);db.ordersRef2.insertOne({ _id: "o1", customerId: "cust_77", items: [{ productId: "BK-1", qty: 1 }, { productId: "BK-2", qty: 2 }, { productId: "BK-3", qty: 1 }] });db.ordersExt.insertOne({ _id: "o1", customer: { id: "cust_77", name: "Grace Hopper" }, items: [{ productId: "BK-1", title: "Distributed Systems", category: "Systems", qty: 1 }, { productId: "BK-2", title: "Designing Data-Intensive Applications", category: "Databases", qty: 2 }, { productId: "BK-3", title: "Compilers", category: "Systems", qty: 1 }] });db.setProfilingLevel(2);const o = db.ordersRef2.findOne({ _id: "o1" }); // 1: the orderdb.customers.findOne({ _id: o.customerId }); // 2: its customerfor (const i of o.items) { const p = db.prods.findOne({ _id: i.productId }); db.categories.findOne({ _id: p.categoryId }); } // 3 products + 3 categoriesdb.setProfilingLevel(0);const n1 = db.system.profile.countDocuments({ "command.find": { $in: ["ordersRef2", "customers", "prods", "categories"] } });db.system.profile.drop(); db.setProfilingLevel(2);db.ordersExt.findOne({ _id: "o1" });db.setProfilingLevel(0);const n2 = db.system.profile.countDocuments({ "command.find": "ordersExt" });console.log("queries to render the order page - fully normalized:", n1, "| embedded snapshot + extended reference:", n2);assert.strictEqual(n2, 1);queries to render the order page - fully normalized: 8 | embedded snapshot + extended reference: 1แปดคิวรีเทียบหนึ่ง วิธีแก้: embed ข้อมูลที่อ่านด้วยกันจริงและมีขอบเขต หรือใช้ pattern Extended Reference คัดลอกหนึ่งสอง field ที่แสดงเสมอ (title, category, ชื่อลูกค้า) เพื่อให้การอ่านทั่วไปไม่กระจายออก snapshot บน order ยังถูกต้องเชิงประวัติศาสตร์ ดังที่บท embedding แสดง
การจับคู่แบบไม่สนตัวพิมพ์ใหญ่เล็ก
หัวข้อที่มีชื่อว่า “การจับคู่แบบไม่สนตัวพิมพ์ใหญ่เล็ก”การค้นแบบไม่สนตัวพิมพ์ใหญ่เล็ก ("Ada", "ada", "ADA") ล่อให้ใช้ regex อย่าง /^ada$/i บทเดิมบอกว่า collation “บน collection หรือ index” ทำให้ query equality ธรรมดาไม่สนตัวพิมพ์ และ ใช้ index ได้ ครึ่งหนึ่งผิด สี่การตั้งค่าบนชื่อ 50 003 รายการ ซึ่งสามอันตรงกับ ada ทุกตัวพิมพ์:
console.log("1. regex /^ada$/i, plain index on name :");db.people.createIndex({ name: 1 });console.log(" ", plan(db.people.find({ name: /^ada$/i })));db.people.dropIndex("name_1");db.people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 }, name: "name_ci" });console.log("2. index with collation, plain query :", plan(db.people.find({ name: "ada" })), "-> matches:", db.people.find({ name: "ada" }).toArray().length);console.log("3. index with collation, SAME collation :", plan(db.people.find({ name: "ada" }).collation({ locale: "en", strength: 2 })), "-> matches:", db.people.find({ name: "ada" }).collation({ locale: "en", strength: 2 }).toArray().length);db.createCollection("peopleCI", { collation: { locale: "en", strength: 2 } });db.peopleCI.insertMany(db.people.find({}, { _id: 0 }).toArray());db.peopleCI.createIndex({ name: 1 }); // inherits the collection collationconsole.log("4. collection default collation, plain :", plan(db.peopleCI.find({ name: "ada" })), "-> matches:", db.peopleCI.find({ name: "ada" }).toArray().length);assert.strictEqual(db.peopleCI.find({ name: "ada" }).toArray().length, 3);const people = db.collection("people");await people.insertMany(["Ada", "ada", "ADA", "Grace"].map((name) => ({ name })));await people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 }, name: "name_ci" });const plain = await people.find({ name: "ada" }).toArray();const ci = await people.find({ name: "ada" }).collation({ locale: "en", strength: 2 }).toArray();const stage = async (cur) => { const e = await cur.explain("queryPlanner"); let p = e.queryPlanner.winningPlan; while (p.inputStage) p = p.inputStage; return p.stage; };console.log({ plainMatches: plain.length, plainStage: await stage(people.find({ name: "ada" })), ciMatches: ci.length, ciStage: await stage(people.find({ name: "ada" }).collation({ locale: "en", strength: 2 })) });if (plain.length !== 1 || ci.length !== 3) throw new Error("collation behaviour unexpected");from pymongo.collation import Collation
ci = Collation(locale="en", strength=2)db.people.create_index([("name", 1)], collation=ci, name="name_ci")rows = list(db.people.find({"name": "ada"}, collation=ci))ci := &options.Collation{Locale: "en", Strength: 2}_, err := coll.Indexes().CreateOne(ctx, mongo.IndexModel{ Keys: bson.D{{Key: "name", Value: 1}}, Options: options.Index().SetName("name_ci").SetCollation(ci),})if err != nil { return err}cur, err := coll.Find(ctx, bson.M{"name": "ada"}, options.Find().SetCollation(ci))if err != nil { return err}var rows []bson.Mif err := cur.All(ctx, &rows); err != nil { return err}let ci = Collation::builder() .locale("en".to_string()) .strength(CollationStrength::Secondary) .build();coll.create_index( IndexModel::builder() .keys(doc! { "name": 1 }) .options( IndexOptions::builder() .name("name_ci".to_string()) .collation(ci.clone()) .build(), ) .build(),).await?;let mut cursor = coll .find(doc! { "name": "ada" }) .collation(ci) .await?;1. regex /^ada$/i, plain index on name : FETCH > IXSCAN(name_1) returned=3 keys=50003 docs=32. index with collation, plain query : COLLSCAN returned=1 keys=0 docs=50003 -> matches: 13. index with collation, SAME collation : FETCH > IXSCAN(name_ci) returned=3 keys=3 docs=3 -> matches: 34. collection default collation, plain : FETCH > IXSCAN(name_1) returned=3 keys=3 docs=3 -> matches: 3{ plainMatches: 1, plainStage: 'COLLSCAN', ciMatches: 3, ciStage: 'IXSCAN'}- regex ใช้ index แต่เป็น การสแกนทั้ง 50 003 key เพื่อคืน 3 document (seek ไม่ได้)
- index ที่มี collation ถูกใช้ เฉพาะโดย query ที่ระบุ collation เดียวกัน query ธรรมดาเป็น COLLSCAN และ match แค่ document ที่ตัวพิมพ์ตรง
- ส่ง collation เดียวกันใน query ได้ 3 key, 3 match
- collation เริ่มต้นบน collection คือการตั้งค่าที่ query ธรรมดาและ index ของมัน match แบบไม่สนตัวพิมพ์ได้โดยไม่ต้องจำ option ทุกครั้ง
ทุกอย่างใน collection เดียว
หัวข้อที่มีชื่อว่า “ทุกอย่างใน collection เดียว”สัญชาตญาณที่จะโยนหลาย entity ที่ไม่เกี่ยวกันลง collection เดียวโดยแยกด้วย field type ทำให้ index ครอบคลุม document ที่ไม่เกี่ยว และ validator อธิบายรูปทรงเดียวไม่ได้ index ธรรมดาบน email ยังเก็บ entry ของทุก document ที่ไม่มี field นั้นด้วย:
const kb = (c, n) => Math.round(db[c].stats().indexSizes[n] / 1024);db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "user", email: "u" + i + "@example.test", name: "User " + i })));db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "order", total: i % 100, userId: i % 30000 })));db.everything.insertMany(Array.from({ length: 30000 }, (_, i) => ({ type: "log", level: "info", message: "event " + i })));db.users2.insertMany(Array.from({ length: 30000 }, (_, i) => ({ email: "u" + i + "@example.test", name: "User " + i })));db.everything.createIndex({ email: 1 });db.everything.createIndex({ email: 1 }, { name: "email_partial", partialFilterExpression: { type: "user" } });db.users2.createIndex({ email: 1 });console.log("documents:", db.everything.countDocuments(), "| with an email:", db.everything.countDocuments({ email: { $exists: true } }), "| without one (indexed as null by a plain index):", db.everything.countDocuments({ email: { $exists: false } }));console.log("index KB - mixed collection, plain { email: 1 }:", kb("everything", "email_1"), "| mixed, partial on type:user:", kb("everything", "email_partial"), "| users-only collection:", kb("users2", "email_1"));console.log("a lookup for a missing email on the plain index does an index seek into the null entries:", plan(db.everything.find({ email: null }).limit(1)));documents: 90000 | with an email: 30000 | without one (indexed as null by a plain index): 60000index KB - mixed collection, plain { email: 1 }: 952 | mixed, partial on type:user: 748 | users-only collection: 748a lookup for a missing email on the plain index does an index seek into the null entries: LIMIT > FETCH > IXSCAN(email_1) returned=1 keys=1 docs=1ที่นี่ความสิ้นเปลืองไม่มาก (952 KB เทียบ 748 KB) แต่โตตามจำนวน document ที่ไม่เกี่ยวกัน วิธีแก้: หนึ่ง collection ต่อ entity หรืออย่างน้อยใช้ partial index ที่กรองด้วย type ส่วนสุดโต่งอีกด้านก็เป็นกับดัก: Atlas ระบุ “Reduce Number of Collections” ในคำแนะนำ schema จึงควรแยกตาม entity ไม่ใช่ตาม tenant หรือรายวัน (Atlas schema suggestions, ตรวจ 2026-09-30)
anti-pattern และวิธีแก้
หัวข้อที่มีชื่อว่า “anti-pattern และวิธีแก้”flowchart LR A1["Massive unbounded array"] --> F1["Reference children or use the Bucket pattern"] A2["Bloated single document"] --> F2["Keep hot fields, reference cold and large data"] A3["Over-referencing everywhere"] --> F3["Embed co-accessed data or extend the reference"] A4["Case-insensitive via regex"] --> F4["Collection default collation, or pass the index collation"] A5["Everything in one collection"] --> F5["One entity per collection"]
เคล็ดลับและข้อควรระวัง
หัวข้อที่มีชื่อว่า “เคล็ดลับและข้อควรระวัง”- anti-pattern แทบไม่เคยดูผิดในวันแรก; ทบทวน schema โดยคิดถึงการเติบโต
- “array ไม่จำกัด” และ “document บวม” คือสองหน้าของการ embed ข้อมูลที่ควร reference
- การ reference มากเกินไปคือการแก้เกินจริง: 8 query สำหรับหนึ่งหน้า ตัดสินทีละความสัมพันธ์
- สำหรับการจับคู่แบบไม่สนตัวพิมพ์ใหญ่เล็กใช้ collation เริ่มต้นของ collection หรือให้ query ระบุ collation ของ index; regex ที่มี
iสแกนทั้ง index - MongoDB Atlas แสดงหลายข้อเหล่านี้ (array ไม่จำกัด, document บวม,
$lookupมากเกินไป, collection มากเกินไป, index ที่ไม่จำเป็น) ใน Performance Advisor ซึ่งใช้ได้บน cluster M10 ขึ้นไป และใน Compass Performance Insights
อ่านโค้ดที่ AI สร้าง
หัวข้อที่มีชื่อว่า “อ่านโค้ดที่ AI สร้าง”เมื่อถูกขอให้ “ทำให้การค้นชื่อไม่สนตัวพิมพ์ใหญ่เล็กและเร็ว” AI สร้าง index แบบไม่สนตัวพิมพ์ใหญ่เล็กและ query เหมือนเดิม:
🐛 หา bug ในโค้ดนี้
// AI comment: "the index is case-insensitive, so this find is too - and it uses the index"await people.createIndex({ name: 1 }, { collation: { locale: "en", strength: 2 } });const rows = await people.find({ name: "ada" }).toArray();เฉลย
query ใช้ index ที่มี collation ก็ต่อเมื่อระบุ collation เดียวกัน หากไม่ระบุ find ข้างบนเป็น COLLSCAN ทั้ง 50 003 document และ match แค่ ada ที่ตัวพิมพ์ตรง (1 match ไม่ใช่ 3) - ผลผิด และ ช้า โดยไม่มี error ให้เพิ่ม .collation({ locale: "en", strength: 2 }) ในทุก query หรือ (ดีกว่า) สร้าง collection ด้วย collation เริ่มต้นนั้น output ของ fence ข้างบนแสดงว่าทั้งสองวิธีใช้ได้ (IXSCAN, 3 match)
Mental Model
หัวข้อที่มีชื่อว่า “Mental Model”anti-pattern คือการตัดสินใจที่ต้นทุนโตตามข้อมูลขณะที่ประโยชน์คงที่: array ที่โต document ที่แบกน้ำหนักเย็น query ที่สแกนทั้ง index ถามทุกการเลือกออกแบบว่า “ที่ข้อมูลมากขึ้น 100 เท่าเกิดอะไร” - คำตอบคือตัวเลขที่คงที่ หรือตัวเลขที่อธิบายเหตุการณ์ปีหน้า
ข้อแลกเปลี่ยน
หัวข้อที่มีชื่อว่า “ข้อแลกเปลี่ยน”| แนวทาง | ข้อดี | ต้นทุน |
|---|---|---|
| ออกแบบอย่างมีวินัยล่วงหน้า | anti-pattern น้อยกว่า; แก้ตอนโตง่ายกว่า | ใช้เวลาออกแบบมากกว่าก่อนเขียนโค้ด |
| ออกแบบเร็ว แก้ทีหลัง | ส่งมอบเร็วกว่า | migration ท่ามกลางแรงกดดัน (dual-write, backfill) |
| collation บน collection | query ธรรมดาไม่สนตัวพิมพ์และใช้ index | การเปรียบเทียบ string ทั้งหมดใน collection เปลี่ยน |
| collation บน index เดียว | เปลี่ยนเฉพาะ index นั้น | ทุก query ต้องระบุ collation ซ้ำ |
Common Misconceptions
หัวข้อที่มีชื่อว่า “Common Misconceptions”- “anti-pattern คือความผิดพลาดของมือใหม่” - ส่วนใหญ่สมเหตุสมผลตอนออกแบบและเสื่อมเมื่อข้อมูลโต
- “collation บน index ทำให้ query ธรรมดาไม่สนตัวพิมพ์” - query ธรรมดาเป็น COLLSCAN และ match 1 รายการ
- “regex บน field ที่มี index ไม่เป็นไร” -
/^ada$/iอ่านทั้ง 50 003 key - “collection ฟรี จึงแยกได้ไม่จำกัด” - Atlas เตือนเรื่อง collection มากเกินไป; แยกตาม entity
- “แก้ schema ด้วยการ deploy การเปลี่ยนแปลง” - การย้าย array ที่ embed ไปเป็น reference บนข้อมูลจริงต้อง rollout เป็นขั้น (dual-write แล้ว backfill)
ตัวอย่างจากของจริง
Atlas เปลี่ยนสิ่งเหล่านี้เป็นฟีเจอร์ของผลิตภัณฑ์: Performance Advisor และ Compass Performance Insights “identify schema design anti-patterns in your database” - array ไม่จำกัด, document บวม,
$lookupมากเกินไป, collection มากเกินไป และ index ที่ไม่จำเป็น (schema suggestions, ตรวจ 2026-09-30; Performance Advisor ต้องใช้ cluster M10 ขึ้นไป)
แท็บ Python/Go/Rust สะท้อนพฤติกรรมที่พิสูจน์แล้วของ shell/Node; แต่ละแท็บถูกรันหนึ่งครั้งในโปรแกรม scratch กับ server เดียวกัน (query ที่ระบุ collation คืนทั้งสามรูปแบบตัวพิมพ์) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส