การแบ่งหน้าแบบเคอร์เซอร์ทำให้รายการคงที่เมื่อข้อมูลเปลี่ยน เรียนรู้ว่าทำไมการแบ่งหน้าด้วย offset ถึงพังเมื่อมีการแทรก/ลบ และวิธีการสร้างเคอร์เซอร์ที่สะอาด

คุณเปิดฟีด เลื่อนลงไปบ้าง แล้วทุกอย่างก็ดูปกติจนกระทั่งไม่เป็นไปตามนั้น คุณเห็นรายการเดิมสองครั้ง สิ่งที่คุณมั่นใจว่าเคยอยู่กลับหายไป แถวที่คุณจะกดเลื่อนไปและคุณไปหน้ารายการผิด
นี่คือบั๊กที่ผู้ใช้จะเห็น แม้ว่าการตอบกลับจาก API แต่ละครั้งจะดู “ถูกต้อง” เมื่อแยกกัน อาการที่พบบ่อยมีดังนี้:
ปัญหานี้แย่ขึ้นบนมือถือ ผู้ใช้หยุดชั่วคราว สลับแอป หลุดการเชื่อมต่อ แล้วกลับมาต่อ ในช่วงเวลานั้น รายการใหม่ถูกสร้าง ลบรายการเก่า และบางรายการถูกแก้ไข ถ้าแอปของคุณยังคงขอ “หน้า 3” โดยใช้ offset ขอบเขตหน้าสามารถเลื่อนได้ขณะที่ผู้ใช้กำลังเลื่อน ผลคือฟีดที่รู้สึกไม่เสถียรและไม่น่าเชื่อถือ
เป้าหมายไม่ยาก: เมื่อลูกค้าเริ่มเลื่อนไปข้างหน้า รายการควรทำงานเหมือนสแนปชอต รายการใหม่สามารถมีได้ แต่ไม่ควรทำให้สิ่งที่ผู้ใช้กำลังดูเปลี่ยนตำแหน่ง ผู้ใช้ควรได้รับลำดับที่ราบรื่นและคาดเดาได้
ไม่มีวิธีแบ่งหน้าที่สมบูรณ์แบบ ระบบจริงมีการเขียนพร้อมกัน การแก้ไข และตัวเลือกการเรียงลำดับหลายแบบ แต่การแบ่งหน้าแบบเคอร์เซอร์ปลอดภัยกว่าการแบ่งหน้าแบบออฟเซ็ตในหลายกรณี เพราะมันเลื่อนจากตำแหน่งเฉพาะในลำดับที่เสถียร แทนที่จะอ้างจากจำนวนแถวที่เปลี่ยนไป
การแบ่งหน้าแบบออฟเซ็ตคือการทำ “ข้าม N แถว แล้วเอา M แถว” คุณบอก API ว่าจะข้ามกี่แถว (offset) และส่งกลับกี่แถว (limit) กับ limit=20 คุณจะได้ 20 รายการต่อหน้า
เชิงแนวคิด:
GET /items?limit=20&offset=0 (หน้าแรก)GET /items?limit=20&offset=20 (หน้าที่สอง)GET /items?limit=20&offset=40 (หน้าที่สาม)การตอบกลับมักรวมรายการพร้อมข้อมูลพอให้ขอหน้าถัดไป
{
"items": [
{"id": 101, "title": "..."},
{"id": 100, "title": "..."}
],
"limit": 20,
"offset": 20,
"total": 523
}
มันเป็นที่นิยมเพราะแมปได้ตรงกับตาราง รายการแอดมิน ผลการค้นหา และฟีดง่ายๆ และง่ายต่อการทำใน SQL ด้วย LIMIT และ OFFSET
ข้อเสียคือสมมติฐานที่ซ่อนอยู่: ชุดข้อมูลคงที่ระหว่างการร้องขอ ในแอปจริง แถวใหม่ถูกแทรก แถวถูกลบ และคีย์การเรียงลำดับเปลี่ยน นั่นคือจุดที่บั๊กลึกลับเริ่มเกิด
การแบ่งหน้าแบบออฟเซ็ตสมมติว่ารายการนิ่งระหว่างคำขอ แต่รายการจริงไหล เมื่อรายการเลื่อน offset เช่น “ข้าม 20” จะไม่ชี้ไปยังรายการเดิมอีกต่อไป
ลองจินตนาการฟีดเรียงตาม created_at desc (ใหม่สุดก่อน) ขนาดหน้า 3
โหลดหน้า 1 ด้วย offset=0, limit=3 แล้วได้ [A, B, C]
ตอนนี้มีรายการใหม่ X ถูกสร้างและแทรกมาด้านบน รายการจะเป็น [X, A, B, C, D, E, F, ...] คุณโหลดหน้า 2 ด้วย offset=3, limit=3 เซิร์ฟเวอร์ข้าม [X, A, B] แล้วคืน [C, D, E]
คุณจึงเห็น C ซ้ำ และต่อมาคุณจะพลาดรายการบางอันเพราะทุกอย่างเลื่อนลง
การลบทำให้ล้มเหลวแบบตรงกันข้าม เริ่มจาก [A, B, C, D, E, F, ...] คุณโหลดหน้า 1 เห็น [A, B, C] ก่อนหน้า 2 B ถูกลบ รายการกลายเป็น [A, C, D, E, F, ...] หน้า 2 กับ offset=3 ข้าม [A, C, D] แล้วคืน [E, F, G] D จึงเป็นช่องว่างที่คุณไม่เคยดึงมา
ในฟีดแบบใหม่สุดก่อน การแทรกเกิดที่ด้านบน ซึ่งเป็นสาเหตุที่ทำให้ทุก offset ถัดไปเลื่อนไป
“รายการเสถียร” คือสิ่งที่ผู้ใช้คาดหวัง: เมื่อพวกเขาเลื่อนไปข้างหน้า รายการไม่กระโดด ซ้ำ หรือลบโดยไม่มีเหตุผลชัดเจน มันไม่ใช่การแช่เวลาเท่านั้น แต่คือการทำให้การแบ่งหน้าคาดเดาได้
สองแนวคิดที่มักสับสนกัน:
created_at พร้อมตัวเบรกอย่าง id) ดังนั้นคำขอสองครั้งด้วยอินพุตเดียวกันจะคืนลำดับเดียวกันการรีเฟรชและการเลื่อนไปข้างหน้าคือการกระทำคนละแบบ รีเฟรชหมายถึง “แสดงสิ่งใหม่เดี๋ยวนี้” ด้านบนจึงเปลี่ยนได้ การเลื่อนไปข้างหน้าหมายถึง “ไปต่อจากที่ฉันอยู่” ดังนั้นคุณไม่ควรเห็นการซ้ำหรือช่องว่างที่เกิดจากขอบเขตหน้าที่เปลี่ยนไป
กฎง่ายๆ ที่ป้องกันบั๊กส่วนใหญ่: การเลื่อนไปข้างหน้าไม่ควรแสดงรายการซ้ำ
การแบ่งหน้าแบบเคอร์เซอร์เลื่อนผ่านรายการโดยใช้ที่คั่นแทนหมายเลขหน้า แทนที่จะบอก “ให้หน้าที่ 3” ไคลเอนต์จะบอกว่า “ต่อจากตรงนี้”
สัญญา (contract) ง่าย:
วิธีนี้ทนต่อการแทรกและการลบได้ดีกว่าเพราะเคอร์เซอร์ฝังอยู่กับตำแหน่งในลำดับที่เรียง ไม่ใช่จำนวนแถวที่ไหล
ข้อกำหนดสำคัญคือการเรียงลำดับต้องกำหนดได้แบบ deterministic คุณต้องมีกฎการเรียงที่เสถียรและตัวเบรกของการเสมอกัน มิฉะนั้นเคอร์เซอร์จะไม่เป็นที่คั่นตำแหน่งที่น่าเชื่อถือ
เริ่มจากเลือกการเรียงลำดับที่สอดคล้องกับวิธีที่คนอ่านรายการ ฟีด ข้อความ และบันทึกกิจกรรมมักเป็น newest first ประวัติอย่างใบแจ้งหนี้หรือตรวจสอบมักง่ายกว่า oldest first
เคอร์เซอร์ต้องระบุตำแหน่งในลำดับนั้นอย่างไม่ซ้ำกัน หากสองรายการมีค่าคีย์เดียวกัน คุณจะได้ duplicate หรือ gap
ตัวเลือกที่ใช้บ่อยและสิ่งที่ต้องระวัง:
created_at อย่างเดียว: ง่าย แต่ไม่ปลอดภัยถ้ามีหลายแถวที่มี timestamp เท่ากันid อย่างเดียว: ปลอดภัยถ้า id เป็น monotonic แต่บางครั้งอาจไม่ตรงกับการเรียงที่ต้องการของผลิตภัณฑ์created_at + id: มักเป็นการผสมที่ดีที่สุด (timestamp สำหรับการเรียงที่เข้าใจได้, id เป็นตัวเบรก)updated_at เป็นการเรียงหลัก: เสี่ยงสำหรับการเลื่อนไม่รู้จบเพราะการแก้ไขอาจย้ายรายการข้ามหน้าถ้าคุณให้ตัวเลือกการเรียงหลายแบบ ให้ถือว่าแต่ละโหมดเป็นรายการต่างหากพร้อมกฎเคอร์เซอร์ของตัวเอง เคอร์เซอร์มีความหมายเฉพาะสำหรับการเรียงลำดับเดียวเท่านั้น
คุณสามารถเก็บผิวสัมผัส API ให้เล็ก: อินพุตสองอย่าง ผลลัพธ์สองอย่าง
ส่ง limit (จำนวนรายการที่ต้องการ) และ cursor ทางเลือก (ตำแหน่งที่จะต่อจาก) ถ้าไม่มีเคอร์เซอร์ เซิร์ฟเวอร์คืนหน้าต้น
Example request:
GET /api/messages?limit=30&cursor=eyJjcmVhdGVkX2F0IjoiMjAyNi0wMS0xNlQxMDowMDowMFoiLCJpZCI6Ijk4NzYifQ==
คืนรายการและ next_cursor ถ้าไม่มีหน้าถัดไป ให้คืน next_cursor: null ไคลเอนต์ควรปฏิบัติต่อเคอร์เซอร์เป็นโทเค็น ไม่ใช่สิ่งที่จะแก้ไข
{
"items": [ {"id":"9876","created_at":"2026-01-16T10:00:00Z","subject":"..."} ],
"next_cursor": "...",
"has_more": true
}
ตรรกะฝั่งเซิร์ฟเวอร์โดยสรุป: เรียงลำดับในกฎที่เสถียร กรองโดยใช้เคอร์เซอร์ แล้วใช้ limit
ถ้าคุณเรียงใหม่สุดก่อนด้วย (created_at DESC, id DESC) ให้ถอดรหัสเคอร์เซอร์เป็น (created_at, id) แล้วดึงแถวที่มี (created_at, id) น้อยกว่าแบบเข้มงวดกรณีคู่ (strictly less than) ใช้การเรียงแบบเดียวกัน แล้วเอา limit แถว
คุณสามารถเข้ารหัสเคอร์เซอร์เป็น base64 JSON blob (ง่าย) หรือเป็นโทเค็นที่ลงลายเซ็น/เข้ารหัส (งานมากกว่า) แบบทึบปลอดภัยกว่าเพราะให้คุณเปลี่ยนภายในภายหลังโดยไม่ทำให้ไคลเอนต์เสีย
นอกจากนี้ตั้งค่า default ที่สมเหตุสมผล: ค่า default บนมือถือ (มัก 20-30), default เว็บ (มัก 50), และ max บนเซิร์ฟเวอร์ห้ามให้ไคลเอนต์ขอ 10,000 แถวเพียงเพราะบั๊ก
ฟีดเสถียรส่วนใหญ่เกี่ยวกับคำสัญญาหนึ่งข้อ: เมื่อผู้ใช้เริ่มเลื่อนไปข้างหน้า รายการที่พวกเขายังไม่ได้เห็นไม่ควรกระโดดเพราะผู้อื่นสร้าง ลบ หรือแก้ไขเรคคอร์ด
การแทรกเป็นเรื่องง่ายที่สุด รายการใหม่ควรแสดงเมื่อรีเฟรช ไม่ใช่อยู่กลางหน้าที่โหลดแล้ว หากคุณเรียงตาม created_at DESC, id DESC รายการใหม่จะอยู่ก่อนหน้าเพจแรก เคอร์เซอร์ที่มีอยู่จะชี้ไปที่รายการเก่าต่อไป
การลบไม่ควรทำให้รายการสับเปลี่ยน ถ้ารายการถูกลบ มันเพียงไม่ถูกส่งกลับเมื่อคุณถึงมัน ถ้าคุณต้องการรักษาขนาดหน้าคงที่ ให้เรียกต่อจนกว่าจะเก็บ limit รายการที่มองเห็นได้
การแก้ไขคือจุดที่ทีมมักทำให้บั๊กกลับมา คำถามสำคัญคือ: การแก้ไขจะเปลี่ยนตำแหน่งการเรียงไหม?
พฤติกรรมแบบสแนปชอตมักดีที่สุดสำหรับรายการเลื่อน: ใช้คีย์ที่ไม่เปลี่ยนแปลงอย่าง created_at การแก้ไขอาจเปลี่ยนเนื้อหา แต่ไอเท็มจะไม่กระโดดไปตำแหน่งใหม่
พฤติกรรมแบบสดจะเรียงตามอย่าง edited_at ซึ่งอาจทำให้กระโดด (รายการเก่าถูกแก้ไขและย้ายขึ้นมา) ถ้าเลือกแบบนี้ ให้ถือว่ารายการเปลี่ยนตลอดเวลาและออกแบบ UX ให้รองรับการรีเฟรช
อย่าให้เคอร์เซอร์พึ่งพาการ “หาบรรทัดนี้แถวเดียว” ให้เข้ารหัสตำแหน่งแทน เช่น {created_at, id} ของรายการสุดท้ายที่คืนมา แล้วคำขอถัดไปจะอ้างจากค่ามากกว่าการมีแถวจริง:
WHERE (created_at, id) < (:created_at, :id)id) เพื่อหลีกเลี่ยงการซ้ำการเลื่อนไปข้างหน้าเป็นส่วนที่ง่าย ส่วน UX ที่ยากกว่าได้แก่การย้อนกลับ รีเฟรช และการเข้าถึงแบบสุ่ม
สำหรับการย้อนกลับ สองแนวทางที่ได้ผล:
next_cursor สำหรับรายการเก่า และ prev_cursor สำหรับรายการใหม่) พร้อมการเรียงบนหน้าจอแบบเดียวกันการกระโดดแบบสุ่มยากกว่าเพราะ “หน้า 20” ไม่มีความหมายคงที่เมื่อรายการเปลี่ยน หากคุณต้องกระโดดจริงๆ ให้กระโดดไปที่แองเคอร์ เช่น “รอบๆ timestamp นี้” หรือ “เริ่มจาก message id นี้” ไม่ใช่ดัชนีหน้า
บนมือถือ การแคชมีความสำคัญ เก็บเคอร์เซอร์แยกตามสถานะรายการ (query + filters + sort) และถือแต่ละแท็บ/วิวเป็นรายการของตัวเอง นั่นจะป้องกันพฤติกรรม “สลับแท็บแล้วทุกอย่างเละ”
ปัญหาส่วนใหญ่ของการแบ่งหน้าแบบเคอร์เซอร์ไม่ใช่เรื่องฐานข้อมูล แต่เป็นความไม่สอดคล้องกันเล็กๆ น้อยๆ ระหว่างคำขอ ซึ่งจะปรากฏภายใต้ทราฟฟิกจริง
ผู้ร้ายหลัก:
created_at อย่างเดียว) ทำให้กรณีเสมอเกิด duplicate หรือ missingnext_cursor ที่ไม่ตรงกับรายการสุดท้ายที่คืนจริงถ้าคุณสร้างแอปบนแพลตฟอร์มอย่าง Koder.ai edge cases เหล่านี้จะโผล่เร็วเพราะไคลเอนต์เว็บและมือถือมักแชร์ endpoint เดียว การมีสัญญาเคอร์เซอร์ชัดเจนและกฎการเรียงลำดับ deterministic ช่วยให้ทั้งสองไคลเอนต์สอดคล้องกัน
ก่อนเรียกว่าการแบ่งหน้าพร้อม ให้ตรวจพฤติกรรมภายใต้การแทรก การลบ และการลองใหม่
next_cursor มาจากแถวสุดท้ายที่คืนจริงlimit มี max ปลอดภัยและมี default ระบุไว้สำหรับการรีเฟรช เลือกกฎเดียวที่ชัด: ผู้ใช้ดึงเพื่อรีเฟรชเพื่อดึงรายการใหม่ที่ด้านบน หรือคุณตรวจเป็นระยะว่า “มีอะไรใหม่กว่าแถวแรกของฉันไหม?” แล้วแสดงปุ่ม “รายการใหม่” ความสม่ำเสมอคือสิ่งที่ทำให้รายการรู้สึกเสถียรแทนจะเหมือนมีผี
นึกถึงกล่องจดหมายสนับสนุนที่เอเจนต์ใช้บนเว็บ ขณะที่ผู้จัดการเช็กบนมือถือ รายการเรียงจากใหม่สุดก่อน ผู้ใช้คาดหวังอย่างเดียว: เมื่อพวกเขาเลื่อนไปข้างหน้า รายการไม่ควรกระโดด ซ้ำ หรือหายไป
ด้วยการแบ่งหน้าแบบออฟเซ็ต เอเจนต์โหลดหน้า 1 (รายการ 1-20) แล้วเลื่อนไปหน้า 2 (offset=20) ขณะที่เขาอ่าน ขณะเดียวกันมีข้อความใหม่สองข้อความมาถึงด้านบน ตอนนี้ offset=20 ชี้ไปยังตำแหน่งต่างจากเดิม ผู้ใช้จะเห็นรายการซ้ำหรือพลาดข้อความ
ด้วยการแบ่งหน้าแบบเคอร์เซอร์ แอปจะขอ “20 รายการถัดไปหลังเคอร์เซอร์นี้” โดยที่เคอร์เซอร์มาจากรายการสุดท้ายที่ผู้ใช้เห็นจริง (โดยทั่วไปเป็น (created_at, id)) ข้อความใหม่สามารถมาถึงได้ทั้งวัน แต่หน้าถัดไปยังเริ่มหลังข้อความสุดท้ายที่ผู้ใช้เห็น
วิธีทดสอบง่ายๆ ก่อนปล่อย:
ถ้าคุณกำลังทำโปรโตไทป์เร็วๆ Koder.ai ช่วย scaffold endpoint และ flow ของไคลเอนต์จาก prompt แชท แล้ววนปรับโดยใช้ Planning Mode พร้อมสแนปชอตและการ rollback เมื่อการเปลี่ยน pagination ทำให้การทดสอบพัง
การแบ่งหน้าแบบออฟเซ็ตชี้ไปที่ “ข้าม N แถว” ดังนั้นเมื่อแถวใหม่ถูกแทรกหรือแถวเก่าถูกลบ จำนวนแถวจะเลื่อนไป ค่า offset เดิมอาจชี้ไปยังรายการคนละรายการกับที่เคยชี้ก่อนหน้า ทำให้เกิดรายการซ้ำหรือช่องว่างขณะผู้ใช้กำลังเลื่อน
การแบ่งหน้าแบบเคอร์เซอร์ใช้ที่คั่นตำแหน่งที่หมายถึง “ตำแหน่งหลังรายการสุดท้ายที่ฉันเห็น” คำขอต่อไปจะเริ่มจากตำแหน่งนั้นในลำดับที่กำหนดไว้ ทำให้การแทรกแถวที่ด้านบนและการลบแถวในกลางไม่ทำให้ขอบเขตหน้าของคุณเปลี่ยนเหมือนกับ offset
ใช้การเรียงลำดับที่กำหนดได้และมีตัวเบรกสำหรับกรณีเสมอกัน โดยทั่วไปมักใช้ (created_at, id) พร้อมทิศทางเดียวกัน created_at ให้การจัดเรียงที่เข้าใจได้สำหรับผู้ใช้ ส่วน id ทำให้แต่ละตำแหน่งไม่ซ้ำกัน จึงไม่เกิดการซ้ำหรือข้ามเมื่อ timestamp ตรงกัน
การเรียงตาม updated_at อาจทำให้รายการกระโดดข้ามหน้ากันเมื่อมีการแก้ไข ซึ่งจะทำลายความคาดหวังว่า "เลื่อนต่อไปแล้วไม่เปลี่ยนตำแหน่ง" หากคุณต้องการมุมมองแบบสดที่แสดงรายการที่อัปเดตล่าสุด ให้ออกแบบ UI ให้รีเฟรชและยอมรับการเรียงใหม่ แทนที่จะสัญญาการเลื่อนแบบคงที่
คืนค่าโทเค็นทึบในฟิลด์ next_cursor แล้วให้ไคลเอนต์ส่งกลับโดยไม่แก้ไข วิธีง่ายๆ คือเข้ารหัส (created_at, id) ของรายการสุดท้ายเป็น base64 JSON blob แต่สิ่งสำคัญคือปฏิบัติต่อมันเป็นค่า opaque เพื่อให้คุณเปลี่ยนภายในในอนาคตได้โดยไม่ทำให้ไคลเอนต์พัง
ให้สร้างคำขอถัดไปจากค่าที่เก็บไว้ในเคอร์เซอร์ ไม่ใช่จากการหาแถวเฉพาะ หากรายการสุดท้ายถูกลบ (created_at, id) ที่เก็บไว้ยังคงกำหนดตำแหน่งได้ ดังนั้นคุณสามารถดำเนินการต่อด้วยตัวกรองแบบ “strictly less than” (หรือ “greater than”) ตามทิศทางเรียงลำดับได้อย่างปลอดภัย
ใช้การเปรียบเทียบแบบเข้มงวดและตัวเบรกที่ไม่ซ้ำกัน และเสมอนำเคอร์เซอร์จากรายการสุดท้ายที่คุณคืนจริงๆ บั๊กซ้ำส่วนใหญ่เกิดจากการใช้ <= แทน <, ละเลยตัวเบรก, หรือสร้าง next_cursor จากแถวที่ผิดพลาด
เลือกกฎที่ชัดเจน: รีเฟรชดึงรายการใหม่ที่ด้านบน ขณะที่การเลื่อนไปข้างหน้าต่อเนื่องไปยังรายการเก่าจากเคอร์เซอร์ที่มีอยู่ อย่านำ semantics ของการรีเฟรชมาปะปนกับ flow ของเคอร์เซอร์เดียวกัน มิฉะนั้นผู้ใช้จะเห็นการเรียงใหม่และคิดว่ารายการไม่น่าเชื่อถือ
เคอร์เซอร์ใช้ได้เฉพาะกับการเรียงลำดับและชุดตัวกรองเดียวกันเท่านั้น ถ้าไคลเอนต์เปลี่ยนโหมดการเรียง คำค้นหา หรือฟิลเตอร์ ต้องเริ่ม session การแบ่งหน้าใหม่โดยไม่ใช้เคอร์เซอร์เดิม และเก็บเคอร์เซอร์แยกตามสถานะรายการ
การแบ่งหน้าแบบเคอร์เซอร์เหมาะกับการเรียกดูต่อเนื่อง แต่ไม่เหมาะกับการกระโดดไปยัง “หน้า 20” แบบคงที่เพราะชุดข้อมูลเปลี่ยนได้ หากต้องการกระโดด ให้กระโดดไปยังแองเคอร์เช่น “รอบๆ timestamp นี้” หรือ “เริ่มจาก id นี้” แล้วค่อยแบ่งหน้าต่อด้วยเคอร์เซอร์จากจุดนั้น