MongoDB Indexing และ Query Optimization — คู่มือสร้างและใช้งาน Index

เมื่อข้อมูลใน Collection มีจำนวนมากขึ้น การค้นหาแบบไม่มี Index จะทำให้ฐานข้อมูลต้องอ่านทุก Document (Collection Scan) ซึ่งใช้เวลาเพิ่มขึ้นตามปริมาณข้อมูล Index ช่วยให้ MongoDB ค้นหาข้อมูลได้โดยไม่ต้องอ่านทุก Document คล้ายกับดัชนีท้ายหนังสือที่ช่วยให้หาหน้าที่ต้องการได้เร็วขึ้น

บทความนี้จะอธิบายระบบ Index ของ MongoDB ตั้งแต่ประเภทของ Index วิธีสร้างและจัดการ ไปจนถึงการใช้ explain() วิเคราะห์ Query Plan เพื่อปรับแต่งประสิทธิภาพ

ทำไมต้องมี Index

เมื่อไม่มี Index บน Field ที่ใช้ค้นหา ฐานข้อมูลจะทำ Collection Scan (COLLSCAN) คือวนอ่านทุก Document เพื่อตรวจว่าตรงเงื่อนไขหรือไม่ ถ้า Collection มี 1 ล้าน Document ก็ต้องอ่านทั้ง 1 ล้าน แม้ผลลัพธ์จะมีแค่ 1 ตัว

Index ทำงานเป็นโครงสร้างข้อมูลแบบ B-Tree ที่เก็บค่าของ Field ที่ถูก Index ไว้ในลำดับที่เรียงแล้ว พร้อมชี้ไปยัง Document จริง ทำให้ค้นหาจากปลายทั้งสองได้อย่างรวดเร็ว จากที่ต้องอ่าน 1 ล้าน Document อาจเหลือแค่อ่าน 10-20 Node ในโครงสร้าง B-Tree

ประเภทของ Index

Single Field Index

เป็น Index พื้นฐานที่สุด สร้างบน Field เดียว ค่า 1 คือเรียงจากน้อยไปมาก -1 คือมากไปน้อย ในทางปฏิบัติ MongoDB ใช้ Index ทั้งสองทิศทางได้ จึงไม่ค่อยมีผลแตกต่างสำหรับ Single Field

// สร้าง Index บน Field email
db.users.createIndex({ email: 1 })

// สร้าง Index แบบ Descending
db.logs.createIndex({ createdAt: -1 })

// สร้าง Unique Index — ค่าใน Field นี้ซ้ำไม่ได้
db.users.createIndex({ email: 1 }, { unique: true })

Compound Index

Index ที่สร้างจากหลาย Field รวมกัน ลำดับของ Field มีความสำคัญมากเพราะมีผลต่อ Query ที่สามารถใช้ Index ได้ กฎ ESR (Equality, Sort, Range) ช่วยกำหนดลำดับที่เหมาะสม

// Compound Index สำหรับค้นหาตาม status แล้วเรียงตาม createdAt
db.orders.createIndex({ status: 1, createdAt: -1 })

// Query ที่ใช้ Index นี้ได้
db.orders.find({ status: "active" }).sort({ createdAt: -1 })

// Query ที่ใช้ Prefix ของ Index ได้
db.orders.find({ status: "active" })

// Query ที่ใช้ Index นี้ไม่ได้ (ไม่ตรง Prefix)
db.orders.find({ createdAt: { $gt: new Date("2025-01-01") } })

กฎ ESR สำหรับออกแบบ Compound Index คือวาง Field ที่ใช้ Equality (เท่ากับ) ก่อน ตามด้วย Field ที่ใช้ Sort แล้วตามด้วย Field ที่ใช้ Range (มากกว่า น้อยกว่า)

// Query: ค้นหา status = "active", เรียงตาม createdAt, ราคามากกว่า 100
db.orders.find({
  status: "active",
  price: { $gt: 100 }
}).sort({ createdAt: -1 })

// Index ที่เหมาะสมตาม ESR Rule:
// E = status (Equality)
// S = createdAt (Sort)
// R = price (Range)
db.orders.createIndex({ status: 1, createdAt: -1, price: 1 })

Multikey Index

เมื่อสร้าง Index บน Field ที่เป็น Array จะกลายเป็น Multikey Index โดยอัตโนมัติ โดยแต่ละค่าใน Array จะถูก Index แยกกัน

// ข้อมูล: { name: "Server A", tags: ["web", "production", "nginx"] }

// สร้าง Index บน Array Field
db.servers.createIndex({ tags: 1 })

// ค้นหาที่ใช้ Multikey Index ได้
db.servers.find({ tags: "production" })
db.servers.find({ tags: { $in: ["web", "api"] } })

// ข้อจำกัด: Compound Index ที่มี Multikey ได้แค่ 1 Array Field
db.collection.createIndex({ tags: 1, categories: 1 })
// ถ้าทั้ง tags และ categories เป็น Array ทั้งคู่ จะ Error

Text Index

ใช้สำหรับค้นหาข้อความแบบ Full-text Search รองรับการตัดคำ (Stemming) และ Stop Words โดย Collection หนึ่งมี Text Index ได้แค่ 1 ตัว

// สร้าง Text Index
db.articles.createIndex({ title: "text", content: "text" })

// ค้นหาแบบ Full-text
db.articles.find({ $text: { $search: "mongodb performance" } })

// ค้นหาแบบ Phrase (ต้องตรงทั้งวลี)
db.articles.find({ $text: { $search: "\"query optimization\"" } })

// เรียงตาม Text Score (ความเกี่ยวข้อง)
db.articles.find(
  { $text: { $search: "mongodb index" } },
  { score: { $meta: "textScore" } }
).sort({ score: { $meta: "textScore" } })

// กำหนดน้ำหนัก Field
db.articles.createIndex(
  { title: "text", content: "text" },
  { weights: { title: 10, content: 1 } }
)

TTL Index

TTL (Time-To-Live) Index จะลบ Document โดยอัตโนมัติหลังจากเวลาที่กำหนด เหมาะสำหรับข้อมูลที่มีอายุจำกัด เช่น Session, Log, หรือ Cache

// ลบ Document หลังจาก 30 วัน (2592000 วินาที)
db.sessions.createIndex(
  { createdAt: 1 },
  { expireAfterSeconds: 2592000 }
)

// ลบเมื่อถึงเวลาที่กำหนดใน Field (expireAt เป็น Date)
db.events.createIndex(
  { expireAt: 1 },
  { expireAfterSeconds: 0 }
)
// Document: { event: "promo", expireAt: ISODate("2026-12-31") }

Partial Index

สร้าง Index เฉพาะ Document ที่ตรงเงื่อนไข ช่วยลดขนาด Index และประหยัดหน่วยความจำ

// Index เฉพาะ Document ที่ active = true
db.products.createIndex(
  { name: 1 },
  { partialFilterExpression: { active: true } }
)

// Index เฉพาะ Document ที่มี Field email
db.users.createIndex(
  { email: 1 },
  {
    unique: true,
    partialFilterExpression: { email: { $exists: true } }
  }
)
// ช่วยให้ Document ที่ไม่มี email ไม่ต้องอยู่ใน Unique Index

Wildcard Index

ใช้สำหรับ Document ที่มีโครงสร้างไม่แน่นอน (Polymorphic) สร้าง Index ทุก Field ใน Subdocument

// Index ทุก Field ใน metadata
db.products.createIndex({ "metadata.$**": 1 })

// ค้นหา Field ใด ๆ ภายใน metadata
db.products.find({ "metadata.color": "red" })
db.products.find({ "metadata.size": "XL" })

// Index ทุก Field ใน Document (ใช้ระวัง — ขนาดใหญ่)
db.collection.createIndex({ "$**": 1 })

จัดการ Index

// ดู Index ทั้งหมดใน Collection
db.products.getIndexes()

// ดูขนาดของ Index
db.products.stats().indexSizes

// ลบ Index ตามชื่อ
db.products.dropIndex("email_1")

// ลบ Index ตาม Key Pattern
db.products.dropIndex({ email: 1 })

// ลบ Index ทั้งหมด (ยกเว้น _id)
db.products.dropIndexes()

// ซ่อน Index (ทดสอบผลกระทบก่อนลบจริง)
db.products.hideIndex("email_1")
db.products.unhideIndex("email_1")

explain() — วิเคราะห์ Query Plan

คำสั่ง explain() เป็นเครื่องมือสำคัญที่สุดในการวิเคราะห์ประสิทธิภาพ Query แสดงให้เห็นว่าฐานข้อมูลวางแผนทำงานอย่างไร ใช้ Index ตัวไหน และตรวจสอบ Document กี่ตัว

// Verbosity Modes
db.products.find({ price: { $gt: 100 } }).explain()                    // queryPlanner
db.products.find({ price: { $gt: 100 } }).explain("executionStats")     // + สถิติจริง
db.products.find({ price: { $gt: 100 } }).explain("allPlansExecution")  // + ทุก Plan ที่พิจารณา

อ่านผลลัพธ์ explain()

ส่วนสำคัญที่ต้องดูในผลลัพธ์ explain("executionStats")

// ตัวอย่างผลลัพธ์ (ย่อ)
{
  "queryPlanner": {
    "winningPlan": {
      "stage": "FETCH",           // ดึง Document จริง
      "inputStage": {
        "stage": "IXSCAN",        // ใช้ Index Scan (ดี!)
        "indexName": "status_1_createdAt_-1"
      }
    }
  },
  "executionStats": {
    "nReturned": 50,              // จำนวน Document ที่คืน
    "totalKeysExamined": 50,      // จำนวน Index Key ที่ตรวจ
    "totalDocsExamined": 50,      // จำนวน Document ที่ตรวจ
    "executionTimeMillis": 2      // เวลาที่ใช้ (ms)
  }
}

// สิ่งที่ต้องสังเกต:
// 1. stage = "COLLSCAN" = ไม่ใช้ Index (แย่!)
// 2. stage = "IXSCAN" = ใช้ Index (ดี!)
// 3. nReturned ≈ totalDocsExamined = ดี (ไม่ตรวจเกินจำเป็น)
// 4. totalDocsExamined >> nReturned = แย่ (ตรวจมากแต่คืนน้อย)

ตัวอย่างการวิเคราะห์และปรับปรุง

// ก่อนสร้าง Index — COLLSCAN
db.orders.find({ status: "active" }).explain("executionStats")
// stage: "COLLSCAN", totalDocsExamined: 1000000, executionTimeMillis: 850

// สร้าง Index
db.orders.createIndex({ status: 1 })

// หลังสร้าง Index — IXSCAN
db.orders.find({ status: "active" }).explain("executionStats")
// stage: "IXSCAN", totalDocsExamined: 5000, executionTimeMillis: 12

// ปรับปรุงอีก — Compound Index + Covered Query
db.orders.createIndex({ status: 1, total: 1, orderId: 1 })
db.orders.find(
  { status: "active" },
  { total: 1, orderId: 1, _id: 0 }
).explain("executionStats")
// stage: "IXSCAN" (ไม่มี FETCH — Covered Query!)
// totalDocsExamined: 0, totalKeysExamined: 5000

Covered Query — ประสิทธิภาพสูงสุด

Covered Query คือ Query ที่ผลลัพธ์ทั้งหมดอยู่ใน Index โดยไม่ต้อง Fetch Document จริงเลย ทำให้เร็วที่สุดเพราะอ่านเฉพาะ Index ในหน่วยความจำ สังเกตจาก explain() ที่ไม่มี FETCH Stage

// Index: { status: 1, price: 1 }

// Covered Query — ดึงเฉพาะ Field ที่อยู่ใน Index
db.products.find(
  { status: "active" },
  { status: 1, price: 1, _id: 0 }  // ต้อง exclude _id ด้วย
)
// totalDocsExamined: 0 (ไม่ต้องอ่าน Document เลย!)

// ไม่ใช่ Covered Query — ดึง Field ที่ไม่อยู่ใน Index
db.products.find(
  { status: "active" },
  { status: 1, price: 1, name: 1, _id: 0 }
)
// ต้อง FETCH Document เพื่อดึง name

Index Intersection

ในบางกรณี MongoDB สามารถใช้หลาย Single Field Index ร่วมกันในการตอบ Query เดียว แต่โดยทั่วไป Compound Index จะมีประสิทธิภาพดีกว่า Index Intersection

// สมมติมี 2 Index แยกกัน:
// { status: 1 }
// { category: 1 }

// Query นี้อาจใช้ Index Intersection
db.products.find({ status: "active", category: "VPS" })

// แต่ Compound Index จะเร็วกว่า:
db.products.createIndex({ status: 1, category: 1 })

แนวทางออกแบบ Index ที่ดี

  • สร้าง Index ตาม Query Pattern จริง ไม่ใช่สร้างบนทุก Field เพราะ Index มี Cost ในการเขียนและใช้หน่วยความจำ
  • ใช้กฎ ESR (Equality, Sort, Range) ในการออกแบบ Compound Index
  • ตรวจสอบ Query ด้วย explain() ทุกครั้งก่อนและหลังสร้าง Index
  • ใช้ Partial Index เมื่อ Query ส่วนใหญ่ค้นหาเฉพาะ Document บางกลุ่ม เพื่อลดขนาด Index
  • หลีกเลี่ยงการสร้าง Index ซ้ำซ้อน เช่น ถ้ามี {a: 1, b: 1} แล้วไม่จำเป็นต้องสร้าง {a: 1} เพิ่มเพราะ Compound Index ตอบ Query บน Prefix ได้
  • ตรวจสอบ Index ที่ไม่ได้ใช้ด้วย $indexStats แล้วพิจารณาลบออก
  • สำหรับ Collection ขนาดใหญ่ ควรสร้าง Index แบบ Background เพื่อไม่บล็อกการทำงาน
// ตรวจสอบ Index ที่ไม่ได้ใช้
db.products.aggregate([{ $indexStats: {} }])

// ผลลัพธ์แสดง accesses.ops — ถ้าเป็น 0 แสดงว่าไม่ได้ใช้ Index นี้เลย
// {
//   "name": "unused_index",
//   "accesses": { "ops": 0, "since": ISODate("2026-01-01") }
// }

// สร้าง Index แบบ Background (MongoDB 4.2+ สร้างแบบ Background เป็น Default)
db.bigCollection.createIndex(
  { category: 1, price: 1 },
  { background: true }  // สำหรับเวอร์ชันเก่ากว่า 4.2
)

สรุป

Index เป็นกลไกสำคัญที่สุดในการเพิ่มความเร็วการค้นหาข้อมูลใน MongoDB ตั้งแต่ Single Field Index สำหรับการค้นหาพื้นฐาน Compound Index ตามกฎ ESR ไปจนถึง Text Index สำหรับ Full-text Search และ TTL Index สำหรับลบข้อมูลอัตโนมัติ การใช้ explain() วิเคราะห์ Query Plan เป็นทักษะที่จำเป็นในการปรับแต่งประสิทธิภาพ และ Covered Query ช่วยให้ได้ประสิทธิภาพสูงสุดโดยไม่ต้องอ่าน Document จริง

แนะนำบริการ DE

การรัน MongoDB กับข้อมูลขนาดใหญ่ต้องการ RAM เพียงพอสำหรับ WiredTiger Cache และ Index ที่ต้องอยู่ในหน่วยความจำ Cloud VPS ของ DE รองรับการเลือก RAM ตามขนาดข้อมูลและ SSD NVMe สำหรับ I/O ที่เร็ว พร้อม Root Access สำหรับตั้งค่าฐานข้อมูลได้เต็มที่

สำหรับโปรเจกต์ที่ไม่ต้องการจัดการเซิร์ฟเวอร์เอง Cloud Hosting ของ DE เป็นทางเลือกที่สะดวกพร้อม Managed Infrastructure ให้พร้อมใช้งาน