Offset Pagination
field ที่เป็น list สามารถคืนค่าเป็นพันแถวได้ การส่งทั้งหมดออกมาในคราวเดียวนั้นช้าและสิ้นเปลือง ดังนั้น list จึงรับ argument สำหรับ pagination เพื่อขอดูทีละช่วง สไตล์ที่คุ้นเคยที่สุด — สไตล์ที่นักพัฒนา SQL ทุกคนรู้จักอยู่แล้ว — คือ offset pagination: ข้ามบางแถว แล้วหยิบมาบางแถว
limit และ offset
หัวข้อที่มีชื่อว่า “limit และ offset”คุณเปิดเผย argument ที่เป็นจำนวนเต็มสองตัว limit (บางทีก็เป็น first หรือ pageSize) บอกว่าจะคืน item กี่อัน ส่วน offset (บางทีก็เป็น skip) บอกว่าจะ ข้ามกี่อัน ก่อนเริ่ม:
type Query { posts(limit: Int = 10, offset: Int = 0): [Post!]!}posts(limit: 10, offset: 0) คืน item 1–10 ส่วน posts(limit: 10, offset: 10) คืน item 11–20 resolver จับคู่ค่าเหล่านี้ลงบน slice ของข้อมูลเบื้องล่างโดยตรง — และจับคู่ลงบน LIMIT/OFFSET ใน SQL ซึ่งนั่นเองเป็นเหตุผลว่าทำไมสไตล์นี้จึงพบได้ทั่วไป
เลขหน้าก็เป็นแนวคิดเดียวกัน
หัวข้อที่มีชื่อว่า “เลขหน้าก็เป็นแนวคิดเดียวกัน”API แบบ page/pageSize ก็คือ offset pagination ที่ใช้ตัวเลขที่เป็นมิตรกว่า คุณแปลงค่าภายในเอา:
type Query { posts(page: Int = 1, pageSize: Int = 10): [Post!]!}resolver คำนวณ offset = (page - 1) * pageSize แล้วทำงานเหมือนเดิมทุกประการ เลขหน้าดีกว่าสำหรับการสร้างส่วนท้าย “หน้า 1 2 3” ส่วน offset ดิบ ๆ ดีกว่าสำหรับ infinite scroll กลไกเบื้องล่างนั้นเหมือนกัน
รอยรั่วสองจุด
หัวข้อที่มีชื่อว่า “รอยรั่วสองจุด”offset pagination นั้นเรียบง่าย แต่มีปัญหาที่รู้กันดีอยู่สองข้อ
ข้อแรกคือ ต้นทุนของ deep-offset การจะคืน item 100,001–100,010 database ที่ใช้ OFFSET 100000 ยังต้อง นับผ่าน แสนแถวแรกก่อนจะทิ้งทั้งหมดนั้นไป ยิ่งเปิดหน้าลึกเท่าไหร่ แต่ละหน้าก็ยิ่งช้า เพราะงานเพิ่มขึ้นตาม offset ไม่ใช่ตามขนาดของหน้า
ข้อสองคือ shifting window offset ระบุ ตำแหน่ง ไม่ใช่ item ถ้ามี post ใหม่ถูกแทรกเข้ามาที่ด้านบนขณะที่ผู้ใช้กำลังอ่านอยู่ ทุก item จะเลื่อนลงไปหนึ่งช่อง หน้า 2 ตอนนี้จะเริ่มต้นด้วย item ที่ผู้ใช้เห็นไปแล้วในหน้า 1 และมี item หนึ่งหลุดลอดผ่านช่องว่างระหว่างหน้าไปอย่างเงียบ ๆ ในกรณีที่มีการลบ จะเกิดเหตุการณ์ตรงกันข้าม — item หนึ่งอาจถูกข้ามไปทั้งหมด
flowchart TD
subgraph Before["Before insert"]
B1["pos 1: A"]
B2["pos 2: B"]
B3["pos 3: C — page boundary"]
B4["pos 4: D"]
end
subgraph After["After inserting X at top"]
A1["pos 1: X (new)"]
A2["pos 2: A"]
A3["pos 3: B — page boundary"]
A4["pos 4: C"]
end
Before -->|"offset shifts by one"| After paging รันจริง
หัวข้อที่มีชื่อว่า “paging รันจริง”runner ด้านล่างถือ list ที่เรียงลำดับขนาดเล็กไว้ แล้ว resolve posts(limit, offset) ด้วยการ slice list นั้น รันตามที่เป็นอยู่เพื่อดึงหน้าสอง จากนั้นเปลี่ยน offset แล้วรันใหม่เพื่อดูว่า window เลื่อนไปอย่างไร
ผลลัพธ์คืน item 3–4 (offset: 2, limit: 2) พร้อมกับ totalCount และ flag hasMore ที่ client ใช้เพื่อ render ตัวเปิดหน้า (pager) ได้ นี่คือ offset pagination ที่ทำงานได้ดี — list เล็ก ๆ ที่เสถียร ซึ่ง deep offset และการแก้ไขแบบพร้อมกันไม่ใช่เรื่องต้องกังวล ทันทีที่ list ของคุณใหญ่ขึ้นหรือเปลี่ยนแปลงอยู่ตลอด รอยรั่วสองจุดข้างต้นจะเริ่มกัด ซึ่งนั่นคือสิ่งที่บทเรียนถัดไปเรื่อง cursor connection ถูกออกแบบมาเพื่อแก้
| ข้อดี | ข้อแลกเปลี่ยน |
|---|---|
เรียบง่าย — skip และ take เข้าใจง่าย | inconsistent result เมื่อ data เพิ่ม/ลบระหว่าง paginate |
| random access — ข้ามหน้าได้โดยตรง | performance ลดเมื่อ offset ใหญ่ — database ต้อง scan |
| นับจำนวน page ทั้งหมดได้ง่าย | ไม่เหมาะกับ infinite scroll ที่ต้องการ stable cursor |
| ง่ายต่อการ implement ใน SQL | เกิด duplicate/missing item เมื่อ concurrent insert |
ข้อผิดพลาดที่พบบ่อย
หัวข้อที่มีชื่อว่า “ข้อผิดพลาดที่พบบ่อย”Offset Pagination สำหรับ Real-time Feed อาการ:
- feed ที่มีข้อมูลใหม่เพิ่มบ่อย — user เลื่อนหน้า 2 แต่เห็น item เดียวกับหน้า 1
- offset pagination ไม่ stable กับ data ที่เปลี่ยนบ่อย
- ใช้ cursor pagination (Relay Connection) สำหรับ feed
Count Query แยกทุก Request อาการ:
- ทุก page request ทำ
COUNT(*)เพิ่มเติม - expensive บน table ใหญ่ — PostgreSQL ต้อง sequential scan
- cache total count หรือใช้ estimated count แทน exact count
💡 ตัวอย่างจากของจริง
Stack Overflow:
- ใช้ offset pagination สำหรับ question list — เหมาะเพราะ content ไม่เปลี่ยนบ่อย
- page URL มี
?page=2— shareable และ bookmarkableAdmin dashboards:
- offset pagination เหมาะสำหรับ admin table ที่ต้องการ random access และ page number