Pagination
สิ่งที่จะสร้าง
หัวข้อที่มีชื่อว่า “สิ่งที่จะสร้าง”ไม่มีอะไรใหม่ — บทเรียนนี้คือการอ่าน PostsService.findPage และ type PostPage อย่างละเอียด ทั้งสองอย่างเขียนไว้แล้วใน Code-first basics และ Posts resolver โค้ดไม่มีการเปลี่ยนแปลง สิ่งที่ใหม่ตรงนี้คือการเรียกชื่อแพตเทิร์นให้ชัด (offset pagination) แล้วชั่งน้ำหนักกับทางเลือกอื่น (cursor pagination) อย่างจงใจ แทนที่จะปล่อยให้เป็นการตัดสินใจโดยนัยที่ซ่อนอยู่ใน signature ของ findPage
findPage({ status?, tag?, page = 1, pageSize = 10 }) แปลงคู่ page/pageSize ให้เป็นคู่ skip/limit ของ MongoDB: skip = (page - 1) * pageSize นับว่าต้องทิ้ง document ที่ตรงเงื่อนไขไปกี่ตัวก่อนจะเริ่มเก็บผลลัพธ์ และ limit(pageSize) จำกัดว่าจะคืนกี่ตัว total มาจาก query ที่สอง แยกอิสระจากกัน — countDocuments(filter) — รันด้วย Promise.all คู่กับ find() เพื่อให้ round-trip ทั้งสองซ้อนทับกันแทนที่จะรันทีละตัว ทั้งสอง query ใช้ object filter ตัวเดียวกันเป๊ะ ค่า total จึงอธิบายชุดข้อมูลชุดเดียวกับที่ items เป็นหน้าหนึ่งอยู่เสมอ ไม่มีทางเกิดกรณี “total ของทั้งหมด” ที่ไม่ตรงกับ items ซึ่งผ่าน filter status/tag มาแล้ว
PostPage { items, total, page, pageSize } ให้ client มีทุกอย่างที่ต้องใช้เพื่อ render UI แบบเลขหน้าโดยไม่ต้องขอครั้งที่สอง: Math.ceil(total / pageSize) คือเลขหน้าสุดท้าย และ page < Math.ceil(total / pageSize) บอกปุ่ม “ถัดไป” ว่าควรเปิดใช้งานหรือไม่ — ทั้งหมดคำนวณฝั่ง client ได้จาก response เดียว
ข้อดีข้อเสีย
หัวข้อที่มีชื่อว่า “ข้อดีข้อเสีย”Offset pagination (skip/limit, สิ่งที่ findPage ทำ) เทียบกับ cursor pagination (after: <opaque token>, first: N) Offset pagination เข้าใจง่ายและสร้าง UI ง่าย: “หน้า 3 จาก 12” กระโดดตรงไปหน้า 7 โชว์แถบเลขหน้าเต็ม ๆ — ทั้งหมดนี้ต้องการตำแหน่งสัมบูรณ์ (page) ซึ่งมีแต่ offset pagination เท่านั้นที่ให้ได้โดยตรง ต้นทุนจริงจะโผล่ออกมาเมื่อมีการเขียนพร้อมกัน: skip(20) หมายถึง “ข้าม document 20 ตัวแรกที่ตรงกับ query ปัจจุบัน ตามลำดับการเรียงปัจจุบัน” และถ้ามีโพสต์ใหม่ถูกสร้างขึ้น (หรือ status ของโพสต์ที่มีอยู่เปลี่ยนไป) ระหว่างที่ผู้อ่านโหลดหน้า 1 กับตอนขอหน้า 2 ชุดข้อมูลที่ตรงเงื่อนไขของ collection ได้ขยับไปแล้ว — รายการหนึ่งอาจถูกข้ามไปเลยหรือโชว์ซ้ำสองครั้ง เพราะ skip นับตำแหน่งในชุดข้อมูลที่ไม่คงที่ระหว่างสอง query ที่แยกกัน countDocuments ก็ช้าลงอย่างเห็นได้ชัดเมื่อ collection โตขึ้นถึงระดับล้าน เพราะ MongoDB ยังต้องเดินผ่าน document ที่ตรงเงื่อนไขทุกตัวเพื่อสร้าง count นั้น แม้ find จะคืนแค่หนึ่งหน้าเท่านั้น
Cursor pagination หลีกเลี่ยงปัญหาทั้งสองนี้ด้วยการแทนตำแหน่งสัมบูรณ์ด้วยตัวชี้ไปยัง document เฉพาะเจาะจง — มักเป็น _id ของ document นั้น หรือ tiebreaker แบบผสม (createdAt, _id) สำหรับ feed ที่เรียงลำดับ แล้ว query ด้วย _id > lastSeenId (หรือ field ที่เรียงลำดับเทียบเท่า) แทนที่จะไล่นับจากจุดเริ่มต้นทุกครั้ง query แบบนี้เสถียรภายใต้การ insert พร้อมกัน เพราะไม่ขึ้นกับว่าตอนนี้มี document กี่ตัวอยู่หน้า cursor ขึ้นกับแค่ว่า document ไหนมาต่อจาก cursor เท่านั้น ต้นทุนก็มีจริง: ไม่มี “กระโดดไปหน้า 7” (cursor รู้แค่วิธีขยับทีละก้าวสัมพัทธ์กับตัวเอง) และไม่มี total ที่ถูกต้อง เหตุผลเดียวกับที่ live count ยอมรับได้สำหรับ total นั่นแหละที่ทำให้ใช้กับการตรวจขอบเขตของ cursor ไม่ได้
feed บล็อกของ DevBlog และรายการโพสต์ฝั่ง admin ทั้งคู่ถูกอ่านมากกว่าที่จะถูกเขียนพร้อมกันระหว่างที่ผู้ใช้คนเดียวกันกำลัง scroll อยู่มาก และ UI แบบ “หน้า 3 จาก 12” ฝั่ง admin ก็เป็นสิ่งที่มีประโยชน์จริง ๆ — offset pagination จึงเป็นตัวเลือกที่ถูกต้องที่นี่ โดยเข้าใจว่ากระทู้คอมเมนต์ที่โตขึ้นแบบเรียลไทม์ใต้ผู้อ่าน (เพิ่มใน Comments) หรือ feed ไหนที่คาดว่าจะสเกลเกินกว่าที่ countDocuments หนึ่งครั้งจะรับไหว คือสัญญาณให้กลับมาพิจารณา cursor pagination สำหรับ query นั้นเฉพาะจุด ไม่ใช่ query นี้
ติดตั้ง
หัวข้อที่มีชื่อว่า “ติดตั้ง”ไม่มีอะไรต้องเปลี่ยน — findPage และ PostPage implement ทุกอย่างข้างต้นไว้แล้ว:
async findPage(options: FindPostsPageOptions = {}): Promise<PostsPageResult> { const { status, tag, page = 1, pageSize = 10 } = options; const filter: Record<string, unknown> = {}; if (status) { filter.status = status; } if (tag) { filter.tags = tag; }
const skip = (page - 1) * pageSize; const [items, total] = await Promise.all([ this.postModel.find(filter).sort({ createdAt: -1 }).skip(skip).limit(pageSize).exec(), this.postModel.countDocuments(filter).exec(), ]);
return { items, total, page, pageSize };}sort({ createdAt: -1 })สำคัญพอ ๆ กับskip/limit— offset pagination จะสมเหตุสมผลได้ก็ต่อเมื่อมีลำดับการเรียงที่คงที่เท่านั้น ถ้าไม่มี MongoDB มีอิสระที่จะคืน document ที่ตรงเงื่อนไขในลำดับสัมพัทธ์ที่ต่างกันได้ในสอง call ที่แยกกัน และskip(10)บนชุดที่ไม่เรียงลำดับก็ไม่ได้แปลว่า “โพสต์ที่ใหม่ที่สุดอันดับที่ 11” เลยแม้แต่น้อยPromise.allซ้อนทับfind()กับcountDocuments()— ทั้งสองเป็นการอ่านที่เป็นอิสระต่อกันบนfilterตัวเดียวกัน จึงไม่มีเหตุผลที่จะawaitตัวหนึ่งก่อนเริ่มอีกตัว
ตรวจสอบผล
หัวข้อที่มีชื่อว่า “ตรวจสอบผล”npm run start:devseed โพสต์สักสองสามอันด้วย createPost จาก Posts resolver แล้วขอหน้าที่สองด้วยขนาดหน้าละ 10:
query PostsPageTwo { posts(page: 2, pageSize: 10) { total page pageSize items { title createdAt } }}เมื่อมี 15 โพสต์ใน collection หน้า 2 จะคืน 5 ที่เหลือ เรียงเก่าสุดก่อนภายในหน้านั้น (แต่ยังคงใหม่สุดโดยรวมก่อนอยู่ดี เพราะ sort({ createdAt: -1 }) ไม่เปลี่ยนเลย):
{ "data": { "posts": { "total": 15, "page": 2, "pageSize": 10, "items": [ { "title": "Post #6", "createdAt": "2026-01-01T09:00:00.000Z" }, { "title": "Post #5", "createdAt": "2026-01-01T08:00:00.000Z" }, { "title": "Post #4", "createdAt": "2026-01-01T07:00:00.000Z" }, { "title": "Post #3", "createdAt": "2026-01-01T06:00:00.000Z" }, { "title": "Post #2", "createdAt": "2026-01-01T05:00:00.000Z" } ] } }}total: 15 ยืนยันว่า countDocuments รันกับ document ที่ตรงเงื่อนไขทุกตัว ไม่ใช่แค่หน้าที่คืนมา items.length === 5 ยืนยันว่า skip(10).limit(10) คืนเฉพาะสิ่งที่เหลือหลังจาก 10 ตัวแรกอย่างถูกต้อง
รูปทรง items/total/page/pageSize ของ PostPage คือ offset pagination ครบวงจร: skip/limit คำนวณจาก page/pageSize และ countDocuments ที่เป็นอิสระแต่ใช้ filter เดียวกัน ทำให้ total กับ items อธิบายชุดข้อมูลเดียวกันเสมอ นี่คือค่าเริ่มต้นที่ถูกต้องสำหรับรายการของ DevBlog ที่เน้นอ่านและมีเลขหน้า แลกมาด้วยความไม่เสถียรภายใต้การเขียนพร้อมกัน และ total ที่แพงขึ้นเรื่อย ๆ เมื่อสเกลใหญ่ — สองเหตุผลที่ทำให้ feed แบบ traffic หนักกว่าและเรียลไทม์กว่าจะเลือกใช้ cursor pagination แทน
Next: Content Workflow →