Cursor Pagination เป็นวิธีการโหลดข้อมูลแบบแบ่งหน้าที่ใช้เคอร์เซอร์เฉพาะเพื่อนำทางผ่านชุดเรกคอร์ดที่เรียงลำดับ ตาม GraphQL Specification (2025) การแบ่งหน้าแบบเคอร์เซอร์เป็นมาตรฐานที่แนะนำสำหรับ API ที่ทำงานกับข้อมูลไดนามิก Cursor pagination กำจัดข้อเสียหลักของแนวทาง Offset: ความไม่เสถียรระหว่างการแทรกและการลดลงของประสิทธิภาพบน offset ขนาดใหญ่
ประเด็นสำคัญ
Cursor Pagination เป็นวิธีการโหลดแบบแบ่งหน้าที่เซิร์ฟเวอร์ส่งคืนตัวชี้พิเศษ — เคอร์เซอร์ — พร้อมกับข้อมูล ไคลเอ็นต์ใช้เคอร์เซอร์นี้ในคำขอถัดไปเพื่อรับชุดถัดไปของเรกคอร์ด เคอร์เซอร์คือตัวระบุเฉพาะขององค์ประกอบสุดท้ายในหน้าปัจจุบัน
ซึ่งแตกต่างจากการแบ่งหน้าแบบ Offset ที่ไคลเอ็นต์พูดว่า “ขอหน้า 5 พร้อม 20 เรกคอร์ด” การแบ่งหน้าแบบเคอร์เซอร์ทำงานต่างกัน: “ขอ 20 เรกคอร์ดหลังจากเรกคอร์ดที่มี ID = 83” เซิร์ฟเวอร์ดำเนินการคำสั่งด้วย WHERE id > 83 และ LIMIT 20 แนวทางนี้ รับประกันว่าแต่ละเรกคอร์ดจะตกอยู่ในหน้าเดียวโดยไม่คำนึงถึงการแทรก
แนวคิดของการแบ่งหน้าแบบเคอร์เซอร์ได้รับการยอมรับอย่างกว้างขวางเนื่องจากข้อกำหนด Relay Connection (GraphQL) ซึ่งทำให้การแบ่งหน้าแบบเคอร์เซอร์เป็นมาตรฐานสำหรับ API สมัยใหม่ Relay กำหนดรูปแบบการตอบสนอง: edges (อาร์เรย์ของเรกคอร์ดพร้อมเคอร์เซอร์), pageInfo (hasNextPage, hasPreviousPage, startCursor, endCursor)
การแบ่งหน้าแบบเคอร์เซอร์ไม่ใช่เทคนิคใหม่ — มันถูกใช้ในฐานข้อมูลมานานก่อนยุคเว็บ ใน SQL เรียกว่า keyset pagination หรือ seek method วิธีการนี้เป็นที่นิยมใน API หลังจากการเผยแพร่ข้อกำหนด Relay ในปี 2015 ซึ่งทำให้รูปแบบเคอร์เซอร์เป็นทางการเป็นสตริงที่เข้ารหัส base64 เพื่อความสอดคล้องในการส่งผ่าน HTTP
หลักการพื้นฐานของการแบ่งหน้าแบบเคอร์เซอร์คือคำสั่งใช้เงื่อนไข WHERE บนฟิลด์ที่มีดัชนีเพื่อกำหนดตำแหน่ง ไม่ใช่ offset สำหรับทิศทางไปข้างหน้าใช้ WHERE id > last_id; สำหรับทิศทางย้อนกลับใช้ WHERE id < first_id ดัชนี B-tree ค้นหาเรกคอร์ดแรกหลังจากเคอร์เซอร์ใน O(log n) ให้เวลาตอบสนองที่เสถียร
-- ดึงข้อมูล 20 รายการหลังเคอร์เซอร์ '83'
SELECT id, title, created_at
FROM posts
WHERE id < 83
ORDER BY id DESC
LIMIT 20;
-- ดึงข้อมูล 20 รายการก่อนเคอร์เซอร์ '83' (ย้อนกลับ)
SELECT id, title, created_at
FROM posts
WHERE id > 83
ORDER BY id ASC
LIMIT 20;
เคอร์เซอร์สามารถเป็นแบบง่าย (ค่า ID) หรือซับซ้อน (ประกอบจากหลายฟิลด์) เคอร์เซอร์แบบง่ายคือคีย์หลักของเรกคอร์ด เช่น id แบบเพิ่มอัตโนมัติหรือ UUID เคอร์เซอร์แบบผสมใช้สำหรับการเรียงลำดับตามฟิลด์ที่ไม่ซ้ำกัน เช่น (created_at, id) โดยที่ id รับประกันความเป็นเอกลักษณ์เมื่อการประทับเวลาเหมือนกัน
รูปแบบ API ทั่วไปคือเคอร์เซอร์เป็นสตริงที่เข้ารหัส base64 เซิร์ฟเวอร์ถอดรหัสเคอร์เซอร์ ดึงค่า และสร้างคำสั่ง SQL การเข้ารหัส Base64 ซ่อนโครงสร้างภายในของเคอร์เซอร์จากไคลเอ็นต์และอนุญาตให้เปลี่ยนรูปแบบโดยไม่ทำลายความเข้ากันได้ย้อนหลัง ไคลเอ็นต์รับเคอร์เซอร์ในฟิลด์ endCursor ของการตอบสนองและส่งผ่านเป็นสตริงในคำขอถัดไป
การแบ่งหน้าแบบเคอร์เซอร์รองรับการนำทางสองทิศทาง สำหรับการเคลื่อนที่ไปข้างหน้า (next) ใช้เคอร์เซอร์ขององค์ประกอบสุดท้ายในหน้าปัจจุบัน; สำหรับย้อนกลับ (previous) ใช้เคอร์เซอร์ขององค์ประกอบแรก พารามิเตอร์ after และ before ในคำขอกำหนดทิศทาง: after นำเรกคอร์ดหลังจากเคอร์เซอร์ before นำเรกคอร์ดก่อนเคอร์เซอร์
การเลือกระหว่างการแบ่งหน้าแบบเคอร์เซอร์และ Offset เป็นหนึ่งในการตัดสินใจทางสถาปัตยกรรมที่สำคัญเมื่อออกแบบ API แต่ละวิธีมีจุดแข็งและจุดอ่อนที่กำหนดความสามารถในการนำไปใช้ Cursor pagination ชนะในสถานการณ์ที่มีข้อมูลไดนามิก; Offset ชนะในสถานการณ์ที่มีการนำทางตามอำเภอใจ
| คุณลักษณะ | Cursor | Offset |
|---|---|---|
| ความเสถียรเมื่อแทรก | สูง (ไม่ซ้ำ) | ต่ำ (หน้ากระจัดกระจาย) |
| ประสิทธิภาพบนชุดข้อมูลขนาดใหญ่ | O(log n) — เสถียร | O(n) — ลดลงเมื่อเติบโต |
| การนำทางตามหมายเลขหน้า | ไม่ | ได้ (page=5) |
| ความซับซ้อนในการใช้งาน | ปานกลาง | ต่ำ |
| การรองรับ REST | cursor/before/after | page/offset |
| การรองรับ GraphQL | มาตรฐาน Relay | ไม่แนะนำ |
การแบ่งหน้าแบบ Offset ทำการสแกนตารางทั้งหมดจนถึงตำแหน่ง OFFSET ที่ offset=100000 ฐานข้อมูลอ่านและข้าม 100000 แถว แม้ว่า LIMIT จะเป็น 20 MySQL และ PostgreSQL ไม่สามารถปรับ OFFSET ให้เหมาะสมได้ — นี่คือคุณลักษณะการใช้งาน LIMIT/OFFSET ใน SQL การแบ่งหน้าแบบเคอร์เซอร์ใช้ดัชนี B-tree ที่ค้นหาตำแหน่งใน O(log n)
ปัญหาเพิ่มเติมของ Offset คือการ “ข้าม” เรกคอร์ดเมื่อแบ่งหน้าย้อนกลับ หากผู้ใช้โหลดหน้าที่ 5 และในขณะนั้นมีการเพิ่มเรกคอร์ดใหม่ เมื่อขอหน้าที่ 6 พวกเขาจะเห็นเรกคอร์ดจากหน้าที่ 5 อีกครั้งหรือพลาดเรกคอร์ดใหม่ Cursor pagination กำจัดสถานการณ์นี้อย่างสมบูรณ์: เคอร์เซอร์ชี้ไปยังตำแหน่งเฉพาะในชุด และการแทรกไม่เปลี่ยนตำแหน่ง
มาดูการใช้งานการแบ่งหน้าแบบเคอร์เซอร์บนแบ็กเอนด์ (Kotlin + Spring) และบนไคลเอ็นต์ (Android + Retrofit) เซิร์ฟเวอร์รับพารามิเตอร์ after, before, limit และส่งคืนรายการเรกคอร์ดพร้อมเคอร์เซอร์และ pageInfo การตอบสนองทั่วไปประกอบด้วย hasNextPage และ hasPreviousPage สำหรับจัดการ UI การแบ่งหน้า
@GetMapping("/posts")
fun getPosts(
@RequestParam after: Long?,
@RequestParam(defaultValue = "20") limit: Int
): CursorResponse<Post> {
val cursor = after ?: Long.MAX_VALUE
val posts = repository.findByIdLessThanOrderByIdDesc(
cursor, PageRequest.of(0, limit)
)
val endCursor = posts.lastOrNull()?.id
return CursorResponse(
data = posts,
pageInfo = PageInfo(
hasNextPage = posts.size == limit,
endCursor = endCursor
)
)
}
บนไคลเอ็นต์ การแบ่งหน้าแบบเคอร์เซอร์ใช้งานผ่าน PagingSource จาก Paging 3 โดยที่คีย์คือเคอร์เซอร์ (Long) PagingSource.load รับ LoadParams.key — เคอร์เซอร์ของเรกคอร์ดที่โหลดล่าสุด LoadResult.Page ส่งคืนข้อมูลและ nextKey — เคอร์เซอร์สำหรับหน้าถัดไป เมื่อ nextKey = null การแบ่งหน้าจะเสร็จสมบูรณ์
// Retrofit API
interface PostApi {
@GET("posts")
suspend fun getPosts(
@Query("after") after: Long?,
@Query("limit") limit: Int = 20
): CursorResponse<Post>
}
// PagingSource พร้อมคีย์เคอร์เซอร์
class PostPagingSource(
private val api: PostApi
) : PagingSource<Long, Post>() {
override suspend fun load(
params: LoadParams<Long>
): LoadResult<Long, Post> = try {
val response = api.getPosts(
after = params.key,
limit = params.loadSize
)
val nextKey = response.pageInfo.endCursor
LoadResult.Page(
data = response.data,
prevKey = null,
nextKey = nextKey
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
ใน GraphQL การแบ่งหน้าแบบเคอร์เซอร์ใช้งานผ่านรูปแบบ Relay Connection แต่ละประเภทมี Connection (พร้อม pageInfo และ edges) และ Edge (node + cursor) คำสั่งส่งพารามิเตอร์ first, after, last, before เซิร์ฟเวอร์ส่งคืน อาร์เรย์ของ edges พร้อมเคอร์เซอร์และ pageInfo พร้อม hasNextPage/hasPreviousPage
แนะนำให้ใช้การแบ่งหน้าแบบเคอร์เซอร์สำหรับ API ที่ทำงานกับ ข้อมูลไดนามิก ซึ่งเรกคอร์ดถูกเพิ่มหรือลบบ่อยครั้ง ตัวอย่างคลาสสิก: ฟีดข่าวในเครือข่ายสังคม ข้อความแชท ประวัติธุรกรรม ความคิดเห็นในโพสต์ ในทุกสถานการณ์เหล่านี้ ความสอดคล้องและการไม่มีการซ้ำซ้อนเป็นสิ่งสำคัญ
มีสถานการณ์ที่การแบ่งหน้าแบบ Offset สะดวกกว่า: แผงผู้ดูแลระบบ ที่ต้องการการนำทางตามหมายเลขหน้า; การค้นหาด้วยการแบ่งหน้า ที่ผลลัพธ์อาจเปลี่ยนแปลง; รายงานและการวิเคราะห์ ที่ต้องการลิงก์คงที่ไปยังหน้าที่ 5 ในกรณีเหล่านี้ ข้อดีของเคอร์เซอร์ไม่มีมากกว่าความซับซ้อนในการใช้งาน
การแบ่งหน้าแบบเคอร์เซอร์ไม่รองรับการ “กระโดด” ไปยังหน้าตามอำเภอใจ — ผู้ใช้ไม่สามารถคลิก “หน้าที่ 5” และไปที่นั่นได้ นี่คือข้อจำกัดทางสถาปัตยกรรม: การนับจำนวนหน้าทั้งหมด ต้องใช้คำสั่ง COUNT แยกต่างหาก ซึ่งอาจมีราคาแพงสำหรับตารางขนาดใหญ่ ในกรณีเช่นนี้ ใช้แนวทางแบบผสม: เคอร์เซอร์สำหรับข้อมูล + count สำหรับการแบ่งหน้า
คำถามที่พบบ่อย
เคอร์เซอร์คือ ตัวระบุเรกคอร์ดเฉพาะ ที่ชี้ไปยังตำแหน่งในชุดข้อมูล สามารถเป็นแบบง่าย (ID เรกคอร์ด) หรือแบบผสม (หลายฟิลด์) ไคลเอ็นต์รับเคอร์เซอร์ของเรกคอร์ดสุดท้ายในหน้าและส่งผ่านในคำขอถัดไปเพื่อรับชุดถัดไป
Cursor pagination ไม่ได้รับผลกระทบจากการเลื่อน เมื่อเพิ่มเรกคอร์ดใหม่ — แต่องค์ประกอบตกอยู่ในหน้าเดียว นอกจากนี้ยังรักษาความเร็วบนปริมาณมากโดยใช้ดัชนีแทนการสแกน n แถวแรก Offset ง่ายกว่าแต่ไม่เสถียรสำหรับข้อมูลไดนามิก
ได้ การแบ่งหน้าแบบเคอร์เซอร์ไม่ได้ผูกติดกับ GraphQL สามารถใช้งานได้ใน REST API ใด ๆ โดยส่งเคอร์เซอร์เป็นพารามิเตอร์คำสั่ง ?after=83&limit=20 การตอบสนองควรมี pageInfo พร้อม endCursor และ hasNextPage — สิ่งนี้ช่วยให้ไคลเอ็นต์จัดการการโหลดโดยไม่ต้องรู้โครงสร้างภายในของเคอร์เซอร์
ID แบบเพิ่มอัตโนมัติ เป็นตัวเลือกที่ดีที่สุด: เพิ่มขึ้นแบบโมโนโทนิก ไม่เปลี่ยนแปลง ถูกทำดัชนีอย่างมีประสิทธิภาพ UUID v7 (เรียงตามเวลา) ก็เหมาะสมเช่นกัน การประทับเวลาอาจสร้างการซ้ำซ้อนในเวลาเดียวกัน ดังนั้นให้รวมกับ ID: (created_at, id) เพื่อรับประกันความเป็นเอกลักษณ์ของเคอร์เซอร์
การแบ่งหน้าแบบเคอร์เซอร์ไม่ได้ให้จำนวนหน้าทั้งหมด — นี่คือข้อจำกัด หากคุณต้องการข้อมูลทั้งหมด ให้ดำเนินการ คำสั่ง COUNT แยกต่างหาก ด้วยตัวกรองเดียวกัน สำหรับตารางขนาดใหญ่ ใช้การนับประมาณผ่าน EXPLAIN หรือยอดรวมที่แคชไว้จากการวิเคราะห์
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม