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

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 ส่วนอื่นเตรียมสิ่งที่ต้องใช้เอง

mongosh/anti-seed.js
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: 50003

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." }

ที่เกี่ยวข้องกันคือความอยากยัด ทุกอย่าง ของ entity ลง document เดียว - ผู้ใช้พร้อมบันทึกกิจกรรมทั้งหมดข้างใน การอ่านแค่อีเมลยังแตะ document ขนาดใหญ่ และ cache ถือมันทั้งหมด ผู้ใช้ 300 คน กิจกรรมคนละ 1 000 รายการ เทียบกับผู้ใช้ชุดเดียวกันที่ย้ายกิจกรรมออก:

mongosh/anti-bloat.js
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 out
const 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 ข้อมูลเย็นไม่ได้อะไรเลย

ความผิดพลาดตรงข้ามคือแยกจนหน้าจอเดียวต้อง lookup เป็นโหล - ปัญหา join แบบ relational อีกครั้ง order ลูกค้า สินค้าสามรายการ และหมวดของมัน นับด้วย profiler:

mongosh/anti-over-reference.js
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 order
db.customers.findOne({ _id: o.customerId }); // 2: its customer
for (const i of o.items) { const p = db.prods.findOne({ _id: i.productId }); db.categories.findOne({ _id: p.categoryId }); } // 3 products + 3 categories
db.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 ทุกตัวพิมพ์:

mongosh/anti-collation.js
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 collation
console.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);
1. regex /^ada$/i, plain index on name :
FETCH > IXSCAN(name_1) returned=3 keys=50003 docs=3
2. index with collation, plain query : COLLSCAN returned=1 keys=0 docs=50003 -> matches: 1
3. index with collation, SAME collation : FETCH > IXSCAN(name_ci) returned=3 keys=3 docs=3 -> matches: 3
4. collection default collation, plain : FETCH > IXSCAN(name_1) returned=3 keys=3 docs=3 -> matches: 3
{
plainMatches: 1,
plainStage: 'COLLSCAN',
ciMatches: 3,
ciStage: 'IXSCAN'
}
  1. regex ใช้ index แต่เป็น การสแกนทั้ง 50 003 key เพื่อคืน 3 document (seek ไม่ได้)
  2. index ที่มี collation ถูกใช้ เฉพาะโดย query ที่ระบุ collation เดียวกัน query ธรรมดาเป็น COLLSCAN และ match แค่ document ที่ตัวพิมพ์ตรง
  3. ส่ง collation เดียวกันใน query ได้ 3 key, 3 match
  4. collation เริ่มต้นบน collection คือการตั้งค่าที่ query ธรรมดาและ index ของมัน match แบบไม่สนตัวพิมพ์ได้โดยไม่ต้องจำ option ทุกครั้ง

สัญชาตญาณที่จะโยนหลาย entity ที่ไม่เกี่ยวกันลง collection เดียวโดยแยกด้วย field type ทำให้ index ครอบคลุม document ที่ไม่เกี่ยว และ validator อธิบายรูปทรงเดียวไม่ได้ index ธรรมดาบน email ยังเก็บ entry ของทุก document ที่ไม่มี field นั้นด้วย:

mongosh/anti-one-collection.js
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): 60000
index KB - mixed collection, plain { email: 1 }: 952 | mixed, partial on type:user: 748 | users-only collection: 748
a 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)

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 ในการออกแบบแต่ละแบบเชื่อมกับวิธีแก้ที่ชัดเจนจากบทก่อนหน้า
  • 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 สร้าง 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)

anti-pattern คือการตัดสินใจที่ต้นทุนโตตามข้อมูลขณะที่ประโยชน์คงที่: array ที่โต document ที่แบกน้ำหนักเย็น query ที่สแกนทั้ง index ถามทุกการเลือกออกแบบว่า “ที่ข้อมูลมากขึ้น 100 เท่าเกิดอะไร” - คำตอบคือตัวเลขที่คงที่ หรือตัวเลขที่อธิบายเหตุการณ์ปีหน้า

แนวทางข้อดีต้นทุน
ออกแบบอย่างมีวินัยล่วงหน้าanti-pattern น้อยกว่า; แก้ตอนโตง่ายกว่าใช้เวลาออกแบบมากกว่าก่อนเขียนโค้ด
ออกแบบเร็ว แก้ทีหลังส่งมอบเร็วกว่าmigration ท่ามกลางแรงกดดัน (dual-write, backfill)
collation บน collectionquery ธรรมดาไม่สนตัวพิมพ์และใช้ indexการเปรียบเทียบ string ทั้งหมดใน collection เปลี่ยน
collation บน index เดียวเปลี่ยนเฉพาะ index นั้นทุก query ต้องระบุ collation ซ้ำ
  • “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 คืนทั้งสามรูปแบบตัวพิมพ์) แต่ไม่ถูกรันโดยตัวตรวจอัตโนมัติของคอร์ส

คุณสร้าง index ด้วย collation { locale: "en", strength: 2 } แล้วรัน find({ name: "ada" }) โดยไม่ระบุ collation เกิดอะไรขึ้น
การตั้งค่าใดทำให้ find({ name: "ada" }) ธรรมดา match แบบไม่สนตัวพิมพ์ใหญ่เล็กและใช้ index ได้
ทำไม /^ada$/i บน index ธรรมดายังช้า
การแสดงหน้า order ต้องใช้ order, ลูกค้า, สินค้า 3 รายการ และหมวด 3 รายการ โมเดล normalize เต็มใช้กี่ query
ต้นทุนหลักของ document ที่บวมซึ่งเก็บข้อมูลกิจกรรมเย็นไว้ข้าง field ที่ร้อนคืออะไร
ทำไมควรเลี่ยง collection เดียวที่มี field type สำหรับทุกอย่าง