ทุกๆ ปลายทางรายการต้องตอบคำถามเดียวกัน: จะแบ่งคำสั่งซื้อ 2 ล้านรายการออกเป็นหน้าๆ ให้ไคลเอนต์เรียกดูได้อย่างไร? การแบ่งหน้าแบบ Offset ให้ SQL ที่เรียบง่ายและผู้ใช้ข้ามไปยังหมายเลขหน้าได้ ส่วน Cursor-based ให้ผลลัพธ์ที่เสถียรและประสิทธิภาพคงที่ แม้เรียกดูข้อมูลลึก แต่จะไม่มีความสามารถในการ “ข้ามไปยังหน้า 47” โดยตรง
ทีมส่วนใหญ่มักเริ่มด้วย 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;
คิวรีนี้คืนหน้า 3 โดยมี 25 แถวต่อหน้า:
GET /v1/orders?page=3&per_page=25
ตัวอย่างการตอบกลับ:
{
"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
}
ข้อดีของ 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;
ใช้คีย์จัดเรียงที่ไม่ซ้ำกัน
created_at เพียงอย่างเดียวไม่เพียงพอ เพราะคำสั่งซื้อหลายรายการอาจถูกสร้างในมิลลิวินาทีเดียวกัน หากคีย์การจัดเรียงไม่เป็นเอกลักษณ์ อาจเกิดข้อมูลซ้ำหรือข้อมูลหายที่ขอบหน้า
การเพิ่ม id เป็น tie-breaker จะทำให้ลำดับชัดเจน:
ORDER BY created_at DESC, id DESC
ควรสร้างดัชนีรวมให้สอดคล้องกัน:
CREATE INDEX orders_created_at_id_idx
ON orders (created_at DESC, id DESC);
ด้วยดัชนีนี้ ฐานข้อมูลสามารถค้นหาไปยังขอบเขตโดยตรงและอ่านเพียง 25 รายการ หน้าที่ 1 และหน้าที่ 60,000 จึงมีต้นทุนใกล้เคียงกัน หรือประมาณ O(1) ต่อหน้า
อย่าเปิดเผยค่าดิบใน Cursor
API ไม่ควรส่งค่าคีย์การจัดเรียงแบบดิบ ควรเข้ารหัสเป็นโทเค็นที่ไม่โปร่งใส เช่น Base64:
GET /v1/orders?limit=25&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wOC0zMFQxNDoyMjowN1oiLCJpZCI6Im9yZF84ODIxIn0
ความทึบแสงเป็นการตัดสินใจด้านการออกแบบ ไม่ใช่เพียงการทำให้ค่าดูอ่านยาก เมื่อไคลเอนต์แยกวิเคราะห์ 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 ล้านรายการต้องการ:
- ไม่ให้แถวหาย แม้มีการเขียนข้อมูลพร้อมกัน
- มีต้นทุนคงที่ต่อหน้า
- กลับมาทำงานต่อจาก 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"
}
กฎสำคัญ 4 ข้อ
ส่งคืน
has_moreเสมอ
ไคลเอนต์ไม่ควรอนุมานจุดสิ้นสุดจากหน้าที่มีข้อมูลน้อย เพราะผลลัพธ์อาจสั้นลงจากการกรองข้อมูลภายหลังใช้ค่า
next_cursorให้สม่ำเสมอ
ส่งคืนnullในหน้าสุดท้าย หรือใช้สตริงว่างแบบ Slack แต่ควรเลือกเพียงรูปแบบเดียวและบันทึกไว้ในเอกสารCursor ที่ไม่ถูกต้องต้องตอบ
400
อย่าตอบ200พร้อมข้อมูลว่าง เพราะจะทำให้ข้อผิดพลาดของไคลเอนต์ถูกซ่อนลงชื่อหรือกำหนดเวอร์ชันของ Cursor
หาก Cursor มีข้อมูลมากกว่าคีย์การจัดเรียง ควรมี signature หรือ version เพื่อรองรับการย้าย schema ในอนาคต
ทดสอบทั้งสองรูปแบบใน Apidog
ข้อผิดพลาดของ Pagination มักซ่อนอยู่ที่ขอบเขต เช่น หน้าสุดท้าย หน้าว่าง หรือ Cursor ที่อ้างถึงแถวซึ่งถูกลบไปแล้ว การคลิกทดสอบด้วยมือมักตรวจไม่พบ แต่สถานการณ์ทดสอบแบบลูกโซ่ช่วยตรวจได้
ทดสอบ Cursor แบบหลายหน้า
- เรียกปลายทางหน้าแรก แล้วดึงค่า Cursor ด้วย JSONPath:
$.next_cursor
เก็บค่าไว้ในตัวแปร เช่น nextCursor
- เรียกหน้าถัดไปในขั้นตอน
ForEachหรือ loop โดยส่งตัวแปร:
{{nextCursor}}
ดึง
$.next_cursorใหม่ทุกครั้งหยุดเมื่อ
has_moreเป็นfalseเพิ่ม 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) < (?, ?)
เมื่อสถานการณ์ทดสอบผ่านในเครื่องแล้ว ให้รันใน 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) < (?, ?)
ไม่จำเป็นต้องให้แถวหลักยังมีอยู่ ระบบจะค้นหาต่อจากขอบเขตนั้นได้โดยตรง นี่เป็นข้อได้เปรียบเหนือการออกแบบที่ Cursor ต้องใช้ค้นหาแถวหลัก และเป็นกรณีขอบที่ควรยืนยันในสถานการณ์ทดสอบก่อนที่ผู้ใช้จะพบปัญหาเอง
Top comments (0)