ข้ามไปยังเนื้อหา

Offset Pagination

field ที่เป็น list สามารถคืนค่าเป็นพันแถวได้ การส่งทั้งหมดออกมาในคราวเดียวนั้นช้าและสิ้นเปลือง ดังนั้น list จึงรับ argument สำหรับ pagination เพื่อขอดูทีละช่วง สไตล์ที่คุ้นเคยที่สุด — สไตล์ที่นักพัฒนา SQL ทุกคนรู้จักอยู่แล้ว — คือ offset pagination: ข้ามบางแถว แล้วหยิบมาบางแถว

คุณเปิดเผย 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
การแทรก item ใหม่ที่ด้านบนทำให้ทุกตำแหน่งเลื่อน ขอบเขตของหน้าจึงทำให้ item ซ้ำหรือถูกข้ามได้

runner ด้านล่างถือ list ที่เรียงลำดับขนาดเล็กไว้ แล้ว resolve posts(limit, offset) ด้วยการ slice list นั้น รันตามที่เป็นอยู่เพื่อดึงหน้าสอง จากนั้นเปลี่ยน offset แล้วรันใหม่เพื่อดูว่า window เลื่อนไปอย่างไร

JavaScript

ผลลัพธ์คืน 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 และ bookmarkable

Admin dashboards:

  • offset pagination เหมาะสำหรับ admin table ที่ต้องการ random access และ page number
argument limit และ offset ทำหน้าที่อะไรใน offset pagination?
ทำไม offset ที่ใหญ่มาก (เช่น OFFSET 100000) จึงมักจะช้า?
ปัญหา "shifting window" ของ offset pagination คืออะไร?