DEV Community

Cover image for Pagination แบบ Cursor-Based vs Offset: API ควรเลือกใช้แบบไหน
Thanawat Wongchai
Thanawat Wongchai

Posted on Originally published at apidog.com

Pagination แบบ Cursor-Based vs Offset: API ควรเลือกใช้แบบไหน

ทุกๆ ปลายทางรายการต้องตอบคำถามเดียวกัน: จะแบ่งคำสั่งซื้อ 2 ล้านรายการออกเป็นหน้าๆ ให้ไคลเอนต์เรียกดูได้อย่างไร? การแบ่งหน้าแบบ Offset ให้ SQL ที่เรียบง่ายและผู้ใช้ข้ามไปยังหมายเลขหน้าได้ ส่วน Cursor-based ให้ผลลัพธ์ที่เสถียรและประสิทธิภาพคงที่ แม้เรียกดูข้อมูลลึก แต่จะไม่มีความสามารถในการ “ข้ามไปยังหน้า 47” โดยตรง

ลองใช้ Apidog วันนี้

ทีมส่วนใหญ่มักเริ่มด้วย Offset เพราะเป็นค่าเริ่มต้นในบทเรียนต่างๆ แต่เมื่อมีข้อมูลหลายล้านแถว หน้าที่ 4,000 อาจเริ่มหมดเวลา และผู้ใช้อาจเห็นข้อมูลซ้ำหรือข้อมูลหายระหว่างเลื่อนดู

บทความนี้เปรียบเทียบ Offset กับ Cursor-based อธิบายข้อจำกัดของ Offset เหตุผลที่ Stripe และ Slack เลือกใช้ Cursor และวิธีทดสอบทั้งสองรูปแบบด้วยคำขอแบบลูกโซ่ใน Apidog

หากต้องการภาพรวมทุกกลยุทธ์ โปรดดู คู่มือการแบ่งหน้า API

การแบ่งหน้าแบบ Offset

Offset จับคู่โดยตรงกับ SQL ไคลเอนต์ส่งหมายเลขหน้าและขนาดหน้า จากนั้นเซิร์ฟเวอร์แปลงเป็น LIMIT และ OFFSET

SELECT id, customer_id, total_cents, created_at
FROM orders
ORDER BY created_at DESC
LIMIT 25 OFFSET 50;
Enter fullscreen mode Exit fullscreen mode

คิวรีนี้คืนหน้า 3 โดยมี 25 แถวต่อหน้า:

GET /v1/orders?page=3&per_page=25
Enter fullscreen mode Exit fullscreen mode

ตัวอย่างการตอบกลับ:

{
  "data": [
    {
      "id": "ord_8821",
      "customer_id": "cus_1932",
      "total_cents": 4599,
      "created_at": "2026-08-30T14:22:07Z"
    }
  ],
  "page": 3,
  "per_page": 25,
  "total": 1848203,
  "total_pages": 73929
}
Enter fullscreen mode Exit fullscreen mode

ข้อดีของ Offset

  • ข้ามไปยังหน้าใดก็ได้
  • ส่งคืนจำนวนรวมและจำนวนหน้าได้ง่าย
  • พัฒนาได้รวดเร็ว
  • เหมาะกับตารางผู้ดูแลระบบขนาดเล็กหรือข้อมูลที่ไม่เปลี่ยนแปลงบ่อย

ดูตัวอย่างการสร้าง Offset แบบเต็มได้จาก คู่มือการแบ่งหน้าใน REST API

อย่างไรก็ตาม Offset มีปัญหาสำคัญสองประการ ซึ่งมักไม่เห็นในช่วงพัฒนา แต่จะเกิดขึ้นจริงเมื่อระบบใช้งาน

ปัญหา 1: Page drift

Offset นับแถวจากด้านบนของผลลัพธ์ที่เรียงลำดับไว้ โดยไม่รู้ว่าไคลเอนต์เห็นแถวใดไปแล้ว

สมมติว่าไคลเอนต์โหลดหน้าแรก ซึ่งมีแถวที่ 1–25 จากใหม่สุดไปเก่าสุด ระหว่างนั้นมีคำสั่งซื้อใหม่ 3 รายการถูกเพิ่มเข้ามา เมื่อไคลเอนต์ขอหน้า 2 ด้วย OFFSET 25 แถวที่ 23–25 จากหน้าแรกอาจถูกเลื่อนไปอยู่ตำแหน่ง 26–28 ทำให้ปรากฏซ้ำ

ในทางกลับกัน หากมีการลบ 3 แถวจากหน้าแรก OFFSET 25 จะข้ามแถวที่ไคลเอนต์ยังไม่เคยเห็น ทำให้ข้อมูลหายโดยไม่มีข้อผิดพลาด

Page drift อาจไม่สำคัญสำหรับรายงานรายเดือน แต่เป็นปัญหาสำหรับ:

  • ฟีดกิจกรรม
  • ปลายทางสำหรับซิงค์ข้อมูล
  • งานที่เรียกดูทีละหน้าในขณะที่มีการเขียนข้อมูลต่อเนื่อง
  • การส่งออกข้อมูลขนาดใหญ่

ปัญหา 2: Offset ลึกมีต้นทุนสูง

OFFSET 500000 ไม่ได้กระโดดไปยังแถวที่ 500,001 โดยตรง ฐานข้อมูลต้องอ่านข้อมูล 500,000 แถว ทิ้งข้อมูลเหล่านั้น แล้วจึงคืน 25 แถวที่ต้องการ ต้นทุนจึงเพิ่มขึ้นตามความลึกแบบ O(n) เมื่อ n คือค่า Offset

ตัวอย่างบน PostgreSQL ที่มีคำสั่งซื้อ 2 ล้านแถวและมีดัชนีบน created_at:

  • LIMIT 25 OFFSET 0 อ่านรายการดัชนี 25 รายการ ใช้เวลาเพียงไม่กี่มิลลิวินาที
  • LIMIT 25 OFFSET 100000 อ่าน 100,025 รายการและทิ้ง 100,000 รายการ ใช้เวลาหลายสิบมิลลิวินาที
  • LIMIT 25 OFFSET 1500000 อ่านประมาณ 1.5 ล้านรายการ ใช้เวลาหลายร้อยมิลลิวินาที รวมถึงใช้บัฟเฟอร์และ CPU โดยไม่จำเป็น

บทความเกี่ยวกับการไม่ใช้ Offset ของ Markus Winand แสดงต้นทุนนี้ผ่านแผนการคิวรีโดยละเอียด

ในระบบจริง ควรตรวจสอบ slow query log โดยเฉพาะคำขอที่มี Offset สูง บางครั้งต้นเหตุคือ crawler เพียงรายเดียวที่กำลังไล่เรียกทุกหน้าของ API สาธารณะ ทำให้ค่า p99 latency เพิ่มขึ้นอย่างมาก

การแบ่งหน้าแบบ Cursor-based

Cursor-based หรือ Keyset Pagination ไม่ใช้ตัวนับแถว แต่ระบุว่า “ขอแถวถัดจากรายการนี้” Cursor จึงเก็บข้อมูลของแถวสุดท้ายที่ไคลเอนต์เห็น เพื่อให้เซิร์ฟเวอร์ค้นหาข้อมูลชุดถัดไปได้โดยตรง

SQL จะใช้การเปรียบเทียบคีย์การจัดเรียงแทน OFFSET:

SELECT id, customer_id, total_cents, created_at
FROM orders
WHERE (created_at, id) < ('2026-08-30T14:22:07Z', 'ord_8821')
ORDER BY created_at DESC, id DESC
LIMIT 25;
Enter fullscreen mode Exit fullscreen mode

ใช้คีย์จัดเรียงที่ไม่ซ้ำกัน

created_at เพียงอย่างเดียวไม่เพียงพอ เพราะคำสั่งซื้อหลายรายการอาจถูกสร้างในมิลลิวินาทีเดียวกัน หากคีย์การจัดเรียงไม่เป็นเอกลักษณ์ อาจเกิดข้อมูลซ้ำหรือข้อมูลหายที่ขอบหน้า

การเพิ่ม id เป็น tie-breaker จะทำให้ลำดับชัดเจน:

ORDER BY created_at DESC, id DESC
Enter fullscreen mode Exit fullscreen mode

ควรสร้างดัชนีรวมให้สอดคล้องกัน:

CREATE INDEX orders_created_at_id_idx
ON orders (created_at DESC, id DESC);
Enter fullscreen mode Exit fullscreen mode

ด้วยดัชนีนี้ ฐานข้อมูลสามารถค้นหาไปยังขอบเขตโดยตรงและอ่านเพียง 25 รายการ หน้าที่ 1 และหน้าที่ 60,000 จึงมีต้นทุนใกล้เคียงกัน หรือประมาณ O(1) ต่อหน้า

อย่าเปิดเผยค่าดิบใน Cursor

API ไม่ควรส่งค่าคีย์การจัดเรียงแบบดิบ ควรเข้ารหัสเป็นโทเค็นที่ไม่โปร่งใส เช่น Base64:

GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
Enter fullscreen mode Exit fullscreen mode

ความทึบแสงเป็นการตัดสินใจด้านการออกแบบ ไม่ใช่เพียงการทำให้ค่าดูอ่านยาก เมื่อไคลเอนต์แยกวิเคราะห์ Cursor ไม่ได้ คุณสามารถเปลี่ยนคีย์การจัดเรียง เพิ่มข้อมูลสำหรับการแบ่งส่วน หรือเปลี่ยนกลไกจัดเก็บข้อมูลโดยไม่ทำให้ไคลเอนต์พัง

ข้อตกลงของ API จึงเรียบง่าย: ไคลเอนต์ต้องส่งคืน Cursor ที่ได้รับมาเท่านั้น

ข้อแลกเปลี่ยนคือ Cursor ไม่รองรับการข้ามไปยังหน้าใดก็ได้ ไคลเอนต์ต้องเดินหน้าทีละหน้า และจำนวนรวมต้องใช้คิวรีแยกต่างหาก ดูแนวทางเพิ่มเติมได้จาก คู่มือการออกแบบการแบ่งหน้า API สำหรับข้อมูลหลายล้านรายการ

เปรียบเทียบ Offset กับ Cursor

มิติ Offset Cursor-based
ข้ามไปยังหน้าใดก็ได้ ได้ ไม่ได้ ต้องเดินหน้าทีละหน้า
จำนวนรวม / จำนวนหน้า รวมได้ง่าย ต้องใช้คิวรีนับแยก
ประสิทธิภาพหน้าลึก O(n) ลดลงตามความลึก O(1) ต่อหน้า
เสถียรภาพระหว่างมีการเขียนข้อมูล อาจเกิดข้อมูลซ้ำหรือข้อมูลหาย เสถียร ยึดตามตำแหน่งของแถว
ค่าใช้จ่ายในการพัฒนา ต่ำมาก ปานกลาง ต้องออกแบบ Cursor และดัชนี
ข้อกำหนดการจัดเรียง ORDER BY ใดก็ได้ ต้องมีคีย์ที่ไม่ซ้ำและมีดัชนี
การแคช URL ง่าย URL คาดเดาได้ ยากกว่า Cursor เปลี่ยนตามลำดับการเรียก
ความซับซ้อนของไคลเอนต์ ต่ำ ต่ำ หากโครงสร้างตอบกลับชัดเจน

จุดสำคัญคือ Cursor ต้องใช้ลำดับที่กำหนดได้ หาก API เปิดให้เรียงตามคอลัมน์ที่เปลี่ยนแปลงและไม่เป็นเอกลักษณ์ เช่น status การทำ Keyset Pagination จะซับซ้อนขึ้นอย่างรวดเร็ว Offset ยืดหยุ่นกับการจัดเรียงที่ไม่เข้มงวดมากกว่า

ควรเลือกแบบไหน?

เลือกตามรูปแบบการใช้งานข้อมูล

ตารางผู้ดูแลระบบและแดชบอร์ด: Offset

เหมาะกับข้อมูลไม่กี่พันแถว ผู้ใช้คลิกหมายเลขหน้า ต้องการเห็นข้อความอย่าง “1,848 ผลลัพธ์” และมักไม่เรียกดูหน้าลึก Page drift ไม่สำคัญและต้นทุนการพัฒนาต่ำ

Infinite scroll feed: Cursor

ผู้ใช้ไม่ได้ต้องการข้ามไปหน้า 47 แต่ต้องการกด “โหลดเพิ่มเติม” การเขียนข้อมูลเกิดขึ้นตลอดเวลา และข้อมูลซ้ำจะเห็นได้ชัดเจน Cursor จึงเหมาะกว่า

Public API: Cursor

คุณควบคุมพฤติกรรมของผู้ใช้ API ไม่ได้ จะมีบางรายเขียนลูปไล่เรียกทุกหน้าอย่างแน่นอน Offset ลึกจะกลายเป็นภาระด้านประสิทธิภาพ ส่วน Cursor ช่วยให้ต้นทุนต่อหน้าคงที่และเปิดโอกาสให้คุณเปลี่ยนโครงสร้างภายในได้โดยไม่กระทบไคลเอนต์

ดูรายละเอียด URL และข้อกำหนดส่วนหัวได้จาก คู่มือการแบ่งหน้า REST API

งานส่งออกและซิงค์ข้อมูล: Cursor

งานที่ต้องดึงคำสั่งซื้อ 2 ล้านรายการต้องการ:

  1. ไม่ให้แถวหาย แม้มีการเขียนข้อมูลพร้อมกัน
  2. มีต้นทุนคงที่ต่อหน้า
  3. กลับมาทำงานต่อจาก Cursor เดิมได้เมื่อเกิดความล้มเหลว

Offset ไม่รับประกันทั้งสามข้อ แต่ Cursor ให้จุดเริ่มต้นสำหรับทำงานต่อได้โดยไม่ต้องเริ่มใหม่ตั้งแต่ต้น

กฎง่ายๆ:

  • ใช้ Offset กับอินเทอร์เฟซขนาดเล็กที่มนุษย์เรียกดูและเน้นหมายเลขหน้า
  • ใช้ Cursor กับข้อมูลขนาดใหญ่ ข้อมูลที่เปลี่ยนแปลงบ่อย งานซิงค์ หรือ API สาธารณะ

API จริงจัดการเรื่องนี้อย่างไร?

Stripe

Stripe ใช้ Cursor-based Pagination กับปลายทางรายการ โดยรับ starting_after และ limit การขอหน้าถัดไปต้องส่ง ID ของรายการล่าสุดที่ได้รับ และการตอบกลับมี has_more

ดูรูปแบบได้จาก เอกสารการแบ่งหน้าของ Stripe โดยสังเกตว่าไม่มีจำนวนรวม ซึ่งเป็นการตัดสินใจที่เหมาะกับระบบที่มีการเขียนข้อมูลจำนวนมาก

GitHub

GitHub REST API ยังคงใช้ page และ per_page ในหลายปลายทาง พร้อมส่วนหัว Link สำหรับหน้าถัดไปและหน้าสุดท้าย

ตาม เอกสารการแบ่งหน้าของ GitHub ไคลเอนต์ควรทำตามส่วนหัว Link แทนการสร้าง URL เอง ปลายทางใหม่บางส่วนเปลี่ยนไปใช้ Cursor เพื่อหลีกเลี่ยงต้นทุนของหน้าลึกในคลังข้อมูลขนาดใหญ่

Slack

Slack ใช้ Cursor-based Pagination กับ Web API และระบุว่าเป็นแนวทางสำหรับเมธอดใหม่ๆ เช่น conversations.history

การตอบกลับมี response_metadata.next_cursor หาก Cursor เป็นสตริงว่าง แปลว่าถึงจุดสิ้นสุดแล้ว ดูรายละเอียดจาก เอกสารการแบ่งหน้าของ Slack

ทั้งสาม API ที่มีการใช้งานสูงสะท้อนทิศทางเดียวกัน: มุ่งสู่ Cursor

ออกแบบโครงสร้างการตอบกลับ

Cursor API ควรมีโครงสร้างที่เรียบง่ายและคาดเดาได้:

{
  "data": [
    {
      "id": "ord_8846",
      "customer_id": "cus_2201",
      "total_cents": 12900,
      "created_at": "2026-08-30T16:01:44Z"
    }
  ],
  "has_more": true,
  "next_cursor": "eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNjowMTo0NFoiLCJpZCI6Im9yZF84ODQ2In0"
}
Enter fullscreen mode Exit fullscreen mode

กฎสำคัญ 4 ข้อ

  1. ส่งคืน has_more เสมอ

    ไคลเอนต์ไม่ควรอนุมานจุดสิ้นสุดจากหน้าที่มีข้อมูลน้อย เพราะผลลัพธ์อาจสั้นลงจากการกรองข้อมูลภายหลัง

  2. ใช้ค่า next_cursor ให้สม่ำเสมอ

    ส่งคืน null ในหน้าสุดท้าย หรือใช้สตริงว่างแบบ Slack แต่ควรเลือกเพียงรูปแบบเดียวและบันทึกไว้ในเอกสาร

  3. Cursor ที่ไม่ถูกต้องต้องตอบ 400

    อย่าตอบ 200 พร้อมข้อมูลว่าง เพราะจะทำให้ข้อผิดพลาดของไคลเอนต์ถูกซ่อน

  4. ลงชื่อหรือกำหนดเวอร์ชันของ Cursor

    หาก Cursor มีข้อมูลมากกว่าคีย์การจัดเรียง ควรมี signature หรือ version เพื่อรองรับการย้าย schema ในอนาคต

ทดสอบทั้งสองรูปแบบใน Apidog

ข้อผิดพลาดของ Pagination มักซ่อนอยู่ที่ขอบเขต เช่น หน้าสุดท้าย หน้าว่าง หรือ Cursor ที่อ้างถึงแถวซึ่งถูกลบไปแล้ว การคลิกทดสอบด้วยมือมักตรวจไม่พบ แต่สถานการณ์ทดสอบแบบลูกโซ่ช่วยตรวจได้

ทดสอบ Cursor แบบหลายหน้า

  1. เรียกปลายทางหน้าแรก แล้วดึงค่า Cursor ด้วย JSONPath:
   $.next_cursor
Enter fullscreen mode Exit fullscreen mode

เก็บค่าไว้ในตัวแปร เช่น nextCursor

  1. เรียกหน้าถัดไปในขั้นตอน ForEach หรือ loop โดยส่งตัวแปร:
   {{nextCursor}}
Enter fullscreen mode Exit fullscreen mode
  1. ดึง $.next_cursor ใหม่ทุกครั้ง

  2. หยุดเมื่อ has_more เป็น false

  3. เพิ่ม assertions ในทุกหน้า:

  • ไม่มี id ซ้ำกับหน้าก่อนหน้า
  • จำนวนรายการไม่เกิน limit
  • สถานะ HTTP ถูกต้อง
  • Cursor ถัดไปมีค่าเมื่อ has_more เป็น true

ดูวิธีใช้ assertions และ JSONPath ได้จาก คู่มือการตั้งค่า assertions และดึงตัวแปรด้วย JSONPath

ทดสอบ Offset แบบหลายหน้า

ใช้โครงสร้างเดียวกัน แต่เก็บตัวแปร page แทน Cursor:

  • เพิ่มค่า page ในแต่ละรอบ
  • ตรวจสอบว่าจำนวน data เท่ากับ per_page จนถึงหน้าสุดท้าย
  • ตรวจสอบว่า total และ total_pages สอดคล้องกันตลอดการเรียกดู
  • ตรวจสอบว่าไม่มีรายการซ้ำหรือหายระหว่างการดึงข้อมูล

เพิ่มกรณีขอบ

สร้างแต่ละกรณีเป็นขั้นตอนทดสอบแยกกัน:

  • หน้าว่าง: ใช้ตัวกรองที่ไม่ตรงกับข้อมูล ตรวจสอบว่า data เป็น [], has_more เป็น false และสถานะเป็น 200
  • Cursor ไม่ถูกต้อง: ส่ง cursor=not-a-real-cursor ตรวจสอบสถานะ 400 และ error code ที่อ่านได้โดยโปรแกรม
  • แถวหลักถูกลบ: สร้างคำสั่งซื้อ ดึง Cursor ที่อ้างถึงคำสั่งซื้อนั้น ลบคำสั่งซื้อ แล้วใช้ Cursor เดิม ตรวจสอบว่าการเรียกดูดำเนินต่อจากตำแหน่งที่ถูกต้อง

Keyset Pagination รองรับกรณีแถวหลักถูกลบได้ตามธรรมชาติ เพราะเงื่อนไขนี้ไม่จำเป็นต้องให้แถวหลักยังคงอยู่:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

เมื่อสถานการณ์ทดสอบผ่านในเครื่องแล้ว ให้รันใน CI ทุกครั้งที่มีการผสานโค้ด ดาวน์โหลด Apidog เพื่อสร้างสถานการณ์ทดสอบแบบลูกโซ่ พร้อม loop และ assertions ได้ภายในเวลาไม่ถึงครึ่งชั่วโมง

คำถามที่พบบ่อย

Cursor-based ดีกว่าเสมอหรือไม่?

ไม่เสมอไป Offset เหมาะกว่าเมื่อผู้ใช้ต้องการหมายเลขหน้า จำนวนรวม และการเข้าถึงแบบสุ่มบนชุดข้อมูลขนาดปานกลาง ซึ่งเป็นลักษณะของเครื่องมือผู้ดูแลระบบภายใน

Cursor เหมาะกับชุดข้อมูลขนาดใหญ่ ระบบที่มีการเขียนข้อมูลบ่อย หรือ API สาธารณะ ปัญหาคือการใช้ Offset เป็นค่าเริ่มต้นกับปลายทางสาธารณะ แล้วเพิ่งพบต้นทุน O(n) หลังเปิดใช้งานจริง

จะนับจำนวนรวมด้วย Cursor ได้อย่างไร?

รัน SELECT COUNT(*) แยกต่างหากโดยใช้ตัวกรองเดียวกัน อาจทำเป็นปลายทางแยก หรือเปิดใช้ด้วยพารามิเตอร์ เช่น include_count=true

ควรแคชผลลัพธ์อย่างจริงจัง การนับโดยประมาณที่อัปเดตทุกนาทีก็เพียงพอสำหรับ UI ส่วนใหญ่ Stripe เลือกไม่ส่งจำนวนรวมเลย ซึ่งสะท้อนว่าหลายไคลเอนต์ไม่ได้ต้องการข้อมูลนี้จริงๆ

สามารถรองรับทั้ง Offset และ Cursor ในปลายทางเดียวได้หรือไม่?

ทำได้ และ GitHub เคยใช้แนวทางนี้ในช่วงเปลี่ยนผ่าน แต่ไม่ควรทำกับ API ใหม่หากไม่จำเป็น เพราะจะเพิ่ม:

  • กรณีขอบสองชุด
  • เมทริกซ์การทดสอบสองชุด
  • ความสับสนว่าไคลเอนต์ควรใช้รูปแบบใด

ควรเลือกหนึ่งรูปแบบต่อปลายทาง หากกำลังออกแบบ API ใหม่ คู่มือการแบ่งหน้า REST API จะช่วยกำหนดชื่อพารามิเตอร์ให้สอดคล้องกัน

จะเกิดอะไรขึ้นหากแถวหลักของ Cursor ถูกลบ?

ด้วย Keyset Pagination จะไม่มีอะไรพัง เงื่อนไข:

WHERE (created_at, id) < (?, ?)
Enter fullscreen mode Exit fullscreen mode

ไม่จำเป็นต้องให้แถวหลักยังมีอยู่ ระบบจะค้นหาต่อจากขอบเขตนั้นได้โดยตรง นี่เป็นข้อได้เปรียบเหนือการออกแบบที่ Cursor ต้องใช้ค้นหาแถวหลัก และเป็นกรณีขอบที่ควรยืนยันในสถานการณ์ทดสอบก่อนที่ผู้ใช้จะพบปัญหาเอง

Top comments (0)