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

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:

แพตเทิร์นทั่วไปในหน้า admin (ไม่ผูกกับผลิตภัณฑ์ใดผลิตภัณฑ์หนึ่ง):

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