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

Cursor paging without complexity
Implement cursor pagination logic visually with drag-and-drop business processes.
Try Now

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

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

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

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

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

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

Make your API contract consistent
Standardize your response shape once and reuse it across every list endpoint.
Get Started

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

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

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

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

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

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

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

Ship the backend first
Model data in PostgreSQL and generate a production-ready backend in Go.
Build Backend

รันรายการนี้ครั้งหนึ่งพร้อม 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 ด้วยแผนฟรี
เมื่อคุณพร้อม คุณสามารถเลือกการสมัครที่เหมาะสมได้

เริ่ม