Offset Pagination — การแบ่งหน้าแบบออฟเซ็ต — วิธีการโหลดข้อมูลแบบแบ่งหน้าผ่าน HTTP API โดยไคลเอนต์ส่งพารามิเตอร์ offset (ออฟเซ็ตจากจุดเริ่มต้น) และ limit (ขนาดหน้า) และเซิร์ฟเวอร์ส่งคืนเรกคอร์ดจากตำแหน่ง offset ตามข้อมูลจาก REST API Tutorial แนวทางนี้ถูกใช้อย่างแพร่หลายในบริการ RESTful เนื่องจากความเรียบง่ายในการนำไปใช้ อย่างไรก็ตาม ในปริมาณข้อมูลขนาดใหญ่ การแบ่งหน้าแบบออฟเซ็ตจะสูญเสียประสิทธิภาพเนื่องจากการสแกนทั้งตารางจนถึงตำแหน่งที่ต้องการ
ประเด็นสำคัญ
Offset Pagination เป็นวิธีการแบ่งหน้าข้อมูลที่คำขอของไคลเอนต์ประกอบด้วยสองพารามิเตอร์: offset (จำนวนเรกคอร์ดที่จะข้าม) และ limit (จำนวนเรกคอร์ดที่จะส่งคืน) เซิร์ฟเวอร์ดำเนินการ查询 SQL ด้วย OFFSET และ LIMIT ข้ามจำนวนแถวที่ระบุ และส่งคืนชุดผลลัพธ์ขนาดคงที่
วิธีการนี้เกิดขึ้นในฐานข้อมูลเชิงสัมพันธ์เพื่อเป็นวิธีที่ง่ายที่สุดในการจัดระเบียบการนำทางระหว่างหน้า และถูกถ่ายโอนไปยัง HTTP API พร้อมกับการพัฒนาสถาปัตยกรรม REST Offset Pagination ไม่ต้องการการจัดเก็บสถานะบนเซิร์ฟเวอร์ — แต่ละคำขอเป็นอิสระและมีข้อมูลทั้งหมดที่จำเป็นสำหรับการ查询
ตามรายงานการออกแบบ API ของ Postman (2025) การแบ่งหน้าแบบออฟเซ็ตถูกใช้ใน 72% ของ REST API สาธารณะ ทำให้เป็นมาตรฐานที่โดดเด่นแม้จะมีข้อจำกัดด้านประสิทธิภาพที่ทราบในชุดข้อมูลขนาดใหญ่
คำขอ REST ทั่วไปที่มี Offset Pagination ประกอบด้วยพารามิเตอร์查询 offset และ limit การตอบสนองประกอบด้วยรายการเรกคอร์ดของหน้าที่ขอและข้อมูลเมตาสำหรับสร้างอินเทอร์เฟซการนำทาง
พารามิเตอร์ limit จำกัดจำนวนเรกคอร์ดที่ส่งคืนและปกป้องเซิร์ฟเวอร์และไคลเอนต์จากการโหลดมากเกินไป ค่า limit ทั่วไปอยู่ระหว่าง 10 ถึง 50 เรกคอร์ดต่อหน้าขึ้นอยู่กับความซับซ้อนของข้อมูล
data class PageRequest(
val offset: Int,
val limit: Int
)
data class PageResponse<T>(
val items: List<T>,
val total: Int,
val hasMore: Boolean
)
fun RetrofitApi.fetchPage(request: PageRequest): Call<PageResponse<Item>>
Offset Pagination แปลงเป็นคำสั่ง SQL ด้วยโครงสร้าง OFFSET และ FETCH NEXT (หรือ LIMIT ใน MySQL/SQLite) เซิร์ฟเวอร์ฐานข้อมูลสแกนตาราง ข้ามจำนวนแถวเท่ากับออฟเซ็ต และส่งคืน limit แถวถัดไป ยิ่งออฟเซ็ตมากเท่าไร คำสั่งก็ยิ่งใช้เวลานานขึ้นเท่านั้น
ปัญหาด้านประสิทธิภาพเกี่ยวข้องกับข้อเท็จจริงที่ว่าฐานข้อมูลไม่สามารถข้ามไปยังตำแหน่งออฟเซ็ตได้โดยตรง — ต้องอ่านและละทิ้งแถวก่อนหน้าทั้งหมด ที่ offset = 100000 และ limit = 20 ระบบจัดการฐานข้อมูลอ่าน 100,020 แถวและส่งคืนเพียง 20 แถว
SQL — ภาษาที่เซิร์ฟเวอร์ดำเนินการแบ่งหน้าแบบออฟเซ็ต PostgreSQL และ MySQL ใช้ LIMIT ในขณะที่ SQL Server และ Oracle ใช้ OFFSET...FETCH ระบบจัดการฐานข้อมูลต่างๆ ปรับคำสั่งนี้ให้เหมาะสมแตกต่างกัน แต่ปัญหาการสแกนพื้นฐานยังคงเหมือนเดิม
SELECT id, title, created_at
FROM articles
ORDER BY created_at DESC
OFFSET 100000 ROWS
FETCH NEXT 20 ROWS ONLY;
ความสอดคล้อง ของข้อมูลเป็นข้อเสียหลักของ Offset Pagination เมื่อทำงานกับชุดข้อมูลแบบไดนามิก หากมีการเพิ่มเรกคอร์ดใหม่ที่จุดเริ่มต้นของตารางระหว่างคำขอของผู้ใช้สองครั้ง เรกคอร์ดที่มีอยู่ทั้งหมดจะเลื่อนตำแหน่ง ผู้ใช้จะเห็นรายการซ้ำหรือช่องว่าง
พิจารณาตารางที่มี 100 เรกคอร์ดโดยมี limit = 20 ในหน้า 1 ผู้ใช้เห็นเรกคอร์ด 1-20 ผู้ดูแลระบบเพิ่ม 5 เรกคอร์ดใหม่ ในหน้า 2 ผู้ใช้เห็นเรกคอร์ด 26-45 แทนที่ 21-40 ที่คาดไว้ — เรกคอร์ด 21-25 ถูกข้าม และเรกคอร์ด 21-25 จากชุดก่อนหน้า ถูกทำซ้ำ ในหน้า 1
การแบ่งหน้าแบบ Cursor-based เป็นทางเลือกแทน Offset Pagination ที่ใช้ตัวชี้ไปยังเรกคอร์ดสุดท้ายของหน้าปัจจุบัน แทนที่จะใช้ออฟเซ็ตเชิงตัวเลข ไคลเอนต์ส่งตัวระบุของเรกคอร์ดสุดท้ายที่ได้รับ และเซิร์ฟเวอร์ส่งคืน N เรกคอร์ดถัดไปหลังจากนั้น
แนวทางแบบ cursor-based แก้ปัญหาความสอดคล้อง: ตำแหน่งเคอร์เซอร์ไม่เปลี่ยนแปลงเมื่อมีการแทรกหรือลบเนื่องจากเคอร์เซอร์อ้างอิงถึงเรกคอร์ดเฉพาะ ไม่ใช่ตำแหน่ง อย่างไรก็ตาม มันซับซ้อนกว่าในการนำไปใช้ — ต้องมีฟิลด์ที่ไม่ซ้ำกันที่เรียงลำดับได้ (โดยปกติคือ ID หรือ timestamp)
| พารามิเตอร์ | Offset Pagination | Cursor-based Pagination |
|---|---|---|
| ความเรียบง่าย | สูง — สองพารามิเตอร์ตัวเลข | ปานกลาง — ต้องเข้ารหัสเคอร์เซอร์ |
| ความสอดคล้อง | ต่ำ — รายการซ้ำเมื่อแทรก | สูง — เคอร์เซอร์ไม่ได้รับผลกระทบจากการเปลี่ยนแปลง |
| ประสิทธิภาพ | ลดลงเมื่อออฟเซ็ตใหญ่ | คงที่ในทุกปริมาณ |
| กระโดดไปหน้า | ได้ — สามารถนำทางไปยังหน้าใดก็ได้ | ไม่ได้ — นำทางตามลำดับเท่านั้น |
| เหมาะสำหรับ | ตาราง <10K เรกคอร์ด, UI ที่มีเลขหน้า | ฟีด, เลื่อนไม่สิ้นสุด, ชุดข้อมูลขนาดใหญ่ |
การเลือกระหว่างแนวทางขึ้นอยู่กับความต้องการ อินเทอร์เฟซ ของผู้ใช้ หากต้องการการนำทางด้วยเลขหน้าและการกระโดดโดยตรง — Offset Pagination ง่ายกว่า สำหรับการเลื่อนไม่สิ้นสุดหรือฟีดข่าว เคอร์เซอร์จะดีกว่า
Keyset pagination เป็นรูปแบบหนึ่งของแนวทาง cursor-based ที่การกรองดำเนินการบนคีย์ที่ไม่ซ้ำกันโดยใช้ WHERE แทน OFFSET คำสั่ง SQL ใช้เงื่อนไขเช่น WHERE id > lastId ซึ่งช่วยให้ฐานข้อมูลใช้ดัชนีโดยไม่ต้องสแกนแถวที่ถูกทิ้ง
ตาม PostgreSQL Wiki keyset pagination ทำงานเร็วกว่า 100-1000 เท่าเมื่อเทียบกับคำสั่งแบบออฟเซ็ตที่ออฟเซ็ตขนาดใหญ่ เนื่องจากการสแกนดัชนีแทนที่การสแกนทั้งตาราง ข้อเสียคือไม่สามารถกระโดดไปยังหน้าที่ต้องการได้โดยไม่ต้องเดินตามลำดับ
Offset Pagination เหมาะสมที่สุดสำหรับชุดข้อมูลขนาดเล็กถึงกลาง (สูงถึง 10,000 เรกคอร์ด) ที่ผู้ใช้ต้องการอินเทอร์เฟซแบบเลขหน้า สถานการณ์ทั่วไปรวมถึงแผงผู้ดูแลระบบ รายการคำสั่งซื้อ และแคตตาล็อกที่กรองแล้วพร้อมการแบ่งหน้าตามหน้า
สำหรับแอปพลิเคชันมือถือ การแบ่งหน้าแบบออฟเซ็ตเหมาะเมื่อโหลดข้อมูลประวัติที่การแทรกใหม่เกิดขึ้นน้อยหรือเป็นไปไม่ได้ — ตัวอย่างเช่น ประวัติคำสั่งซื้อของผู้ใช้ รายการงานที่เสร็จสมบูรณ์ คลังธุรกรรม ในสถานการณ์เหล่านี้ ปัญหาความสอดคล้องจะไม่เกิดขึ้น
ไม่แนะนำ สำหรับฟีดโซเชียลมีเดีย รายการความคิดเห็น แชท และชุดข้อมูลแบบไดนามิกอื่นๆ ที่มีการแทรกบ่อยครั้ง ในกรณีเหล่านี้ ช่องว่างและเรกคอร์ดซ้ำจะลดคุณภาพประสบการณ์ผู้ใช้และต้องการตรรกะการลบรายการซ้ำเพิ่มเติมบนไคลเอนต์
การแบ่งหน้าแบบผสม รวม offset และ cursor: คำขอแรกใช้ offset เพื่อแสดงหน้าเริ่มต้น ในขณะที่คำขอถัดไปใช้ cursor สำหรับการโหลดแบบเลื่อนไม่สิ้นสุด แนวทางนี้ใช้ใน Instagram และ Twitter ซึ่งหน้าแรกโหลดผ่าน cursor แต่ offset ใช้ในการคำนวณตำแหน่งเมื่อกลับไปยังมุมมองก่อนหน้า
การนำแนวทางแบบผสมไปใช้ต้องการการจัดเก็บตำแหน่งเสมือนของผู้ใช้บนไคลเอนต์และการประสานงานกลไกการแบ่งหน้าสองแบบบนเซิร์ฟเวอร์ ตามบล็อก Instagram Engineering ทีมของพวกเขาใช้การแบ่งหน้าแบบ cursor-based พร้อมฟิลด์เพิ่มเติม startCursor ที่แทนที่ offset สำหรับการโหลดเริ่มต้น
แอปพลิเคชันมือถือ ใช้ Offset Pagination ร่วมกับ Retrofit/OkHttp บน Android และ URLSession/Combine บน iOS รูปแบบทั่วไปคือการโหลดหน้าถัดไปเมื่อเลื่อนไปจนสุดรายการผ่าน RecyclerView.OnScrollListener หรือ UICollectionView prefetching
การนำ Offset Pagination ไปใช้บนไคลเอนต์มือถือประกอบด้วยสามองค์ประกอบ: ตัวจัดการการแบ่งหน้า (จัดเก็บ offset ปัจจุบันและ hasMore), ตัวปรับรายการ (แสดงรายการและตัวบ่งชี้การโหลด), และพื้นที่เก็บข้อมูล (ดำเนินการคำขอและจัดการข้อผิดพลาด) Android Jetpack มีไลบรารี Paging 3 ซึ่งรองรับทั้งการแบ่งหน้าแบบ offset และ cursor-based พร้อมใช้งานทันที
Paging 3 เป็นไลบรารี Android Jetpack สำหรับการโหลดข้อมูลแบบแบ่งหน้า มันห่อหุ้มตรรกะการแบ่งหน้า รวมถึงการติดตามออฟเซ็ต การจัดการสถานะการโหลด และการโหลดล่วงหน้าอัตโนมัติเมื่อเลื่อน PagingSource กำหนดคีย์สำหรับหน้าถัดไปและหน้าก่อนหน้า
class OffsetPagingSource(
private val api: ApiService,
private val limit: Int = 20
) : PagingSource<Int, Item>() {
override suspend fun load(
params: LoadParams<Int>
): LoadResult<Int, Item> {
val offset = params.key ?: 0
return try {
val response = api.getItems(offset, limit)
LoadResult.Page(
data = response.items,
prevKey = null,
nextKey = if (response.hasMore) offset + limit else null
)
} catch (e: Exception) {
LoadResult.Error(e)
}
}
}
PagingSource กำหนดคีย์ prevKey และ nextKey สำหรับการนำทางระหว่างหน้า ด้วยการแบ่งหน้าแบบออฟเซ็ต prevKey เป็น null เสมอ (ไม่สามารถไปหน้าก่อนหน้าได้โดยไม่บันทึกประวัติ) ในขณะที่ nextKey เพิ่มขึ้นทีละ limit ในการโหลดแต่ละครั้งจนกว่าเซิร์ฟเวอร์จะส่งคืน hasMore = false นี่เป็นโมเดลที่เรียบง่ายและคาดการณ์ได้สำหรับรายการมือถือ
ข้อผิดพลาดแรก — การพึ่งพาลำดับเรกคอร์ดโดยไม่มีการเรียงลำดับ Offset Pagination ต้องการ การเรียงลำดับ ORDER BY ที่เสถียรบนฟิลด์ที่ไม่ซ้ำกัน หากไม่มี ระบบจัดการฐานข้อมูลอาจส่งคืนเรกคอร์ดในลำดับใดก็ได้ ทำให้เกิดรายการซ้ำและช่องว่างแบบสุ่มระหว่างหน้า
ข้อผิดพลาดที่สอง — การใช้ offset เพื่อคำนวณ เลขหน้า ในอินเทอร์เฟซ สูตร page = offset / limit + 1 ทำงานเฉพาะเมื่อไม่มีเรกคอร์ดถูกลบหรือเพิ่มระหว่างการโหลด ด้วยข้อมูลแบบไดนามิก เลขหน้าจะไม่แม่นยำและผู้ใช้เห็นข้อมูลที่ไม่ถูกต้อง
ข้อผิดพลาดที่สาม — การเพิกเฉยต่อ การหมดเวลา ของคำสั่งที่มี offset ขนาดใหญ่ ที่ offset เกิน 100,000 คำสั่งอาจใช้เวลาหลายสิบวินาที ปิดกั้นอินเทอร์เฟซและใช้ทรัพยากรเซิร์ฟเวอร์ ควรตั้งค่าออฟเซ็ตสูงสุดที่ระดับ API (เช่น 10,000) และใช้การแบ่งหน้าแบบ cursor-based สำหรับปริมาณมาก
ข้อผิดพลาดที่สี่ — การไม่รวม total count ในการตอบสนอง หากไม่มีจำนวนเรกคอร์ดทั้งหมด ไคลเอนต์ไม่สามารถแสดงจำนวนหน้าและใช้การแบ่งหน้าแบบมีเลขได้ อย่างไรก็ตาม COUNT(*) บนตารางขนาดใหญ่มีค่าใช้จ่ายสูง — สำหรับชุดข้อมูลที่เกิน 100,000 เรกคอร์ด ให้ใช้การประมาณหรือจำกัดค่าสูงสุดของ total
คำถามที่พบบ่อย
Offset ใช้ออฟเซ็ตตัวเลขเพื่อข้ามเรกคอร์ด ในขณะที่ cursor ใช้ตัวชี้ไปยังเรกคอร์ดสุดท้ายของหน้าก่อนหน้า Offset ใช้งานได้ง่ายกว่าแต่ประสบปัญหารายการซ้ำเมื่อแทรกและประสิทธิภาพลดลงที่ออฟเซ็ตขนาดใหญ่ Cursor มีความเสถียรไม่ว่าข้อมูลจะเปลี่ยนแปลงอย่างไร
การแบ่งหน้าแบบออฟเซ็ต ไม่มีประสิทธิภาพ ที่ออฟเซ็ตเกิน 10,000 เรกคอร์ดเนื่องจากการสแกนทั้งตาราง นอกจากนี้ยังไม่เหมาะสำหรับชุดข้อมูลแบบไดนามิก (ฟีด แชท) ที่เรกคอร์ดใหม่ปรากฏระหว่างคำขอ — ผู้ใช้เห็นช่องว่างและเรกคอร์ดซ้ำระหว่างการนำทาง
limit ที่เหมาะสมขึ้นอยู่กับขนาดเรกคอร์ดและความเร็วเครือข่าย — 10 ถึง 50 รายการต่อหน้า สำหรับรายการที่มีรูปภาพขนาดใหญ่ ใช้ limit = 10-15 สำหรับข้อมูลข้อความ 20-50 อนุญาตให้ไคลเอนต์ระบุ limit ของตนเองเสมอโดยมีค่าสูงสุดบนเซิร์ฟเวอร์ (โดยปกติ 100)
เพื่อจัดการกับ รายการซ้ำ ใช้การลบรายการซ้ำฝั่งไคลเอนต์โดย ID ที่ไม่ซ้ำกัน ใช้การเรียงลำดับที่เสถียรบนฟิลด์ที่ไม่ซ้ำกัน หรือเปลี่ยนไปใช้การแบ่งหน้าแบบ cursor-based Android Paging 3 รองรับ key สำหรับการลบรายการซ้ำอัตโนมัติของรายการในรายการ
ได้ GraphQL รองรับการแบ่งหน้าแบบออฟเซ็ตผ่านอาร์กิวเมนต์ offset และ limit ในคำสั่ง แม้ว่าข้อกำหนด Relay จะแนะนำแนวทางแบบ cursor-based ไลบรารี Apollo GraphQL และ Relay ให้การสนับสนุนในตัวสำหรับการแบ่งหน้าแบบออฟเซ็ตพร้อมการจัดการสถานะหน้าอัตโนมัติ
สรุป
เราจะพัฒนาแอปพลิเคชันบนมือถือแบบครบวงจร
IT Sectr สร้างแอปพลิเคชัน iOS และ Android สำหรับสตาร์ทอัพและธุรกิจตั้งแต่ปี 2017 เราจะให้คำแนะนำและเสนอวิธีแก้ปัญหาที่ดีที่สุดแก่คุณ
อ่านเพิ่มเติม