03 ต.ค. 2568·อ่าน 2 นาที

Cursor vs Offset: การแบ่งหน้าสำหรับ API หน้าจอแอดมินที่รวดเร็ว

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

Cursor vs 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 หรือแบบผสม

Build faster list endpoints
Build a fast list API with stable sorting and cursor paging, without hand-coding endpoints.
Try AppMaster

การตัดสินใจเริ่มจากว่าผู้คนใช้ลิสต์อย่างไรจริง ๆ

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 มีอยู่แม้ค่าจะเป็น null
  • totals เป็นทางเลือก หากแพง ให้คืนเฉพาะเมื่อไคลเอนต์ร้องขอ

ตารางบนเว็บยังสามารถแสดง “หน้า 3” โดยเก็บดัชนีหน้าในฝั่งไคลเอนต์และเรียก API ซ้ำ ๆ แอปมือถือสามารถละเว้นหมายเลขหน้าแล้วขอชิ้นถัดไปได้เลย

ถ้าคุณสร้างทั้งเว็บและมือถือด้วย AppMaster สัญญาที่เสถียรแบบนี้จะคุ้มค่าอย่างรวดเร็ว พฤติกรรมลิสต์เดียวกันสามารถนำกลับมาใช้ข้ามหน้าจอโดยไม่ต้องมีตรรกะแบ่งหน้าพิเศษต่อ endpoint

กฎการจัดเรียงที่ทำให้การแบ่งหน้าคงที่

Launch an admin panel quickly
Create internal tools like Tickets, Orders, and Users with reusable list behavior.
Build Admin

การจัดเรียงเป็นจุดที่การแบ่งหน้ามักพัง ถ้าลำดับเปลี่ยนระหว่างคำขอ ผู้ใช้จะเห็นแถวซ้ำ ช่องว่าง หรือแถว “หายไป”

ทำให้การจัดเรียงเป็นสัญญา ไม่ใช่คำแนะนำ เผยแพร่ฟิลด์การจัดเรียงและทิศทางที่อนุญาต และปฏิเสธสิ่งที่เหลือ นั่นทำให้ 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 ใน IN list)
  • นำตัวกรองมาใช้กับข้อมูลและยอดรวมเท่ากัน (ไม่ให้ตัวเลขไม่ตรงกัน)

ยอดรวมเป็นจุดที่ API หลายแห่งช้า การนับที่แม่นยำอาจแพงบนตารางใหญ่โดยเฉพาะกับตัวกรองหลายอย่าง โดยทั่วไปมีสามตัวเลือก: แม่นยำ, ประมาณ, หรือไม่มี แม่นยำดีสำหรับชุดข้อมูลเล็กหรือเมื่อต้องการจริง ๆ ประมาณมักพอสำหรับหน้าจอแอดมิน ไม่มีเลยก็ใช้ได้ถ้าคุณต้องการแค่ “โหลดเพิ่ม”

เพื่อหลีกเลี่ยงการทำให้ทุกคำขอช้า ให้ทำให้ totals เป็นทางเลือก: คำนวณเฉพาะเมื่อไคลเอนต์ขอ (เช่น flag includeTotal=true), แคชสั้น ๆ ต่อชุดตัวกรอง, หรือคืนยอดรวมเฉพาะในหน้าแรก

ทีละขั้นตอน: ออกแบบและติดตั้ง endpoint

Fix pagination edge cases
Design filters, sorting rules, and guardrails so your UI stops seeing duplicates.
Open Builder

เริ่มจากค่าเริ่มต้น จุดเริ่มต้นของลิสต์ 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 อย่างไร

ทดสอบด้วยข้อมูลที่เปลี่ยนแปลงอยู่เบื้องหลัง สร้างและลบเรคอร์ดระหว่างคำขอ อัปเดตฟิลด์ที่มีผลต่อการจัดเรียง และยืนยันว่าไม่เห็นแถวซ้ำหรือหายไป

ตัวอย่าง: รายการตั๋วที่ยังเร็วทั้งบนเว็บและมือถือ

One API for web and mobile
Use one pagination contract across web and native mobile screens.
Create App

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

บนเว็บ 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 และเริ่มจากหน้าแรก

เช็คลิสต์ด่วนก่อนส่งมอบ

Change requirements safely
Regenerate clean source code when requirements change, without accumulating technical debt.
Generate Code

รันรายการนี้ครั้งหนึ่งพร้อม 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) การกำหนดสัญญาการแบ่งหน้าแต่เนิ่น ๆ จะช่วยได้มาก คุณสามารถนำพฤติกรรมลิสต์เดียวกันกลับมาใช้ข้ามเว็บและมือถือเนทีฟ และใช้เวลาน้อยลงกับการแก้บั๊กขอบเขตการแบ่งหน้าในภายหลัง

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

What’s the real difference between offset and cursor pagination?

Offset pagination ใช้ limit บวก offset (หรือ page/pageSize) เพื่อข้ามแถว ดังนั้นหน้าลึก ๆ มักจะช้าลงเพราะฐานข้อมูลต้องเดินผ่านแถวที่ข้ามไปก่อนที่จะคืนผล ส่วน cursor pagination ใช้โทเค็น after ที่สร้างจากค่าการจัดเรียงของไอเท็มสุดท้าย จึงสามารถกระโดดไปยังตำแหน่งที่รู้จักและรักษาความเร็วเมื่อเลื่อนต่อไป

Why does my admin table feel slower the more pages I go through?

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

How do I prevent duplicates or missing rows when users paginate?

ใช้การจัดเรียงที่เสถียรพร้อม tie-breaker ที่ไม่ซ้ำ เช่น created_at DESC, id DESC หรือ updated_at DESC, id DESC หากไม่มี tie-breaker ระเบียนที่มี timestamp เดียวกันอาจสลับตำแหน่งระหว่างคำขอ และทำให้เกิดแถวซ้ำหรือหายไป

When should I prefer cursor pagination?

ใช้ cursor pagination สำหรับลิสต์ที่ผู้ใช้ส่วนมากเลื่อนไปข้างหน้าเรื่อย ๆ และความเร็วสำคัญ เช่น activity logs, tickets, orders และหน้า infinite scroll บนมือถือ เพราะ cursor ยึดตำแหน่งสุดท้ายที่เห็นไว้ ทำให้แถวที่ถูกแทรกหรือถูกลบในขณะที่ผู้ใช้เรียกดูไม่ทำให้ผลเพี้ยน

When does offset pagination still make sense?

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

What should a consistent pagination API response include?

ให้รูปตอบกลับแบบเดียวกันข้ามทุก endpoint และรวมไอเท็ม, สถานะการแบ่งหน้า, และยอดรวมที่เป็นทางเลือก ตัวอย่างดีคือคืน items พร้อมวัตถุ page (มี limit, nextCursor/prevCursor หรือ offset) และธงเช่น hasNext เพื่อให้ทั้งตารางเว็บและลิสต์มือถือใช้ตรรกะเดียวกันได้

Why can totals make pagination slow, and what’s a safer default?

เพราะ COUNT(*) ที่ถูกต้องบนตารางขนาดใหญ่ที่มีการกรองหลายอย่างอาจเป็นงานที่ช้าสุดของคำขอและทำให้การเปลี่ยนหน้าแต่ละครั้งรู้สึกอืด ค่าเริ่มต้นที่ปลอดภัยคือทำให้ totals เป็นทางเลือก: คืนเมื่อมีการร้องขอเท่านั้น, คืนค่าโดยประมาณ, หรือส่งแค่ has_more เมื่อ UI ต้องการเพียง “โหลดเพิ่มเติม”

What should happen to the cursor when filters or sorting changes?

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

How do I make sorting fast and predictable for pagination?

ทำ whitelist ฟิลด์การจัดเรียงและทิศทางที่อนุญาต และปฏิเสธสิ่งที่เหลือเพื่อไม่ให้ไคลเอนต์ขอการจัดเรียงที่ช้าหรือไม่เสถียร เกร็ดปฏิบัติคือใช้ฟิลด์ที่มีดัชนีและต่อด้วย tie-breaker ที่ไม่ซ้ำ เช่น id เพื่อให้เรียงลำดับได้กำหนดแน่น

What guardrails should I add before shipping a pagination endpoint?

กำหนดขีดจำกัด limit สูงสุด ตรวจสอบพารามิเตอร์ตัวกรองและการจัดเรียง และทำให้ token ของ cursor ทึบและตรวจสอบอย่างเข้มงวด ก่อนปล่อยใช้งาน ให้ทดสอบด้วยข้อมูลที่มีการสร้างและลบระหว่างคำขอเพื่อยืนยันว่าไม่มีแถวซ้ำหรือหายไป หากคุณสร้างแอดมินด้วย AppMaster ให้ทำตามกฎเหล่านี้ข้ามทุก endpoint เพื่อหลีกเลี่ยงการแก้บั๊กเฉพาะหน้า

ง่ายต่อการเริ่มต้น
สร้างบางสิ่งที่ น่าทึ่ง

ทดลองกับ AppMaster ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม