Index ที่ประกาศ ≠ index ที่มีจริงบน prod: กับดัก autoIndex, syncIndexes และ aggregation order
COLLSCAN บน hot collection ทั้งที่ working set อยู่ใน RAM คือ smoking gun ว่า index ที่คุณ "ประกาศใน schema" ไม่เคยถูก build จริงบน prod — บทเรียนจริงเรื่อง autoIndex:false, ทำไม syncIndexes() อันตราย และทำไมลำดับ stage ใน aggregation ตัดสินระหว่างใช้ index กับ OOM
อ่าน ~9 นาที
เริ่มจากอาการ: CPU พุ่งทั้งที่ข้อมูลอยู่ใน RAM
ผมเจอเคสนี้ครั้งแรกตอนที่ query ตัวหนึ่งบน production ช้าขึ้นเรื่อยๆ ตาม data ที่โต ทั้งที่ในเครื่อง dev มันเร็วปกติ อาการคลาสสิกคือ CPU ของ database พุ่งสูงเป็นช่วงๆ ทั้งที่ dataset ทั้งก้อนเล็กพอที่จะอยู่ใน RAM ได้สบาย นั่นแปลว่ามันไม่ใช่ปัญหา disk I/O แต่เป็นปัญหา เผา CPU ไปกับการ scan document ทีละตัว
เปิด explain plan ดู ก็เจอตัวการทันที: COLLSCAN บน collection ที่ถูก query บ่อยที่สุด ทั้งที่ผมมั่นใจว่า "ประกาศ index ไว้ใน schema แล้ว" นี่คือจุดเริ่มของบทเรียนที่ผมอยากถ่ายทอด — เพราะมันเป็นกับดักที่ ORM/ODM สมัยใหม่ซ่อนไว้อย่างแนบเนียน
Thesis: "ประกาศใน schema" ≠ "ถูก build จริงบน prod"
ODM อย่าง Mongoose ให้คุณเขียน schema.index({ ... }) ได้ และในเครื่อง dev มันจะ build index ให้อัตโนมัติเวลาแอปสตาร์ท ฟีเจอร์นี้ชื่อ autoIndex และมันทำให้ dev ทุกคนเข้าใจผิดว่า "ประกาศแล้ว = มีแล้ว"
แต่บน production เกือบทุก guideline บอกให้ปิดมัน:
// การตั้งค่าที่พบบ่อยบน production
mongoose.connect(uri, {
autoIndex: false, // ปิดการ build index อัตโนมัติตอนแอปสตาร์ท
});
เหตุผลที่ต้องปิดก็สมเหตุสมผล: การ build index บน collection ที่มีข้อมูลเยอะเป็น operation ที่กิน resource หนัก ถ้าปล่อยให้แอปทุก instance พยายาม build index ตอนสตาร์ทพร้อมกันตอน deploy ระบบอาจ freeze หรือ lock ยาวได้ การปิด autoIndex คือการตัดสินใจที่ถูกต้องด้าน operations
แต่ผลข้างเคียงคือ: เมื่อปิด autoIndex แล้ว index ที่คุณเขียนไว้ใน schema จะ ไม่ถูก materialize เป็น index จริง บน prod เลย — มันเป็นแค่คำประกาศบนกระดาษ query ทั้งหมดที่คุณคิดว่าใช้ index อยู่จริงๆ กำลังทำ COLLSCAN เงียบๆ จนกว่าข้อมูลจะโตพอให้คุณรู้สึก
ทางแก้: explicit idempotent migration + verify ด้วยความจริง
หลักการที่ผมยึดคือ อย่าไว้ใจสิ่งที่มองไม่เห็น การ build index บน prod ต้องเป็น step ที่ตั้งใจทำ ไม่ใช่ผลพลอยได้ของการสตาร์ทแอป ผมทำเป็น migration script ที่รันแยกหลัง deploy และเขียนให้ idempotent — รันซ้ำกี่รอบก็ได้ผลเหมือนเดิม ไม่พัง
// migration/ensure-indexes.js — รันหลัง deploy, idempotent
async function ensureIndexes(db) {
const events = db.collection('events');
// createIndex เป็น idempotent โดยธรรมชาติ:
// ถ้า index (ชื่อ+spec) มีอยู่แล้ว จะ no-op ไม่ error
await events.createIndex(
{ building: 1, timestamp: -1 },
{ name: 'building_ts', background: true }
);
await events.createIndex(
{ createdAt: 1 },
{ name: 'ttl_createdAt', expireAfterSeconds: 60 * 60 * 24 * 90 }
);
}
แต่ที่สำคัญกว่าการรัน migration คือ การ verify บนความจริง ไม่ใช่บนสมมติฐาน RULE ที่ผมท่องไว้เสมอคือ "รันจริง แล้วดู output จริง" — หลัง migration เสร็จ ผมยิงคำสั่งดู index จริงบน prod:
// เชื่อมต่อ prod (read-only) แล้วถามความจริง
db.events.getIndexes()
// ต้องเห็น building_ts และ ttl_createdAt อยู่ในผลลัพธ์จริง
// ไม่ใช่แค่ "มันควรจะมี" เพราะเราเขียน schema ไว้แล้ว
ยิง explain อีกครั้งเพื่อยืนยันว่า query เปลี่ยนจาก COLLSCAN เป็น IXSCAN จริง — explain plan คือหลักฐาน ไม่ใช่ความรู้สึก
ห้าม syncIndexes() บน shared prod: มันคือ reconcile-by-delete
ตอนหาทางแก้ ผมเกือบพลาดใช้ syncIndexes() ซึ่งฟังดูสวยงาม — มัน "ทำให้ index บน DB ตรงกับที่ประกาศใน schema" ฟังดูเป็นสิ่งที่เราต้องการเป๊ะ แต่คำว่า "ทำให้ตรงกัน" มีนัยที่อันตราย
syncIndexes() ทำงานแบบ reconcile-by-delete: มันเทียบ index บน DB กับ schema แล้ว ลบทุก index ที่ไม่ได้ประกาศใน schema ทิ้ง ปัญหาคือบน production จริง มักมี index ที่ทีม ops สร้างมือไว้ตอน firefight — เช่นตอนเจอ query ช้ากลางดึกแล้วต้อง hotfix ด้วยการ createIndex ตรงๆ index พวกนี้มีอยู่จริงและกำลังพยุงระบบอยู่ แต่มันไม่ได้อยู่ใน schema code
ถ้าคุณรัน syncIndexes() ตอน deploy รอบถัดไป มันจะลบ index กู้ชีพเหล่านั้นทิ้งเงียบๆ แล้วระบบก็กลับไปช้าเหมือนเดิมโดยไม่มีใครรู้สาเหตุ นี่คือบทเรียนเชิงหลักการ:
// อันตราย: destructive โดยธรรมชาติ — ลบ index ที่ไม่อยู่ใน schema
await Model.syncIndexes();
// ปลอดภัย: additive เท่านั้น — เพิ่มได้ ไม่ลบของใคร
await collection.createIndex(spec, options);
หลักการที่ transferable: operation ใดก็ตามที่บอกว่า "make reality match my declaration" เป็น destructive โดยธรรมชาติ เพราะ reality บน prod เป็น superset ของสิ่งที่ code รู้เสมอ บน shared prod ให้ใช้ operation แบบ additive (สร้างถ้ายังไม่มี) และแยกการ "ลบ index" ออกมาเป็น decision ที่มนุษย์ review ทีละอัน อย่าให้ automation ลบอะไรบน prod โดยอัตโนมัติ
Aggregation stage order = ใช้ index หรือ OOM
อีกกับดักที่เจ็บกว่าคือ aggregation pipeline หลายคนเขียน stage ตามลำดับที่ "อ่านแล้วเข้าใจง่าย" โดยไม่รู้ว่า ลำดับ stage ตัดสินว่า MongoDB จะใช้ index ได้หรือไม่ และจะ OOM หรือไม่
กฎแรก: ดัน $match ให้อยู่บนสุดเสมอ เพราะเฉพาะ $match ที่อยู่ต้น pipeline เท่านั้นที่ใช้ index ได้ พอมี stage เปลี่ยนรูป document (เช่น $group, $project ที่คำนวณ field ใหม่) ไปแล้ว stage หลังจากนั้นจะทำงานบน in-memory stream ที่ไม่มี index รองรับอีกต่อไป
กฎที่สอง: ลด volume ก่อนถึง blocking stage — $group และ $sort (ที่ไม่มี index รองรับ) เป็น blocking stage: มันต้องกักข้อมูลไว้ในหน่วยความจำก่อนจะปล่อยผลออกไป และ MongoDB มี limit ที่ 100MB ต่อ stage เกินแล้วจะ error (หรือถ้าเปิด allowDiskUse ก็จะกระเด็นไปเขียน disk ทำให้ช้าลงมหาศาล)
// แย่: $group ทำงานบนทุก document ก่อน แล้วค่อย filter
// -> blocking stage กินข้อมูลทั้ง collection -> เสี่ยง OOM 100MB
db.events.aggregate([
{ $group: { _id: '$building', total: { $sum: 1 } } },
{ $match: { _id: 'A-01' } },
]);
// ดี: filter ด้วย index ก่อน แล้วค่อย group บน subset เล็กๆ
db.events.aggregate([
{ $match: { building: 'A-01', timestamp: { $gte: since } } }, // ใช้ index building_ts
{ $sort: { timestamp: -1 } }, // align กับ index -> ไม่ต้อง sort ในหน่วยความจำ
{ $limit: 1000 }, // ตัด volume ก่อนถึง stage หนัก
{ $group: { _id: '$building', total: { $sum: 1 } } },
]);
จุดสำคัญที่คนมองข้าม: $match + $sort ต้อง align กับ compound index อันเดียวกัน ถ้า index คือ { building: 1, timestamp: -1 } การ match ด้วย building แล้ว sort ด้วย timestamp จะได้ทั้ง filter และ sort จาก index เดียว — MongoDB ไม่ต้อง sort ในหน่วยความจำเลย แต่ถ้าคุณ sort ด้วย field ที่ไม่อยู่ใน prefix ของ index ที่ใช้ filter มันจะกลับไปเป็น blocking in-memory sort ทันที
Data modeling ที่กัน scan ตั้งแต่ต้นทาง
สำหรับข้อมูลแบบ time-series (event log, sensor reading) ผมใช้สามเทคนิคควบกัน
1. TTL index ให้ DB purge ข้อมูลเก่าเอง — แทนที่จะเขียน cron ไปไล่ลบ document เก่า ปล่อยให้ MongoDB จัดการผ่าน expireAfterSeconds ข้อมูลที่ไม่ต้องเก็บถาวรจะถูกลบอัตโนมัติ ทำให้ collection ไม่บวมจน scan แพง
2. Compound index ตาม read shape จริง — อย่า index มั่ว ดูว่า query ที่ hot ที่สุดของคุณ filter ด้วยอะไรและ sort ด้วยอะไร แล้วออกแบบ compound index ให้ตรง เช่น ถ้าอ่าน "event ล่าสุดของ building นี้" เสมอ ก็ทำ { building: 1, timestamp: -1 } ลำดับ field ใน compound index สำคัญ — field ที่ใช้ equality filter มาก่อน field ที่ใช้ range/sort
3. Denormalize latest sample ลง parent doc — ถ้า UI ต้องโชว์ "ค่าล่าสุด" ของทุก building บนหน้า dashboard บ่อยๆ อย่าให้มัน aggregate จาก event ทั้งหมดทุกครั้ง เก็บ snapshot ของ sample ล่าสุดไว้ใน parent document เลย แลกพื้นที่นิดหน่อยเพื่อตัด scan ออกทั้งก้อน นี่คือ trade-off แบบ NoSQL คลาสสิก: write เพิ่มนิดเดียว แต่ read เร็วขึ้นมาก
ref-based NoSQL ไม่มี FK: dangling reference เป็นเรื่องปกติ
บทเรียนสุดท้ายเป็นเรื่อง data integrity ที่ dev สาย SQL มักสะดุด MongoDB แบบ ref (เก็บ ObjectId ชี้ไปอีก collection) ไม่มี foreign key constraint แปลว่าเอกสารที่ถูกชี้ถึงอาจถูกลบไปแล้ว แต่ ref ยังค้างอยู่ — นี่ไม่ใช่ bug แต่คือ runtime state ปกติที่ต้องรับมือ
// populate แล้วต้องกรอง null เสมอ — ref อาจ dangling
const orders = await Order
.find({ status: 'active' })
.populate('customer'); // customer อาจเป็น null ถ้า doc ถูกลบไปแล้ว
// กรอง dangling reference ออกก่อนใช้งาน
const valid = orders.filter(o => o.customer != null);
และเรื่องเล็กที่กลายเป็นเรื่องใหญ่: เลือก type ของคอลัมน์ ID ตามช่วงค่าจริง ถ้า external ID เป็นเลข 13 หลัก (เช่นรหัสประจำตัวบางระบบ) แล้วคุณเผลอเก็บเป็น 32-bit INT มันจะ overflow เพราะ INT รองรับได้แค่ประมาณ 2.1 พันล้าน (10 หลัก) ต้องใช้ 64-bit (BigInt/Long) หรือเก็บเป็น string ไปเลยถ้าไม่ต้องคำนวณ — ตัดสินใจจากช่วงค่าจริงของข้อมูล ไม่ใช่ default ของ ORM
สรุป: หลักการที่เอาไปใช้ต่อได้
- COLLSCAN บน hot collection ทั้งที่ข้อมูลอยู่ใน RAM = สัญญาณว่า index ที่ประกาศไว้ไม่ได้ถูก build จริง เช็ค autoIndex ก่อนเลย
- autoIndex:false บน prod ถูกต้องด้าน ops แต่ทำให้ schema index ไม่ materialize — ต้อง build ด้วย explicit idempotent migration หลัง deploy
- Verify ด้วยความจริง: รัน
getIndexes()และexplain()บน prod จริง อย่ารายงานว่าเสร็จเพราะ "เขียน schema ไว้แล้ว" - ห้าม syncIndexes() บน shared prod: มัน reconcile-by-delete จะลบ index ที่ ops สร้างมือตอน firefight ใช้ createIndex แบบ additive แทน
- "make reality match declaration" เป็น destructive โดยธรรมชาติ เพราะ prod เป็น superset ของสิ่งที่ code รู้ — การลบต้องให้มนุษย์ review
- Aggregation: ดัน
$matchขึ้นบนสุด ให้ใช้ index, align$match+$sortกับ compound index เดียวกัน, ตัด volume ด้วย$limitก่อนถึง blocking stage (100MB cap) - Data modeling กัน scan: TTL index purge เอง + compound index ตาม read shape จริง + denormalize latest sample
- ref-based NoSQL ไม่มี FK: dangling reference เป็น state ปกติ ต้อง filter null หลัง populate; เลือก type ของ ID column ตามช่วงค่าจริง (13 หลัก INT ล้น)




