Keyset Pagination สำหรับ Feed และ API ที่โตขึ้น: เร็วกว่า OFFSET อย่างไร
Keyset Pagination เป็นแนวทางแบ่งหน้าที่เหมาะกับ feed และ API ที่ข้อมูลเติบโตต่อเนื่อง เพราะช่วยลดต้นทุนจากการใช้ OFFSET ในหน้าลึก ๆ ได้อย่างชัดเจน บทความนี้อธิบายหลักการทำงาน ข้อดี ข้อควรระวัง และแนวทางออกแบบ API ใน Node

Keyset Pagination สำหรับ Feed และ API ที่โตขึ้น: เร็วกว่า OFFSET อย่างไร
เมื่อระบบเริ่มมีข้อมูลมากขึ้น การแบ่งหน้าข้อมูลแบบเดิมที่ใช้ page และ limit อาจเริ่มกลายเป็นคอขวดโดยไม่รู้ตัว โดยเฉพาะใน feed, timeline, notification หรือ API ที่มีการเรียกดูข้อมูลต่อเนื่องเป็นจำนวนมาก
ตัวอย่าง endpoint ที่หลายคนคุ้นเคยคือ:
GET /posts?page=1000&limit=20
ซึ่งมักถูกแปลงเป็น query ลักษณะนี้:
OFFSET 19980 LIMIT 20
ตอนข้อมูลยังมีเพียงหลักพัน เราอาจยังไม่รู้สึกถึงปัญหา แต่เมื่อข้อมูลโตเป็นหลักแสนหรือหลักล้าน ความช้าจะเริ่มชัดเจนขึ้น เพราะฐานข้อมูลไม่ได้กระโดดข้ามแถวจำนวนมากได้ฟรีอย่างที่หลายคนเข้าใจ
ทำไม OFFSET ถึงช้าลงเมื่อ page ลึกขึ้น
ปัญหาหลักของ OFFSET คือฐานข้อมูลยังคงต้องประมวลผลข้อมูลตามเงื่อนไข ORDER BY ก่อน จากนั้นจึงค่อยนับและทิ้งแถวตามจำนวนที่ระบุใน OFFSET แล้วจึงคืนเฉพาะแถวที่ต้องการจริง ๆ ตาม LIMIT
ตัวอย่างเช่น:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC
LIMIT 20 OFFSET 200000;
คำสั่งนี้เปรียบได้กับการบอกฐานข้อมูลว่า
ช่วยเรียงโพสต์ล่าสุดทั้งหมด แล้วทิ้ง 200,000 รายการแรก ก่อนส่งมาให้ฉัน 20 รายการ
แม้จะมี index อยู่แล้ว แต่ฐานข้อมูลก็ยังมีต้นทุนในการเดินผ่านข้อมูลจำนวนมาก และถ้า ORDER BY ไม่สอดคล้องกับ index ก็อาจยิ่งหนักขึ้นเพราะมีค่าใช้จ่ายในการ sort เพิ่มอีก
ดังนั้นยิ่ง page ลึก ต้นทุนจากการข้ามข้อมูลที่ไม่ได้ใช้ก็ยิ่งสูงตามไปด้วย
Keyset Pagination คืออะไร
Keyset Pagination หรือที่หลายคนเรียกว่า cursor-based pagination คือการเปลี่ยนวิธีคิดจากการถามว่า
- ขอ page ที่ 10,001
มาเป็น
- ขอ 20 รายการถัดจากรายการล่าสุดที่ผู้ใช้เห็น
วิธีนี้ไม่อิงกับเลขหน้า แต่ใช้ค่าของแถวสุดท้ายจากผลลัพธ์ก่อนหน้าเป็นตัวบอกตำแหน่ง หรือที่เรียกว่า cursor
ตัวอย่างรอบแรก:
SELECT id, title, created_at
FROM posts
ORDER BY created_at DESC, id DESC
LIMIT 20;
เมื่อ API ส่งผลลัพธ์กลับไป ก็ส่ง cursor กลับไปด้วย เช่น:
last_seen_created_at = 2026-07-01T10:00:00Zlast_seen_id = 8742
จากนั้นรอบถัดไปจึง query แบบนี้:
SELECT id, title, created_at
FROM posts
WHERE (created_at, id) < ('2026-07-01T10:00:00Z', 8742)
ORDER BY created_at DESC, id DESC
LIMIT 20;
แนวคิดสำคัญคือฐานข้อมูลไม่ต้องเสียเวลาข้ามแถวจำนวนมหาศาลอีกต่อไป แต่สามารถเริ่มค้นหาจากตำแหน่งหลัง cursor ได้ตรงกว่าและมีประสิทธิภาพกว่า
ทำไม Keyset Pagination จึงเหมาะกับ Feed และ API ที่โตต่อเนื่อง
Keyset Pagination เหมาะมากกับงานที่ผู้ใช้เลื่อนดูข้อมูลไปเรื่อย ๆ เช่น:
- timeline
- activity feed
- transaction list
- notification
- audit log
- infinite scroll
ข้อดีสำคัญคือประสิทธิภาพจะค่อนข้างสม่ำเสมอ แม้ข้อมูลจะเพิ่มขึ้นเรื่อย ๆ เพราะระบบไม่ต้องจ่ายต้นทุนกับการ skip ข้อมูลจำนวนมากแบบ OFFSET
นอกจากนี้ผลลัพธ์ยัง predictable กว่าในหลายกรณี เพราะการอ้างอิงตำแหน่งด้วย cursor ทำให้การดึงข้อมูลต่อจากจุดเดิมมีความชัดเจนกว่าเลขหน้า
เงื่อนไขสำคัญ: ORDER BY ต้อง stable
แม้ Keyset Pagination จะเร็ว แต่จะทำงานได้ดีจริงก็ต่อเมื่อเงื่อนไขการเรียงลำดับมีความ stable
ถ้าใช้เพียง:
ORDER BY created_at DESC
และมีหลายแถวที่ created_at เท่ากัน อาจเกิดปัญหาได้ เช่น:
- บางรายการซ้ำระหว่างหน้า
- บางรายการหายไป
แนวทางที่ปลอดภัยคือเพิ่ม tie-breaker ที่ unique เช่น id
ORDER BY created_at DESC, id DESC
และ cursor ก็ควรเก็บทั้ง created_at และ id เพื่อให้การเลื่อนไปหน้าถัดไปแม่นยำ
Index ที่ดีคือหัวใจของความเร็ว
Keyset Pagination จะให้ผลดีมากเมื่อมี index ที่สอดคล้องกับ query
ถ้าใช้ query แบบนี้:
ORDER BY created_at DESC, id DESC
ก็ควรมี composite index ที่เรียงตามลำดับเดียวกัน เช่นใน PostgreSQL:
CREATE INDEX idx_posts_feed
ON posts (created_at DESC, id DESC);
สำหรับ MySQL ก็ใช้หลักคิดเดียวกัน คือออกแบบ index ให้ช่วยทั้งการ filter หลัง cursor และการเรียงลำดับในคำสั่งเดียวกัน ไม่ใช่แยก index คนละชุดแล้วหวังว่า optimizer จะเลือกได้เหมาะเสมอไป
ตัวอย่าง workflow ใน Node.js API
ถ้ากำลังออกแบบ API ใหม่ใน Node.js วิธีทำงานที่ใช้ได้จริงมักเป็นดังนี้:
- endpoint รับ
limitและcursorแทนpage number - decode cursor ออกมาเป็น
created_atและid - query ด้วย
WHEREหลัง cursor พร้อมORDER BYที่ stable - ดึงข้อมูล
limit + 1แถว เพื่อเช็กว่ายังมีหน้าถัดไปหรือไม่ - ส่ง
nextCursorจากแถวสุดท้ายกลับไป
ตัวอย่าง response:
items = [...]
nextCursor = base64(created_at + id)
ฝั่ง client ไม่จำเป็นต้องรู้ว่า offset เท่าไร เพียงแค่เก็บ cursor แล้วส่งกลับมาตอนกดโหลดเพิ่มหรือเลื่อนหน้าต่อ
ข้อควรระวังในการออกแบบ cursor
cursor ไม่ควรเป็นค่าที่ผู้ใช้แก้เองได้ง่ายเกินไป เพราะอาจนำไปสู่การส่งค่าที่ผิดรูปแบบ หรือในบางกรณีอาจกระทบกับ business rule ได้
แนวทางที่เหมาะสมคือ:
- encode cursor เป็น opaque token
- ใช้รูปแบบเช่น base64 ของ JSON
- validate ทุกครั้งก่อนใช้งาน
- หากมีข้อมูลสำคัญ ควร sign cursor ด้วย HMAC เพื่อป้องกันการปลอมแปลง
แนวทางนี้ช่วยให้ API ปลอดภัยและควบคุมพฤติกรรมการเลื่อนข้อมูลได้ดีขึ้น
เมื่อไรที่ OFFSET ยังเหมาะกว่า
แม้ Keyset Pagination จะเหมาะกับ feed อย่างมาก แต่ก็ไม่ได้แปลว่าควรใช้แทน OFFSET ทุกกรณี
ตัวอย่างที่ OFFSET ยังเหมาะกว่า คือหน้าจอที่ผู้ใช้ต้องการกระโดดไปยังหน้าแบบระบุเลขได้ทันที เช่น:
- ตาราง admin
- รายงานหลังบ้าน
- ชุดข้อมูลที่ไม่ใหญ่มาก
เหตุผลคือ Keyset Pagination ถูกออกแบบมาสำหรับการไหลไปข้างหน้า หรือย้อนกลับตาม cursor ไม่ได้เหมาะกับ random access แบบ "ไปหน้า 100" โดยตรง
หลายระบบจึงใช้แบบผสมกัน เช่น:
- feed ใช้ keyset
- admin report หน้าเล็ก ๆ ใช้ OFFSET
- search results ใช้ cursor ของ search engine โดยเฉพาะ
การเลือกใช้จึงควรขึ้นกับลักษณะของหน้าจอและพฤติกรรมผู้ใช้ ไม่ใช่ยึดวิธีเดียวกับทุกกรณี
สรุป
OFFSET เป็นวิธีที่เข้าใจง่ายและเริ่มต้นได้เร็ว แต่เมื่อหน้าลึกขึ้น ต้นทุนจะสูงขึ้นตามจำนวนแถวที่ต้องข้าม ทำให้ประสิทธิภาพลดลงอย่างชัดเจนในระบบที่ข้อมูลเติบโตต่อเนื่อง
ในทางกลับกัน Keyset Pagination ใช้แนวคิด last_seen + WHERE + ORDER BY + LIMIT เพื่อดึงข้อมูลต่อจากจุดล่าสุดได้ตรงกว่า เร็วกว่า และคาดเดาพฤติกรรมได้ดีกว่าสำหรับ feed และ API ที่ใช้งานจริงในระยะยาว
หากกำลังออกแบบ Node.js backend ใหม่ และหน้าจอเป็นลักษณะ "โหลดเพิ่ม" หรือ infinite scroll มากกว่าการ "กระโดดไปหน้า 100" การเริ่มต้นด้วย Keyset Pagination มักเป็นตัวเลือกที่เหมาะกว่าตั้งแต่แรก