Relationships & Indexes
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”การตัดสินใจสำหรับความสัมพันธ์แต่ละคู่ระหว่างสี่ schema จาก Schemas — reference หรือ embed — บวกกับ populate call และ index ที่ทำให้ query pattern จริงของแต่ละความสัมพันธ์เร็วขึ้น
| ความสัมพันธ์ | สร้างเป็น | resolve ด้วย |
|---|---|---|
Post.author → User | reference (ObjectId, ref: 'User') | .populate('author', ...) |
Comment.post → Post | reference (ObjectId, ref: 'Post') | query ตรง ๆ: Comment.find({ post }) |
Post.tags → Tag | ไม่ใช่ reference — denormalized string[] ของ slug | ไม่มี populate; อ่านตรงจาก Post |
ทุก reference ใน DevBlog มีอยู่เพราะ entity ปลายทางมี identity และ lifecycle ของตัวเอง แยกจาก document ที่ชี้ไปหา บัญชี User อยู่ยืนยาวกว่าโพสต์แต่ละอันที่เจ้าตัวเขียน และ Post ก็อยู่ยืนยาว (และโต) กว่า comment แต่ละอันใต้โพสต์ ส่วน Post.tags เป็นความสัมพันธ์เดียวในแผนภาพที่ไม่ใช่ความสัมพันธ์ระดับฐานข้อมูลเลย เป็นแค่ข้อมูล denormalized ที่เลือกไว้ในบทเรียนก่อน เพราะ tag เล็ก มีขอบเขตจำกัด และอ่านพร้อมโพสต์เสมอ
Index มีอยู่ด้วยเหตุผลเดียวกับที่ reference มีอยู่: เพื่อทำให้ query ที่เจาะจง รู้อยู่แล้ว และเกิดบ่อยเร็วขึ้น ทุก index ด้านล่างผูกกับ query pattern ที่ DevBlog รันจริง ไม่ใช่ใส่ไว้แบบป้องกันเผื่อไว้
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Reference (populate) เทียบกับ embed ใช้กับ author Post.author อาจ embed สำเนา displayName ของ author ไว้บนโพสต์โดยตรงก็ได้ — ไม่ต้องเรียก populate document เดียว อ่านครั้งเดียว ต้นทุนคือ ทันทีที่ author เปลี่ยน displayName ทุกโพสต์ที่เขาเคยเขียนจะแสดงชื่อเก่าจนกว่าคุณจะเขียน bulk-update ข้าม collection posts การ reference มีต้นทุนเพิ่มคือการ lookup อีกครั้งต่อการอ่านโพสต์หนึ่งครั้ง (บรรเทาด้วย populate ที่ทำ lookup นั้นให้คุณในอีก query เดียวแทนที่จะเป็นหนึ่ง query ต่อโพสต์) แต่ข้อมูลของ author จะอยู่ที่เดียวเสมอ — User — และเป็นข้อมูลปัจจุบันตลอด
populate เทียบกับสอง query แยกกัน .populate('author') คือ Mongoose ทำ join ให้คุณ: หนึ่ง query สำหรับโพสต์ อีกหนึ่ง query ตามมาสำหรับ document User ที่ถูก reference แล้วรวมเข้าเป็นผลลัพธ์ที่คุณได้กลับมา นั่นก็ยังเป็นสอง round trip ไปที่ MongoDB ไม่ใช่ relational JOIN เดียว populate จึงไม่ได้ฟรี แต่ก็คือสอง query ชุดเดียวกับที่คุณต้องเขียนเองอยู่ดี แค่ห่อไว้ใน API ที่ project field ได้ด้วย (.populate('author', 'displayName email') ดึงมาแค่สอง field นั้นจาก User ที่ถูก reference ไม่ใช่ทั้ง document รวม passwordHash)
Read-vs-write tradeoff ของการทำ index ทุก index ทำให้การอ่านที่กรองหรือเรียงตาม field นั้นเร็วขึ้น และทำให้การเขียนบน field นั้นช้าลง เพราะ MongoDB ต้องอัปเดต B-tree ของ index ทุกครั้งที่ insert หรือ update ทั้งยังกิน disk กับ RAM เพิ่มในฐานะโครงสร้างแยกจากข้อมูลของ collection
นี่คือเหตุผลที่ index สี่ตัวด้านล่างผูกกับ query ที่เจาะจงและเกิดบ่อยตัวละหนึ่ง แทนที่จะประกาศทุก field ไว้ “เผื่อ” ทั้ง Post และ Comment ถูกอ่านบ่อยกว่าถูกเขียนมาก (การดูโพสต์สาธารณะหรือโหลด comment thread เกิดทุกครั้งที่มีผู้เข้าชม ส่วนการเขียนโพสต์หรือ comment ใหม่เกิดไม่บ่อยเมื่อเทียบกัน) การแลกต้นทุนการเขียนเล็กน้อยกับความเร็วอ่านที่เพิ่มขึ้นมากและคงที่จึงถูกต้องที่นี่ ส่วน field ที่ไม่มีใคร query ก็ไม่ได้ index ไม่ว่าจะถูกใช้ที่อื่นอย่างไรก็ตาม
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”reference author ที่ resolve ด้วย populate
หัวข้อที่มีชื่อว่า “reference author ที่ resolve ด้วย populate”การลงทะเบียน model ของ Post (ครอบคลุมเต็ม ๆ ใน Backend Foundations) คือสิ่งที่ทำให้ @InjectModel(Post.name) ใช้งานได้:
import { Module } from '@nestjs/common';import { MongooseModule } from '@nestjs/mongoose';import { Post, PostSchema } from './schemas/post.schema';
@Module({ imports: [MongooseModule.forFeature([{ name: Post.name, schema: PostSchema }])],})export class PostsModule {}Service method ที่ดึงโพสต์ที่ published ตาม slug พร้อม populate author:
import { Injectable } from '@nestjs/common';import { InjectModel } from '@nestjs/mongoose';import { Model } from 'mongoose';import { Post, PostDocument } from './schemas/post.schema';
@Injectable()export class PostsService { constructor( @InjectModel(Post.name) private readonly postModel: Model<PostDocument>, ) {}
findPublishedBySlug(slug: string) { return this.postModel .findOne({ slug, status: 'published' }) .populate('author', 'displayName email') .exec(); }}.populate('author', 'displayName email') ตาม ObjectId ของ author ไปที่ collection User แล้วสลับค่านั้นเป็น document ที่ตรงกัน โดย project ลงมาเหลือแค่ displayName กับ email ส่วน passwordHash และ role ไม่ถูกดึงออกจากฐานข้อมูลเลยสำหรับ query นี้ (typing แบบ static ของ Mongoose ไม่ได้เปลี่ยน type ของ author จาก Types.ObjectId เป็น User ให้อัตโนมัติหลัง populate โมดูลถัดไปจะ narrow return type อย่างชัดเจนตรงจุดที่จำเป็น เช่นใน return shape ของ GraphQL resolver)
Comment.post ไม่เคยถูก populate แบบเดียวกันใน query ของ comment thread — thread ถูกดึงมาโดยกรอง Comment ตรง ๆ (Comment.find({ post: postId, status: 'approved' })) เพราะผู้เรียกมีโพสต์อยู่แล้วและต้องการแค่ comment ไม่ต้องการข้อมูลโพสต์ซ้ำในทุก comment
เหตุผลที่แต่ละ index มีอยู่
หัวข้อที่มีชื่อว่า “เหตุผลที่แต่ละ index มีอยู่”Index สี่ตัวเกิดจาก schema ในบทเรียนก่อนหน้า แต่ละตัวจับคู่กับ query จริงหนึ่งตัว:
| Index | Field(s) | Query ที่รองรับ |
|---|---|---|
Post.slug | slug (unique) | Post.findOne({ slug }) — หน้ารายละเอียดโพสต์สาธารณะ query ที่เกิดบ่อยที่สุดในแอปทั้งหมด |
Post.status | status | Post.find({ status: 'published' }) — list โพสต์สาธารณะ; admin list query ทั้งสองค่า |
Comment.post | post | Comment.find({ post: postId }) — การโหลด comment thread ของโพสต์ |
Comment.status | status | Comment.find({ status: 'pending' }) — คิว moderation ของ admin ข้ามทุกโพสต์ |
บน Post.slug สังเกตว่า unique: true เพียงอย่างเดียวก็ทำให้ Mongoose สร้าง unique index อยู่แล้ว การใส่ index: true ต่อท้ายช่วยบันทึกความตั้งใจให้ชัด แต่ไม่ได้สร้าง index ตัวที่สอง สุดท้ายยังได้ index บน slug แค่ตัวเดียว
Compound index สำหรับ comment thread
หัวข้อที่มีชื่อว่า “Compound index สำหรับ comment thread”Comment.status เดี่ยว ๆ รองรับคิว moderation (status: 'pending' ไม่มีการกรอง post) แต่ query comment ที่เกิดบ่อยกว่ามากกรองทั้งสองfield พร้อมกัน — { post: postId, status: 'approved' } รันทุกครั้งที่โหลดหน้าโพสต์ MongoDB ตอบ query นี้ได้ด้วยการ intersect index เดี่ยวสองตัวก็จริง แต่ compound index ตอบได้ตรง ๆ ในการสแกน index ครั้งเดียว Mongoose ประกาศ compound index ด้วย schema.index() บน schema instance หลังจาก SchemaFactory.createForClass:
// comments/schemas/comment.schema.ts (addition, after the class above)export const CommentSchema = SchemaFactory.createForClass(Comment);CommentSchema.index({ post: 1, status: 1 });ลำดับ field สำคัญ: { post: 1, status: 1 } มีประสิทธิภาพสำหรับ query ที่กรองบน post อย่างเดียว หรือบน post และ status พร้อมกัน (query ของ comment thread) แต่ไม่ใช่สำหรับ query ที่กรองบน status อย่างเดียวโดยไม่มี post — นั่นคือเหตุผลที่ index เดี่ยวของ status จาก schema ยังคงอยู่ต่อไปเช่นกัน สำหรับคิว moderation ทั้งสอง index รวมกัน ไม่ใช่ตัวหนึ่งแทนที่อีกตัว ครอบคลุมทั้งสอง query pattern
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”เมื่อ API รันอยู่กับ Mongo container ในเครื่องจาก Compose skeleton Mongoose จะสร้างทุก index ที่ประกาศไว้ให้อัตโนมัติใน environment ที่ไม่ใช่ production ตรวจสอบได้โดยตรง:
docker compose exec mongo mongosh -u devblog -p devblog --authenticationDatabase admin devblog --eval "db.posts.getIndexes()"[ { v: 2, key: { _id: 1 }, name: '_id_' }, { v: 2, key: { slug: 1 }, name: 'slug_1', unique: true }, { v: 2, key: { status: 1 }, name: 'status_1' }]docker compose exec mongo mongosh -u devblog -p devblog --authenticationDatabase admin devblog --eval "db.comments.getIndexes()"[ { v: 2, key: { _id: 1 }, name: '_id_' }, { v: 2, key: { post: 1 }, name: 'post_1' }, { v: 2, key: { status: 1 }, name: 'status_1' }, { v: 2, key: { post: 1, status: 1 }, name: 'post_1_status_1' }]การเห็น slug_1 ระบุว่า unique: true และ post_1_status_1 อยู่ควบคู่กับ index เดี่ยวสองตัวบน comments ยืนยันว่าทั้งสอง collection ถูก index ตรงตามที่บทเรียนนี้อธิบายไว้ทุกประการ
Post.author และ Comment.post เป็น reference จริง resolve ด้วย .populate(); Post.tags เป็นข้อมูล denormalized ไม่ใช่ reference และไม่เคยถูก populate populate คือ Mongoose รัน query ที่สองให้คุณ พร้อม field projection เพื่อเลี่ยงการดึง field ที่อ่อนไหวอย่าง passwordHash ข้ามสาย Index สี่ตัว — Post.slug (unique), Post.status, Comment.post, Comment.status — แต่ละตัวตอบ query ที่เจาะจงและเกิดบ่อยหนึ่งตัว บวกกับ compound index { post: 1, status: 1 } บน Comment สำหรับ query comment thread ที่กรองทั้งสอง field พร้อมกัน ทุก index แลกต้นทุนการเขียนกับความเร็วในการอ่าน และเพราะทั้ง Post กับ Comment ถูกอ่านบ่อยกว่าเขียนมาก การแลกนี้จึงคุ้มที่นี่ พอ schema ความสัมพันธ์ และ index ลงตัวแล้ว data model ก็นิ่งพอจะสร้าง API ต่อยอดได้
ถัดไป: Backend Foundations →