Cursor vs Offset: การแบ่งหน้าสำหรับ API หน้าจอแอดมินที่รวดเร็ว
เรียนรู้การแบ่งหน้าแบบ cursor และ offset พร้อมสัญญา API ที่สอดคล้องสำหรับการจัดเรียง ตัวกรอง และยอดรวม ที่ทำให้หน้าจอแอดมินเร็วทั้งบนเว็บและมือถือ

ทำไมการแบ่งหน้าถึงทำให้หน้าจอแอดมินรู้สึกช้า
หน้าจอแอดมินมักเริ่มจากตารางง่าย ๆ: โหลด 25 แถวแรก เพิ่มกล่องค้นหา เสร็จแล้ว มันรู้สึกทันทีเมื่อมีแค่ไม่กี่ร้อยเรคอร์ด แล้วข้อมูลเพิ่มขึ้น และหน้าจอเดียวกันเริ่มกระตุก
ปัญหาโดยทั่วไปไม่ใช่ UI แต่เป็นสิ่งที่ API ต้องทำก่อนจะคืนหน้า 12 พร้อมการจัดเรียงและตัวกรอง เมื่อโตขึ้น backend ต้องใช้เวลาในการหาคู่ที่ตรงกัน นับผลลัพธ์ และข้ามแถวก่อนหน้า หากทุกการคลิกทำให้เกิดคิวรีที่หนักขึ้น หน้าจอจะรู้สึกเหมือนกำลังคิด แทนที่จะตอบสนอง
คุณมักสังเกตเห็นมันในที่เดิม ๆ: การเปลี่ยนหน้าแต่ละครั้งช้าลงเมื่อเวลาผ่านไป การจัดเรียงเริ่มหน่วง การค้นหารู้สึกไม่สม่ำเสมอข้ามหน้า และการเลื่อนแบบ infinite scroll โหลดเป็นช่วง ๆ (เร็ว แล้วก็ช้าทันที) ในระบบที่มีการใช้งานหนาแน่น คุณอาจเห็นแถวซ้ำหรือขาดหายเมื่อข้อมูลเปลี่ยนไประหว่างคำขอ
เว็บและมือถือผลักดันการแบ่งหน้าไปคนละทาง ตารางแอดมินบนเว็บสนับสนุนการกระโดดไปหน้าที่เฉพาะและการจัดเรียงหลายคอลัมน์ หน้าจอบนมือถือมักใช้ลิสต์แบบ infinite ที่โหลดชิ้นถัดไป และผู้ใช้คาดหวังว่าการดึงแต่ละครั้งจะเร็วเท่ากัน หาก API ของคุณออกแบบมาแค่รอบตัวเลขหน้า มือถือมักจะเดือดร้อน หากออกแบบแค่ next/after ตารางเว็บอาจรู้สึกจำกัด
เป้าหมายไม่ใช่แค่คืน 25 รายการ แต่มันคือการเพจที่เร็วและคาดเดาได้ซึ่งคงที่เมื่อข้อมูลโตขึ้น โดยมีกฎที่ทำงานเหมือนกันสำหรับทั้งตารางและลิสต์แบบ infinite
พื้นฐานการแบ่งหน้าที่ UI ของคุณพึ่งพา
การแบ่งหน้าคือการแบ่งรายการยาวเป็นชิ้นเล็ก ๆ เพื่อให้หน้าจอโหลดและแสดงผลได้เร็วขึ้น แทนที่จะขอทุกเรคอร์ด UI จะขอชิ้นถัดไปของผลลัพธ์
การควบคุมที่สำคัญที่สุดคือขนาดหน้า (มักเรียกว่า limit) หน้าขนาดเล็กมักรู้สึกเร็วกว่าเพราะเซิร์ฟเวอร์ทำงานน้อยลงและแอปแสดงแถวให้น้อยลง แต่หน้าที่เล็กเกินไปอาจรู้สึกกระโดดเพราะผู้ใช้ต้องคลิกหรือเลื่อนบ่อยขึ้น สำหรับตารางแอดมินหลายแห่ง 25 ถึง 100 รายการเป็นช่วงที่ใช้งานได้จริง โดยมือถือมักต้องการช่วงล่าง
การจัดเรียงที่เสถียรสำคัญกว่าที่ทีมมักคาดหวัง หากลำดับเปลี่ยนระหว่างคำขอ ผู้ใช้จะเห็นแถวซ้ำหรือหายไป การจัดเรียงที่เสถียรมักหมายถึงการเรียงตามฟิลด์หลัก (เช่น created_at) พร้อม tie-breaker (เช่น id) เรื่องนี้สำคัญไม่ว่าคุณจะใช้ offset หรือ cursor
จากมุมมองของไคลเอนต์ การตอบกลับแบบแบ่งหน้าควรมีไอเท็ม, เคล็ดลับหน้าถัดไป (หมายเลขหน้า หรือโทเค็น cursor) และจำนวนที่ UI ต้องการจริง ๆ บางหน้าจอจำเป็นต้องรู้ยอดรวมที่แม่นยำสำหรับ “1-50 จาก 12,340” บางหน้าจอต้องการแค่ has_more เท่านั้น
Offset pagination: ทำงานอย่างไรและจุดที่เป็นปัญหา
Offset pagination คือวิธีคลาสสิกแบบหน้า N ไคลเอนต์ขอจำนวนแถวคงที่และบอก API ว่าจะข้ามกี่แถวก่อน คุณจะเห็นเป็น limit และ offset หรือเป็น page และ pageSize ที่เซิร์ฟเวอร์แปลงเป็น offset
การร้องขอตัวอย่างเช่น:
GET /tickets?limit=50\u0026offset=950- “ให้ฉัน 50 ตั๋ว โดยข้าม 950 แถวแรก”
มันตรงกับความต้องการแอดมินทั่วไป: กระโดดไปหน้าที่ 20, สแกนเรคคอร์ดเก่า, หรือส่งออกเป็นชุดใหญ่ มันก็ง่ายจะอธิบายภายในว่า “ดูที่หน้า 3 แล้วคุณจะเห็นมัน”
ปัญหาปรากฏบนหน้าลึก ฐานข้อมูลหลายแห่งยังคงต้องเดินผ่านแถวที่ข้ามไปก่อนจะคืนหน้าของคุณ โดยเฉพาะเมื่อการจัดเรียงไม่ได้รองรับด้วยดัชนีที่แน่น หน้า 1 อาจเร็ว แต่หน้า 200 อาจช้าลงอย่างเห็นได้ชัด ซึ่งเป็นสิ่งที่ทำให้หน้าจอแอดมินรู้สึกหน่วงเมื่อผู้ใช้เลื่อนหรือกระโดดไปรอบ ๆ
ปัญหาอีกอย่างคือตอนข้อมูลเปลี่ยนแปลง ความคงเส้นคงวา ตัวอย่างเช่น ผู้จัดการฝ่ายซัพพอร์ตเปิดหน้า 5 ของตั๋วที่เรียงตามล่าสุด ในขณะที่เขาดูตั๋วมาถึงใหม่หรือเรคอร์ดเก่าถูกลบ การแทรกข้อมูลอาจดันไอเท็มไปข้างหน้า (ทำให้เกิดแถวซ้ำข้ามหน้า) การลบอาจดึงไอเท็มกลับ (เรคอร์ดหายไปจากเส้นทางการเรียกดู)
Offset pagination ยังใช้ได้กับตารางเล็ก ชุดข้อมูลสเถียร หรือการส่งออกครั้งเดียว แต่บนตารางขนาดใหญ่และแอคทีฟ ปัญหาเหล่านี้จะปรากฏเร็ว
Cursor pagination: ทำงานอย่างไรและทำไมมันคงที่
Cursor pagination ใช้ cursor เป็นบุ๊คมาร์ก แทนที่จะบอกว่า “เอาหน้า 7” ไคลเอนต์จะบอกว่า “ต่อจากไอเท็มนี้” โดย cursor ปกติจะเข้ารหัสค่าการจัดเรียงของไอเท็มสุดท้าย (เช่น created_at และ id) เพื่อให้เซิร์ฟเวอร์สามารถเริ่มจากจุดที่ถูกต้อง
การร้องขอมักมีเพียง:
limit: จำนวนไอเท็มที่จะคืนcursor: โทเค็นทึบจากการตอบก่อนหน้า (มักเรียกว่าafter)
การตอบกลับคืนไอเท็มพร้อม cursor ใหม่ที่ชี้ไปยังจุดสิ้นสุดของชิ้นนั้น ความแตกต่างเชิงปฏิบัติคือ cursor ไม่ขอให้ฐานข้อมูลนับและข้ามแถว พวกมันขอให้เริ่มจากตำแหน่งที่รู้จัก
นั่นคือเหตุผลที่ cursor pagination ยังคงเร็วสำหรับลิสต์ที่เลื่อนไปข้างหน้า ด้วยดัชนีที่ดี ฐานข้อมูลสามารถกระโดดไปยัง “รายการหลัง X” แล้วอ่าน limit ถัดไปได้ ด้วย offset เซิร์ฟเวอร์มักต้องสแกน (หรืออย่างน้อยก็ข้าม) แถวมากขึ้นเรื่อย ๆ เมื่อ offset เพิ่มขึ้น
สำหรับพฤติกรรม UI cursor ทำให้ “Next” เป็นเรื่องธรรมชาติ: ใช้ cursor ที่ได้จากการตอบกลับแล้วส่งกลับในการร้องขอต่อไป “Previous” เป็นไปได้แต่ซับซ้อนกว่าบาง API รองรับ before cursor ขณะที่บาง API ดึงข้อมูลย้อนกลับแล้วสลับผลลัพธ์
เมื่อต้องเลือก cursor, offset หรือแบบผสม
การตัดสินใจเริ่มจากว่าผู้คนใช้ลิสต์อย่างไรจริง ๆ
Cursor pagination เหมาะที่สุดเมื่อผู้ใช้ส่วนมากเคลื่อนไปข้างหน้าและความเร็วสำคัญ: activity logs, chats, orders, tickets, audit trails และส่วนใหญ่ของ infinite scroll บนมือถือ มันยังทำงานดีกว่าเมื่อมีการแทรกหรือการลบแถวขณะคนกำลังเรียกดู
Offset pagination เหมาะเมื่อผู้ใช้บ่อยครั้งกระโดดไปรอบ ๆ: ตารางแอดมินคลาสสิกที่มีหมายเลขหน้า, ไปยังหน้าที่ต้องการ, และการสลับหน้าอย่างรวดเร็ว มันง่ายอธิบาย แต่บนชุดข้อมูลใหญ่จะช้าลงและไม่คงที่เมื่อข้อมูลเปลี่ยนไป
วิธีตัดสินใจเชิงปฏิบัติ:
- เลือก cursor เมื่อการกระทำหลักคือ “ถัดไป ถัดไป ถัดไป”
- เลือก offset เมื่อ “ข้ามไปหน้าที่ N” เป็นความต้องการจริง
- ถือว่ายอดรวมเป็นทางเลือก ยอดรวมที่แม่นยำอาจแพงบนตารางขนาดใหญ่
ฮายบริดเป็นเรื่องปกติ หนึ่งวิธีคือ next/prev แบบ cursor เพื่อความเร็ว พร้อมโหมดกระโดดหน้าตัวเลือกสำหรับชุดข้อมูลที่กรองแล้วเล็ก ๆ ที่ offset ยังเร็ว อีกวิธีคือการดึงด้วย cursor พร้อมหมายเลขหน้าโดยใช้ snapshot ที่แคชไว้ เพื่อให้ตารางคุ้นเคยโดยไม่ทำให้คำขอทุกครั้งหนักขึ้น
สัญญา API ที่สอดคล้องกันซึ่งใช้ได้ทั้งเว็บและมือถือ
แอดมินรู้สึกเร็วกว่าเมื่อแต่ละ endpoint ของลิสต์มีพฤติกรรมเหมือนกัน UI อาจเปลี่ยน (ตารางเว็บที่มีหมายเลขหน้า, ลิสต์มือถือแบบ infinite) แต่สัญญา API ควรคงที่เพื่อไม่ต้องเรียนรู้กฎการแบ่งหน้าซ้ำสำหรับแต่ละหน้าจอ
สัญญาเชิงปฏิบัติประกอบด้วยสามส่วน: แถว, สถานะการแบ่งหน้า, และยอดรวมที่เป็นทางเลือก เก็บชื่อฟิลด์ให้เหมือนกันข้าม endpoint (tickets, users, orders) แม้โหมดแบ่งหน้าพื้นฐานจะต่างกัน
รูปแบบการตอบที่ใช้งานได้ดีทั้งเว็บและมือถือ มีดังนี้:
{
"data": [ { "id": "...", "createdAt": "..." } ],
"page": {
"mode": "cursor",
"limit": 50,
"nextCursor": "...",
"prevCursor": null,
"hasNext": true,
"hasPrev": false
},
"totals": {
"count": 12345,
"filteredCount": 120
}
}
รายละเอียดเล็ก ๆ น้อย ๆ ที่ทำให้นำกลับมาใช้ได้ง่าย:
page.modeบอกไคลเอนต์ว่าเซิร์ฟเวอร์กำลังทำอะไร โดยไม่ต้องเปลี่ยนชื่อฟิลด์limitเป็นขนาดหน้าที่ร้องขอเสมอnextCursorและprevCursorมีอยู่แม้ค่าจะเป็น nulltotalsเป็นทางเลือก หากแพง ให้คืนเฉพาะเมื่อไคลเอนต์ร้องขอ
ตารางบนเว็บยังสามารถแสดง “หน้า 3” โดยเก็บดัชนีหน้าในฝั่งไคลเอนต์และเรียก API ซ้ำ ๆ แอปมือถือสามารถละเว้นหมายเลขหน้าแล้วขอชิ้นถัดไปได้เลย
ถ้าคุณสร้างทั้งเว็บและมือถือด้วย AppMaster สัญญาที่เสถียรแบบนี้จะคุ้มค่าอย่างรวดเร็ว พฤติกรรมลิสต์เดียวกันสามารถนำกลับมาใช้ข้ามหน้าจอโดยไม่ต้องมีตรรกะแบ่งหน้าพิเศษต่อ endpoint
กฎการจัดเรียงที่ทำให้การแบ่งหน้าคงที่
การจัดเรียงเป็นจุดที่การแบ่งหน้ามักพัง ถ้าลำดับเปลี่ยนระหว่างคำขอ ผู้ใช้จะเห็นแถวซ้ำ ช่องว่าง หรือแถว “หายไป”
ทำให้การจัดเรียงเป็นสัญญา ไม่ใช่คำแนะนำ เผยแพร่ฟิลด์การจัดเรียงและทิศทางที่อนุญาต และปฏิเสธสิ่งที่เหลือ นั่นทำให้ API ของคุณคาดเดาได้และป้องกันไคลเอนต์จากการขอการจัดเรียงที่ช้าที่ดูไม่เป็นไรในสภาพแวดล้อมพัฒนา
การจัดเรียงที่เสถียรต้องมี tie-breaker ที่ไม่ซ้ำ หากคุณจัดเรียงตาม created_at แล้วสองเรคอร์ดมี timestamp เท่ากัน ให้เพิ่ม id (หรือคอลัมน์ที่ไม่ซ้ำอื่น ๆ) เป็นคีย์สุดท้าย หากไม่มี database จะคืนค่าที่เท่ากันในลำดับใดก็ได้
กฎปฏิบัติที่อยู่ได้จริง:
- อนุญาตการจัดเรียงเฉพาะบนฟิลด์ที่มีดัชนีและกำหนดชัดเจน (เช่น
created_at,updated_at,status,priority) - ใส่ tie-breaker ที่ไม่ซ้ำเป็นคีย์สุดท้ายเสมอ (เช่น
id ASC) - กำหนดการจัดเรียงเริ่มต้น (เช่น
created_at DESC, id DESC) และคงไว้ให้เหมือนกันข้ามไคลเอนต์ - ระบุวิธีจัดลำดับค่า null (เช่น “nulls last” สำหรับวันที่และตัวเลข)
การจัดเรียงยังขับเคลื่อนการสร้าง cursor ด้วย Cursor ควรเข้ารหัสค่าการจัดเรียงของไอเท็มสุดท้ายตามลำดับ รวมถึง tie-breaker เพื่อให้หน้าถัดไปสามารถคิวรีว่า “หลัง tuple นั้น” หากการจัดเรียงเปลี่ยนไป cursor เก่าไม่ใช้ได้ ให้ถือว่าพารามิเตอร์การจัดเรียงเป็นส่วนหนึ่งของสัญญา cursor
ตัวกรองและยอดรวมโดยไม่ทำลายสัญญา
ตัวกรองควรรู้สึกแยกจากการแบ่งหน้า UI กำลังบอกว่า “แสดงชุดแถวที่ต่างกัน” แล้วจึงถามว่า “แบ่งหน้าในชุดนั้น” หากคุณผสมฟิลด์ตัวกรองเข้าไปในโทเค็นการแบ่งหน้าหรือปฏิบัติตัวกรองเป็นทางเลือกและไม่ได้ตรวจสอบ คุณจะเจอพฤติกรรมยากต่อการดีบัก: หน้าที่ว่าง แถวซ้ำ หรือ cursor ที่ชี้ไปยังชุดข้อมูลต่างกันทันที
กฎง่าย ๆ: ตัวกรองอยู่ในพารามิเตอร์ query ธรรมดา (หรือ request body สำหรับ POST) และ cursor เป็นทึบและใช้ได้เฉพาะกับชุดตัวกรองและการจัดเรียงนั้นเท่านั้น หากผู้ใช้เปลี่ยนตัวกรอง (status, ช่วงวันที่, assignee) ให้ไคลเอนต์ทิ้ง cursor เก่าและเริ่มจากต้น
เข้มงวดเกี่ยวกับตัวกรองที่อนุญาต มันปกป้องประสิทธิภาพและทำให้พฤติกรรมคาดเดาได้:
- ปฏิเสธฟิลด์ตัวกรองที่ไม่รู้จัก (อย่ามองข้ามโดยเงียบ)
- ตรวจสอบชนิดและช่วง (วันที่, enums, IDs)
- จำกัดตัวกรองกว้าง ๆ (เช่น จำกัดไม่เกิน 50 IDs ใน
INlist) - นำตัวกรองมาใช้กับข้อมูลและยอดรวมเท่ากัน (ไม่ให้ตัวเลขไม่ตรงกัน)
ยอดรวมเป็นจุดที่ API หลายแห่งช้า การนับที่แม่นยำอาจแพงบนตารางใหญ่โดยเฉพาะกับตัวกรองหลายอย่าง โดยทั่วไปมีสามตัวเลือก: แม่นยำ, ประมาณ, หรือไม่มี แม่นยำดีสำหรับชุดข้อมูลเล็กหรือเมื่อต้องการจริง ๆ ประมาณมักพอสำหรับหน้าจอแอดมิน ไม่มีเลยก็ใช้ได้ถ้าคุณต้องการแค่ “โหลดเพิ่ม”
เพื่อหลีกเลี่ยงการทำให้ทุกคำขอช้า ให้ทำให้ totals เป็นทางเลือก: คำนวณเฉพาะเมื่อไคลเอนต์ขอ (เช่น flag includeTotal=true), แคชสั้น ๆ ต่อชุดตัวกรอง, หรือคืนยอดรวมเฉพาะในหน้าแรก
ทีละขั้นตอน: ออกแบบและติดตั้ง endpoint
เริ่มจากค่าเริ่มต้น จุดเริ่มต้นของลิสต์ endpoint ต้องมีการจัดเรียงที่เสถียร พร้อม tie-breaker สำหรับแถวที่มีค่าเท่ากัน เช่น: createdAt DESC, id DESC tie-breaker (id) จะป้องกันแถวซ้ำและช่องว่างเมื่อเรคอร์ดใหม่ถูกเพิ่ม
กำหนดรูปแบบคำขอหนึ่งแบบและอย่าให้มันซับซ้อน พารามิเตอร์ทั่วไปคือ limit, cursor (หรือ offset), sort, และ filters หากคุณรองรับทั้งสองโหมด ให้ทำให้พวกมันขัดกัน: ไคลเอนต์ส่ง cursor หรือ offset แต่ไม่ใช่ทั้งสองพร้อมกัน
รักษาสัญญาการตอบที่สม่ำเสมอเพื่อให้เว็บและมือถือใช้ตรรกะลิสต์เดียวกันได้:
items: หน้าของเรคอร์ดnextCursor: cursor สำหรับหน้าถัดไป (หรือnull)hasMore: boolean เพื่อให้ UI ตัดสินใจว่าแสดง “โหลดเพิ่มเติม” หรือไม่total: ยอดรวมเรคอร์ดที่ตรง (nullยกเว้นเมื่อร้องขอถ้าการนับแพง)
การนำไปใช้เป็นจุดที่สองวิธีแยกออก
Offset queries มักจะเป็น ORDER BY ... LIMIT ... OFFSET ... ซึ่งบนตารางขนาดใหญ่จะช้าลง
Cursor queries ใช้เงื่อนไข seek ตามไอเท็มสุดท้าย: “ให้ฉันไอเท็มที่ (createdAt, id) น้อยกว่า (createdAt, id) สุดท้าย” ซึ่งรักษาเสถียรภาพด้านประสิทธิภาพเพราะฐานข้อมูลสามารถใช้ดัชนี
ก่อนส่งมอบ ให้เพิ่ม guardrails:
- จำกัด
limit(เช่น max 100) และตั้งค่าเริ่มต้น - ตรวจสอบ
sortกับ allowlist - ตรวจสอบตัวกรองตามชนิดและปฏิเสธคีย์ที่ไม่รู้จัก
- ทำให้
cursorทึบ (เข้ารหัสค่าการจัดเรียงสุดท้าย) และปฏิเสธ cursor ที่ผิดรูป - ตัดสินใจว่าจะแสดง
totalอย่างไร
ทดสอบด้วยข้อมูลที่เปลี่ยนแปลงอยู่เบื้องหลัง สร้างและลบเรคอร์ดระหว่างคำขอ อัปเดตฟิลด์ที่มีผลต่อการจัดเรียง และยืนยันว่าไม่เห็นแถวซ้ำหรือหายไป
ตัวอย่าง: รายการตั๋วที่ยังเร็วทั้งบนเว็บและมือถือ
ทีมซัพพอร์ตเปิดหน้าจอแอดมินเพื่อตรวจสอบตั๋วล่าสุด พวกเขาต้องการให้ลิสต์รู้สึกทันที แม้ในขณะที่ตั๋วมาถึงใหม่และเอเจนต์อัปเดตเรคอร์ดเก่า
บนเว็บ UI เป็นตาราง การจัดเรียงเริ่มต้นคือ updated_at (ล่าสุดก่อน) และทีมมักกรองเป็น Open หรือ Pending endpoint เดียวกันสามารถรองรับทั้งสองพฤติกรรมด้วยการจัดเรียงที่เสถียรและ token cursor
GET /tickets?status=open\u0026sort=-updated_at\u0026limit=50\u0026cursor=eyJ1cGRhdGVkX2F0IjoiMjAyNi0wMS0yNVQxMTo0NTo0MloiLCJpZCI6IjE2OTMifQ==
การตอบกลับยังคงคาดเดาได้สำหรับ UI:
{
"items": [{"id": 1693, "subject": "Login issue", "status": "open", "updated_at": "2026-01-25T11:45:42Z"}],
"page": {"next_cursor": "...", "has_more": true},
"meta": {"total": 128}
}
บนมือถือ endpoint เดียวกันขับเคลื่อน infinite scroll แอปโหลดทีละ 20 ตั๋ว แล้วส่ง next_cursor เพื่อดึงชุดถัดไป ไม่มีตรรกะหมายเลขหน้า และจะมีความประหลาดใจน้อยลงเมื่อเรคอร์ดเปลี่ยน
กุญแจสำคัญคือตัว cursor เข้ารหัสตำแหน่งสุดท้ายที่เห็น (เช่น updated_at บวก id เป็น tie-breaker) หากตั๋วถูกอัปเดตขณะเอเจนต์กำลังเลื่อน มันอาจเคลื่อนไปด้านบนในการรีเฟรชถัดไป แต่จะไม่ทำให้เกิดแถวซ้ำหรือช่องว่างในฟีดที่เลื่อนแล้ว
ยอดรวมมีประโยชน์ แต่แพงบนชุดข้อมูลใหญ่ กฎง่ายคือคืน meta.total เฉพาะเมื่อผู้ใช้ใช้ตัวกรอง (เช่น status=open) หรือร้องขออย่างชัดเจน
ความผิดพลาดทั่วไปที่ทำให้เกิดซ้ำ ช่องว่าง และหน่วง
บั๊กการแบ่งหน้าส่วนใหญ่ไม่ใช่อยู่ในฐานข้อมูล แต่มาจากการตัดสินใจ API เล็ก ๆ ที่ดูโอเคในการทดสอบ แล้วพังเมื่อข้อมูลเปลี่ยนระหว่างคำขอ
สาเหตุทั่วไปที่สุดของแถวซ้ำ (หรือแถวหาย) คือการจัดเรียงบนฟิลด์ที่ไม่เป็นเอกลักษณ์ หากคุณจัดเรียงโดย created_at แล้วสองไอเท็มมี timestamp เดียวกัน ลำดับอาจสลับระหว่างคำขอ แก้ได้ง่าย: เพิ่ม tie-breaker เสมอ โดยปกติคือ primary key และถือการจัดเรียงเป็นคู่ เช่น (created_at desc, id desc)
ปัญหาอีกอย่างคือให้ไคลเอนต์ขอขนาดหน้าได้ไม่จำกัด คำขอที่ใหญ่หนึ่งครั้งสามารถเพิ่มการใช้งาน CPU, หน่วยความจำ, และเวลาในการตอบ ซึ่งทำให้หน้าจอแอดมินทุกหน้าช้าลง เลือกค่าเริ่มต้นที่สมเหตุสมผลและกำหนดขีดจำกัดสูงสุด และคืนข้อผิดพลาดเมื่อไคลเอนต์ขอมากเกินไป
ยอดรวมก็สามารถทำร้ายได้ การนับเรคอร์ดทั้งหมดที่ตรงเงื่อนไขในทุกคำขออาจเป็นช้าที่สุดของ endpoint โดยเฉพาะเมื่อมีตัวกรอง หาก UI ต้องการยอดรวม ให้ดึงเฉพาะเมื่อร้องขอ (หรือคืนค่าโดยประมาณ) และหลีกเลี่ยงการบล็อกการเลื่อนลิสต์ด้วยการนับเต็ม
ความผิดพลาดที่มักสร้างช่องว่าง ซ้ำ และหน่วง:
- การจัดเรียงโดยไม่มี tie-breaker ที่ไม่ซ้ำ (ลำดับไม่เสถียร)
- ขนาดหน้าที่ไม่จำกัด (เซิร์ฟเวอร์ล้น)
- คืนยอดรวมทุกครั้ง (คิวรีช้า)
- ผสมกฎ offset และ cursor ใน endpoint เดียว (พฤติกรรมไคลเอนต์สับสน)
- นำ cursor เก่าไปใช้ซ้ำเมื่อตัวกรองหรือการจัดเรียงเปลี่ยน (ผลผิด)
รีเซ็ตการแบ่งหน้าเมื่อใดก็ตามที่ตัวกรองหรือการจัดเรียงเปลี่ยน ถือว่าการค้นหาใหม่คือการค้นหาใหม่: ล้าง cursor/offset และเริ่มจากหน้าแรก
เช็คลิสต์ด่วนก่อนส่งมอบ
รันรายการนี้ครั้งหนึ่งพร้อม API และ UI เคียงกัน ปัญหาส่วนใหญ่เกิดในสัญญาระหว่างหน้าจอลิสต์กับเซิร์ฟเวอร์
- การจัดเรียงเริ่มต้นเป็นเสถียรและรวม tie-breaker ที่ไม่ซ้ำ (เช่น
created_at DESC, id DESC) - ฟิลด์และทิศทางการจัดเรียงถูก whitelist
- บังคับขนาดหน้าสูงสุด และมีค่าเริ่มต้นที่สมเหตุสมผล
- token ของ cursor เป็นทึบ และ cursor ผิดรูปล้มเหลวอย่างคาดเดาได้
- การเปลี่ยนตัวกรองหรือการจัดเรียงรีเซ็ตสถานะการแบ่งหน้า
- พฤติกรรมยอดรวมชัดเจน: แม่นยำ, ประมาณ, หรือไม่แสดง
- สัญญาเดียวกันรองรับทั้งตารางและ infinite scroll โดยไม่ต้องมีกรณีพิเศษ
ขั้นตอนต่อไป: มาตรฐานลิสต์ของคุณแล้วรักษาความสอดคล้อง
เลือกหนึ่งลิสต์แอดมินที่คนใช้ทุกวันและทำให้มันเป็นมาตรฐาน ท้ายที่สุด endpoint ที่คนใช้บ่อยเช่น Tickets, Orders หรือ Users เป็นจุดเริ่มต้นที่ดี เมื่อ endpoint นั้นรู้สึกเร็วและคาดเดาได้ ให้นำสัญญาเดียวกันไปใช้กับหน้าจอแอดมินที่เหลือ
เขียนสัญญาลง แม้สั้น ๆ ก็ตาม ระบุชัดเจนว่า API รับและคืนอะไร เพื่อทีม UI จะได้ไม่คาดเดาและเผลอสร้างกฎต่างกันต่อ endpoint
มาตรฐานง่าย ๆ ที่ใช้กับทุก endpoint ลิสต์:
- การจัดเรียงที่อนุญาต: ชื่อฟิลด์ที่แน่นอน ทิศทาง และค่าเริ่มต้น (พร้อม tie-breaker เช่น
id) - ตัวกรองที่อนุญาต: ฟิลด์ใดกรองได้ รูปแบบค่า และทำอย่างไรเมื่อฟิลด์ไม่ถูกต้อง
- พฤติกรรมยอดรวม: คืนเมื่อใด คืน “ไม่ทราบ” เมื่อไร และข้ามเมื่อไร
- รูปแบบการตอบ: คีย์ที่สม่ำเสมอ (
items, ข้อมูลการแบ่งหน้า, ตัวกรอง/การจัดเรียงที่ใช้, totals) - กฎข้อผิดพลาด: รหัสสถานะและข้อความตรวจสอบที่อ่านง่าย
ถ้าคุณสร้างหน้าจอแอดมินด้วย AppMaster (appmaster.io) การกำหนดสัญญาการแบ่งหน้าแต่เนิ่น ๆ จะช่วยได้มาก คุณสามารถนำพฤติกรรมลิสต์เดียวกันกลับมาใช้ข้ามเว็บและมือถือเนทีฟ และใช้เวลาน้อยลงกับการแก้บั๊กขอบเขตการแบ่งหน้าในภายหลัง
คำถามที่พบบ่อย
Offset pagination ใช้ limit บวก offset (หรือ page/pageSize) เพื่อข้ามแถว ดังนั้นหน้าลึก ๆ มักจะช้าลงเพราะฐานข้อมูลต้องเดินผ่านแถวที่ข้ามไปก่อนที่จะคืนผล ส่วน cursor pagination ใช้โทเค็น after ที่สร้างจากค่าการจัดเรียงของไอเท็มสุดท้าย จึงสามารถกระโดดไปยังตำแหน่งที่รู้จักและรักษาความเร็วเมื่อเลื่อนต่อไป
หน้าแรกมักจะถูก แต่หน้าเช่นหน้า 200 บังคับให้ฐานข้อมูลข้ามแถวจำนวนมากก่อนจะคืนค่า หากคุณมีการจัดเรียงและการกรองด้วย งานที่ต้องทำก็เพิ่มขึ้น ทำให้แต่ละคลิกรู้สึกเหมือนเป็นคิวรีหนักใหม่ แทนที่จะเป็นการดึงข้อมูลอย่างรวดเร็ว
ใช้การจัดเรียงที่เสถียรพร้อม tie-breaker ที่ไม่ซ้ำ เช่น created_at DESC, id DESC หรือ updated_at DESC, id DESC หากไม่มี tie-breaker ระเบียนที่มี timestamp เดียวกันอาจสลับตำแหน่งระหว่างคำขอ และทำให้เกิดแถวซ้ำหรือหายไป
ใช้ cursor pagination สำหรับลิสต์ที่ผู้ใช้ส่วนมากเลื่อนไปข้างหน้าเรื่อย ๆ และความเร็วสำคัญ เช่น activity logs, tickets, orders และหน้า infinite scroll บนมือถือ เพราะ cursor ยึดตำแหน่งสุดท้ายที่เห็นไว้ ทำให้แถวที่ถูกแทรกหรือถูกลบในขณะที่ผู้ใช้เรียกดูไม่ทำให้ผลเพี้ยน
Offset pagination เหมาะเมื่อฟีเจอร์ “ข้ามไปหน้าที่ N” เป็นสิ่งจำเป็นใน UI และผู้ใช้มักกระโดดไปรอบ ๆ เป็นประจำ นอกจากนี้ยังสะดวกสำหรับตารางเล็กหรือชุดข้อมูลที่คงที่ ซึ่งการชะลอจากหน้าลึกและการเปลี่ยนแปลงผลลัพธ์ไม่น่าจะเป็นปัญหา
ให้รูปตอบกลับแบบเดียวกันข้ามทุก endpoint และรวมไอเท็ม, สถานะการแบ่งหน้า, และยอดรวมที่เป็นทางเลือก ตัวอย่างดีคือคืน items พร้อมวัตถุ page (มี limit, nextCursor/prevCursor หรือ offset) และธงเช่น hasNext เพื่อให้ทั้งตารางเว็บและลิสต์มือถือใช้ตรรกะเดียวกันได้
เพราะ COUNT(*) ที่ถูกต้องบนตารางขนาดใหญ่ที่มีการกรองหลายอย่างอาจเป็นงานที่ช้าสุดของคำขอและทำให้การเปลี่ยนหน้าแต่ละครั้งรู้สึกอืด ค่าเริ่มต้นที่ปลอดภัยคือทำให้ totals เป็นทางเลือก: คืนเมื่อมีการร้องขอเท่านั้น, คืนค่าโดยประมาณ, หรือส่งแค่ has_more เมื่อ UI ต้องการเพียง “โหลดเพิ่มเติม”
ถือว่าตัวกรองเป็นส่วนหนึ่งของชุดข้อมูล และถือว่า cursor ใช้ได้เฉพาะกับการรวมตัวกรองและการจัดเรียงนั้น ๆ เท่านั้น หากผู้ใช้เปลี่ยนตัวกรองหรือการจัดเรียง ให้รีเซ็ตการแบ่งหน้าและเริ่มจากหน้าแรกอีกครั้ง การนำ cursor เก่ากลับมาใช้หลังการเปลี่ยนแปลงเป็นสาเหตุทั่วไปของหน้าเปล่าหรือผลลัพธ์สับสน
ทำ whitelist ฟิลด์การจัดเรียงและทิศทางที่อนุญาต และปฏิเสธสิ่งที่เหลือเพื่อไม่ให้ไคลเอนต์ขอการจัดเรียงที่ช้าหรือไม่เสถียร เกร็ดปฏิบัติคือใช้ฟิลด์ที่มีดัชนีและต่อด้วย tie-breaker ที่ไม่ซ้ำ เช่น id เพื่อให้เรียงลำดับได้กำหนดแน่น
กำหนดขีดจำกัด limit สูงสุด ตรวจสอบพารามิเตอร์ตัวกรองและการจัดเรียง และทำให้ token ของ cursor ทึบและตรวจสอบอย่างเข้มงวด ก่อนปล่อยใช้งาน ให้ทดสอบด้วยข้อมูลที่มีการสร้างและลบระหว่างคำขอเพื่อยืนยันว่าไม่มีแถวซ้ำหรือหายไป หากคุณสร้างแอดมินด้วย AppMaster ให้ทำตามกฎเหล่านี้ข้ามทุก endpoint เพื่อหลีกเลี่ยงการแก้บั๊กเฉพาะหน้า


