รายการแดชบอร์ด 100k แถว ให้เร็ว: เริ่มจากอะไร
เรียนรู้วิธีสร้างรายการแดชบอร์ดที่เร็วแม้มี 100k แถว โดยใช้การแบ่งหน้า การจำลองการแสดงผล การกรองอย่างชาญฉลาด และการปรับคำสั่งฐานข้อมูล เพื่อให้เครื่องมือภายในยังคงลื่นไหล

ทำไมหน้ารายการถึงช้าลงเมื่อข้อมูลเพิ่มขึ้น
หน้ารายการมักจะใช้งานได้ลื่นจนกว่าจะไม่เป็นอย่างนั้น ผู้ใช้เริ่มสังเกตการหยุดชั่วคราวเล็ก ๆ ที่สะสม: การเลื่อนกระตุก หน้าเด้งติดเป็นช่วง ๆ หลังการอัปเดต ตัวกรองตอบสนองเป็นวินาที และมีสปินเนอร์หลังการคลิกบางครั้ง บางทีแท็บเบราว์เซอร์ดูเหมือนค้างเพราะ UI thread ทำงานหนัก
จุดเปลี่ยนที่พบบ่อยคือประมาณ 100k แถว เพราะมันกดทุกส่วนของระบบพร้อมกัน ชุดข้อมูลยังไม่ใหญ่เกินไปสำหรับฐานข้อมูล แต่ก็เพียงพอที่จะทำให้ความไม่ประสิทธิภาพเล็ก ๆ สะดุดตาบนเบราว์เซอร์และเครือข่าย หากพยายามโชว์ทุกอย่างพร้อมกัน หน้าจอเรียบง่ายจะกลายเป็นสายการประมวลผลหนัก
เป้าหมายไม่ใช่การเรนเดอร์ทุกแถว เป้าหมายคือช่วยให้ใครสักคนพบสิ่งที่ต้องการอย่างรวดเร็ว: 50 แถวที่ใช่ หน้าถัดไป หรือชิ้นข้อมูลแคบ ๆ ตามตัวกรอง
จะเป็นประโยชน์ถ้าจะแบ่งงานออกเป็นสี่ส่วน:
- เครือข่าย: ส่งกี่ไบต์ และบ่อยแค่ไหน
- ฐานข้อมูล: สแกน เรียง และรวมข้อมูลเท่าไหร่ต่อคำขอ
- การเรนเดอร์ในเบราว์เซอร์: สร้าง วัด และวาดโหนด DOM กี่ตัว
- งาน JavaScript: เรนเดอร์ซ้ำบ่อยแค่ไหน คำนวณคอลัมน์ หรือคำนวณผลลัพธ์บ่อยเท่าไร
ถ้าส่วนใดส่วนหนึ่งแพง หน้าทั้งหมดจะรู้สึกช้า กล่องค้นหาง่าย ๆ สามารถกระตุ้นคำขอที่ไปเรียง 100k แถว คืนผลเป็นพันรายการ แล้วบังคับให้เบราว์เซอร์เรนเดอร์ทั้งหมด นั่นแหละสาเหตุที่การพิมพ์เกิดอาการหน่วง
เมื่อทีมสร้างเครื่องมือภายในอย่างรวดเร็ว (รวมถึงด้วยแพลตฟอร์มแบบโค้ดจากแชทเช่น Koder.ai) หน้ารายการมักเป็นที่แรกที่การเติบโตของข้อมูลจริงเปิดเผยช่องว่างระหว่าง “ใช้งานกับชุดข้อมูลเดโมได้” กับ “รู้สึกทันทีทุกวัน”
เลือกเป้าหมายประสิทธิภาพให้ถูกต้อง
ก่อนจะปรับจูน ให้ตัดสินใจว่า "เร็ว" สำหรับหน้านี้หมายถึงอะไร หลายทีมไล่ตาม throughput (โหลดทุกอย่าง) ในขณะที่ผู้ใช้ส่วนใหญ่ต้องการ low latency (เห็นการอัปเดตเร็ว) รายการสามารถรู้สึกทันทีแม้ว่าจะไม่โหลดแถวทั้ง 100k ก็ต่อเมื่อมันตอบสนองเร็วต่อการเลื่อน การเรียง และตัวกรอง
เป้าหมายที่ใช้งานได้จริงคือเวลาเพื่อแถวแรก ไม่ใช่เวลาในการโหลดทั้งหมด ผู้ใช้เชื่อถือเพจเมื่อเห็นแถวแรก 20–50 แถวอย่างรวดเร็วและการโต้ตอบยังคงลื่น
ควรวัดอะไร (และทำไม)
เลือกชุดตัวเลขเล็ก ๆ ที่ติดตามได้ทุกครั้งที่คุณเปลี่ยนอะไรบางอย่าง:
- เวลาเพื่อแถวแรกหลังเปิดหน้า หรือเปลี่ยนตัวกรอง
- เวลาเพื่อให้ตัวกรองหรือการเรียงแสดงผลอัปเดต
- ขนาดการตอบกลับ (ขนาด payload JSON โดยประมาณ)
- คำสั่งช้าในฐานข้อมูล (โดยเฉพาะ
COUNT(*)และSELECTกว้าง ๆ) - การกระโดดของ main-thread ในเบราว์เซอร์ (การเลื่อนกระตุก การพิมพ์หน่วง)
ตัวชี้เหล่านี้ตรงกับอาการที่พบบ่อย ถ้า CPU ของเบราว์เซอร์พุ่งเมื่อเลื่อน แสดงว่าฝั่ง frontend ทำงานหนักเกินต่อแถว หากสปินเนอร์รอก่อนแต่การเลื่อนหลังจากนั้นเป็นปกติ ปัญหามักอยู่ที่ backend หรือเครือข่าย ถ้าคำขอเร็วแต่หน้าเด้งค้าง มักจะเป็นการเรนเดอร์หรือการประมวลผลบนไคลเอนต์หนักเกินไป
ทดสอบ frontend vs backend แบบเร็ว ๆ
ลองทดลองง่าย ๆ: เก็บ UI ไว้เหมือนเดิม แต่จำกัด backend ชั่วคราวให้คืนแค่ 20 แถวด้วยตัวกรองเดียวกัน หากเร็วขึ้น คอขวดคือขนาดการส่งหรือเวลา query หากยังช้า ให้ดูที่การเรนเดอร์ การจัดรูปแบบ และคอมโพเนนต์ต่อแถว
ตัวอย่าง: หน้าคำสั่ง (Orders) ภายในช้าขณะพิมพ์ค้นหา ถ้า API คืน 5,000 แถวแล้วเบราว์เซอร์กรองบนทุก keypress การพิมพ์จะหน่วง หาก API ใช้ 2 วินาทีเพราะ COUNT บนตัวกรองที่ไม่มีดัชนี คุณจะรอก่อนที่แถวใด ๆ จะเปลี่ยน ทั้งสองกรณีผู้ใช้ร้องเรียนเหมือนกัน แต่การแก้ต่างกัน
พื้นฐาน frontend: ทำให้การเรนเดอร์ถูกและเบา
เบราว์เซอร์มักเป็นคอขวดแรก หน้ารายการสามารถรู้สึกช้าได้แม้ API จะเร็ว เพราะหน้าพยายามวาดมากเกินไป กฎข้อแรกง่าย ๆ: อย่าเรนเดอร์หลายพันแถวใน DOM พร้อมกัน
แม้ก่อนจะเพิ่ม virtualization เต็มรูปแบบ ให้ทำให้แต่ละแถวเบาไว้ แถวที่มี wrapper ซ้อนกัน ไอคอน tooltip และสไตล์เชิงเงื่อนไขซับซ้อนในแต่ละเซลล์ จะมีค่าใช้จ่ายทุกครั้งที่เลื่อนและอัปเดต ให้ใช้ข้อความธรรมดา ป้ายเล็ก ๆ สองสามชิ้น และองค์ประกอบเชิงโต้ตอบไม่เกินหนึ่งหรือสองชิ้นต่อแถว
ความสูงแถวคงที่ช่วยได้มากกว่าที่คิด เมื่อทุกแถวมีความสูงเท่ากัน เบราว์เซอร์คาดเดาเลย์เอาต์ได้และการเลื่อนจะลื่น ความสูงแถวไม่คงที่จะทำให้ต้องวัดซ้ำและ reflow หากต้องการรายละเอียดเพิ่มเติม ให้พิจารณาแผงด้านข้างหรือพื้นที่ขยายเดียว ไม่ใช่แถวหลายบรรทัดทั้งหมด
การจัดรูปแบบก็กินทรัพยากร เช่น วันที่ สกุลเงิน และการจัดการสตริงหนัก ๆ จะสะสมเมื่อทำซ้ำหลายเซลล์
กฎง่าย ๆ ให้จำ
ถ้าค่าหนึ่งมองไม่เห็น อย่าคำนวณมันตอนนี้ แคชผลการจัดรูปแบบที่แพงและคำนวณเมื่อจำเป็น เช่น เมื่อแถวปรากฏหรือเมื่อผู้ใช้เปิดแถว
รายการปรับปรุงด่วนที่มักได้ผลชัดเจน:
- จำกัดการเรนเดอร์เริ่มต้นให้เป็นเพจขนาดเล็ก (หรือหน้าต่างเสมือน)
- ให้ความสูงของแถวคงที่และหลีกเลี่ยงเซลล์หลายบรรทัด
- เลื่อน UI หนัก (tooltip เมนู) ไว้ที่ hover หรือ click
- ใช้ memoization หรือแคชสำหรับการจัดรูปแบบวันที่และสกุลเงินต่อแถว
- หลีกเลี่ยงการเรนเดอร์ซ้ำทั้งชุดเมื่อมีการเปลี่ยนแค่ตัวกรองเดียว
ตัวอย่าง: ตาราง Invoices ภายในที่จัดรูปแบบคอลัมน์สกุลเงินและวันที่ 12 คอลัมน์จะกระตุกขณะเลื่อน การแคชค่าที่จัดรูปแบบต่อ invoice และหน่วงงานสำหรับแถวที่อยู่นอกหน้าจอสามารถทำให้รู้สึกทันที แม้ก่อนจะทำงานเชิงลึกฝั่ง backend
Virtualization: ทำให้การเลื่อน 100k แถวลื่น
Virtualization หมายถึงตารางจะวาดเฉพาะแถวที่มองเห็นได้จริง (บวก buffer เล็กน้อยด้านบนและล่าง) เมื่อเลื่อนมันจะนำ DOM element เดิมมาใช้ซ้ำแล้วสลับข้อมูลภายใน นั่นช่วยให้เบราว์เซอร์ไม่พยายามวาดคอมโพเนนต์แถวจำนวนหมื่น ๆ พร้อมกัน
Virtualization เหมาะกับรายการยาว ตารางกว้าง หรือแถวหนัก (avatar ชิปสถานะ เมนูการกระทำ tooltip) และมีประโยชน์เมื่อผู้ใช้เลื่อนเยอะและคาดหวังว่าจะเห็นวิวต่อเนื่องลื่น ๆ แทนการกระโดดเป็นหน้าทีละหน้า
จุดที่ virtualization ทำให้ยุ่งยาก
มันไม่ใช่เวทย์มนตร์ มีบางอย่างที่มักสร้างความประหลาดใจ:
- ความสูงแถวไม่คงที่ (ข้อความตัดบรรทัด แถวขยายได้) ทำให้การเลื่อนลื่นถูกทำลายเว้นแต่จะวัดความสูงหรือรักษาขนาดแถวให้คงที่
- หัวตารางยึดติด (sticky headers) และคอลัมน์ยึดติดอาจขัดกับ container แบบ virtual หากเลย์เอาต์ใช้ CSS ซับซ้อน
- การนำทางด้วยคีย์บอร์ด (ขึ้น/ลง, page up/down, focus ในเซลล์) ต้องระวังเป็นพิเศษเพื่อไม่ให้ focus กระโดดไปยังแถวที่ยัง unmounted
- การทำ bulk actions ต้องมีกฎชัดเจน: เลือกเฉพาะแถวที่มองเห็น เลือกตามตัวกรอง หรือเลือกทั้งชุดข้อมูล
วิธีที่เรียบง่ายที่สุดคือวิธีน่าเบื่อ: ความสูงแถวคงที่ คอลัมน์คาดเดาได้ และไม่ใส่วิดเจ็ตเชิงโต้ตอบมากเกินไปภายในแต่ละแถว
Virtualization + Pagination (โดยไม่ทำให้ผู้ใช้สับสน)
คุณสามารถผสมทั้งสอง: ใช้การแบ่งหน้า (หรือโหลดแบบ cursor) เพื่อจำกัดสิ่งที่ดึงจากเซิร์ฟเวอร์ และใช้ virtualization เพื่อรักษาการเรนเดอร์ให้ถูกภายในสไลซ์ที่ดึงแล้ว
รูปแบบปฏิบัติคือดึงขนาดเพจปกติ (มัก 100–500 แถว), ทำ virtualization ภายในเพจนั้น, และให้ตัวควบคุมชัดเจนสำหรับย้ายระหว่างหน้า หากใช้ infinite scroll ให้แสดงตัวบ่งชี้ "Loaded X of Y" เพื่อให้ผู้ใช้เข้าใจว่าพวกเขายังไม่เห็นทั้งหมด
ตัวเลือกการแบ่งหน้าที่ยังเร็ว
ถ้าต้องการหน้ารายการที่ยังใช้งานได้เมื่อข้อมูลเพิ่ม การแบ่งหน้ามักเป็นค่าเริ่มต้นที่ปลอดภัย มันคาดเดาได้ ทำงานได้ดีกับเวิร์กโฟลว์แอดมิน (รีวิว แก้ไข อนุมัติ) และรองรับความต้องการทั่วไปเช่นการส่งออก "หน้า 3 พร้อมตัวกรองเหล่านี้" โดยไม่มีความประหลาดใจ หลายทีมกลับมาใช้การแบ่งหน้าหลังจากลองวิธีการเลื่อนอื่น ๆ
Infinite scroll ให้ความรู้สึกดีสำหรับการเรียกดูแบบไม่ตั้งใจ แต่มีต้นทุนแฝง ผู้ใช้จะเสียความรู้สึกว่าตัวเองอยู่ตรงไหน ปุ่มย้อนกลับมักไม่กลับไปตำแหน่งเดิม และเซสชันยาวอาจสะสมหน่วยความจำเมื่อโหลดแถวมากขึ้น ทางสายกลางคือปุ่ม Load more ที่ยังใช้เพจ ทำให้ผู้ใช้คงทิศทาง
Offset vs keyset pagination
Offset pagination เป็นแบบคลาสสิก page=10&size=50 มันง่าย แต่เมื่อโตขึ้นอาจช้าบนตารางใหญ่เพราะฐานข้อมูลอาจต้องข้ามแถวจำนวนมากไปยังหน้าหลัง ๆ และมันทำให้แถวเปลี่ยนหน้าถ้ามีแถวใหม่เข้ามา
Keyset pagination (เรียกอีกอย่างว่า cursor pagination) ขอ "50 แถวถัดไปหลังจากรายการที่เห็นล่าสุด" โดยใช้ id หรือ created_at มันมักจะเร็วเพราะไม่ต้อง count และ skip มาก
กฎปฏิบัติ:
- ใช้ offset กับรายการเล็ก ชุดข้อมูลคงที่ และเมื่อจำเป็นต้องกระโดดไปหน้าที่ระบุ
- ใช้ keyset กับรายการใหญ่ การแทรกบ่อย และการนำทาง Next/Previous
แสดงจำนวนรวมโดยไม่ต้องจ่ายแพงทุกครั้ง
ผู้ใช้มักชอบเห็นจำนวนรวม แต่การทำ "count ทุกแถวที่ตรงเงื่อนไข" อาจแพงกับตัวกรองหนัก ๆ ตัวเลือกได้แก่การแคชจำนวนสำหรับตัวกรองยอดนิยม, อัปเดตจำนวนในพื้นหลังหลังหน้าโหลด, หรือแสดงจำนวนโดยประมาณ (เช่น "10,000+")
ตัวอย่าง: หน้าสั่งซื้อภายในสามารถแสดงผลทันทีด้วย keyset pagination แล้วเติมจำนวนรวมที่แม่นยำเมื่อผู้ใช้หยุดเปลี่ยนตัวกรองสักครู่
ถ้าคุณสร้างด้วย Koder.ai ให้จัดการพฤติกรรมการแบ่งหน้าและการนับเป็นส่วนของสเปคหน้าตั้งแต่เริ่ม เพื่อที่ API และสถานะ UI ที่สร้างจะไม่ขัดกันทีหลัง
การกรองและการค้นหาที่ให้ความรู้สึกทันที
หน้ารายการส่วนใหญ่รู้สึกช้าเพราะเริ่มแบบเปิดกว้าง: โหลดทุกอย่าง แล้วค่อยให้ผู้ใช้แคบลง พลิกมุมมองนั้น เริ่มด้วยค่าพื้นฐานที่สมเหตุสมผลซึ่งคืนผลชุดเล็กที่มีประโยชน์ (เช่น 7 วันที่ผ่านมา, ไอเท็มของฉัน, สถานะ: Open) และให้ตัวเลือก All time เป็นการเลือกชัดเจน
การค้นหาด้วยข้อความเป็นกับดักทั่วไปอีกอย่าง ถ้ารัน query ทุกครั้งที่พิมพ์ คุณจะสร้างคิวคำขอและ UI กระพริบ ให้ debounce ช่องค้นหาเพื่อเรียกเมื่อผู้ใช้หยุดพิมพ์เล็กน้อย และยกเลิกคำขอเก่าพอมีคำขอใหม่ กฎง่าย ๆ: ถ้าผู้ใช้ยังพิมพ์ อย่าทุบเซิร์ฟเวอร์
การกรองจะรู้สึกเร็วเมื่อชัดเจนด้วย แสดงชิปตัวกรองด้านบนตารางเพื่อให้ผู้ใช้เห็นว่าสิ่งใดกำลังถูกใช้และสามารถลบได้ด้วยคลิกเดียว ให้ป้ายชิปอ่านง่ายเป็นภาษามนุษย์ ไม่ใช่ชื่อฟิลด์ดิบ (เช่น Owner: Sam แทน owner_id=42) เมื่อคนบอกว่า "ผลหายไป" มักเป็นเพราะมีตัวกรองที่มองไม่เห็น
รูปแบบที่ช่วยให้รายการใหญ่ตอบสนองโดยไม่ซับซ้อน UI:
- ค่าเริ่มต้นเป็นตัวกรองแคบและช่วงวันที่สั้น
- debounce การค้นหาด้วยข้อความและยกเลิกคำขอที่กำลังรัน
- แสดงชิปตัวกรองและปุ่ม Clear all
- เสนอ saved views สำหรับงานที่ใช้บ่อย
- กั้นตัวกรองที่แพงจนกว่าจะมีตัวกรองแคบอย่างน้อยหนึ่งอย่าง
Saved views เป็นฮีโร่ที่เงียบ ๆ แทนที่จะสอนผู้ใช้ให้สร้างคอมโบตัวกรองครั้งเดียว ให้พวกเขามีพรีเซ็ตไม่กี่แบบที่ตรงงานจริง ทีม ops อาจสลับระหว่าง Failed payments วันนี้ กับ High-value customers ได้ด้วยคลิกเดียว ชุดงานเหล่านี้เข้าใจง่ายและรักษาความเร็วฝั่ง backend ได้ง่ายกว่าการกรอง ad-hoc
ถ้าคุณสร้างเครื่องมือภายในด้วยบิลเดอร์แบบขับเคลื่อนด้วยแชทอย่าง Koder.ai ให้ถือว่าตัวกรองเป็นส่วนหนึ่งของโฟลว์ผลิตภัณฑ์ ไม่ใช่ของเสริม เริ่มจากคำถามที่พบบ่อยที่สุด แล้วออกแบบมุมมองเริ่มต้นและ saved views รอบ ๆ คำถามเหล่านั้น
การปรับรูปแบบคำสั่ง (Query shaping): ส่งน้อย คำนวณน้อย
หน้ารายการไม่ค่อยต้องการข้อมูลเท่าหน้ารายละเอียด ถ้า API คืนทุกอย่างเกี่ยวกับทุกอย่าง คุณจ่ายสองครั้ง: ฐานข้อมูลทำงานมากขึ้น และเบราว์เซอร์ได้รับและเรนเดอร์มากกว่าที่ใช้จริง Query shaping คือการขอเฉพาะสิ่งที่หน้าต้องการตอนนี้
เริ่มจากคืนเฉพาะคอลัมน์ที่จำเป็นในการเรนเดอร์แต่ละแถว สำหรับแดชบอร์ดส่วนใหญ่ นั่นคือ id, ป้ายสองสามอัน, สถานะ, เจ้าของ, และ timestamps ข้อความยาว JSON blobs และฟิลด์คำนวณสามารถรอจนกว่าผู้ใช้จะเปิดแถว
หลีกเลี่ยง join หนักสำหรับการแสดงผลครั้งแรก Join ใช้ได้เมื่อชนกับดัชนีและคืนผลเล็ก แต่จะแพงเมื่อคุณ join หลายตารางแล้วเรียงหรือกรองบนข้อมูลที่ join มา รูปแบบง่าย ๆ คือ: ดึงรายการจากตารางเดียวให้เร็ว แล้วโหลดรายละเอียดที่เกี่ยวข้องตามต้องการ (หรือโหลดเป็นกลุ่มสำหรับแถวที่มองเห็นเท่านั้น)
จำกัดตัวเลือกการเรียงและเรียงด้วยคอลัมน์ที่มีดัชนี "เรียงได้ทุกอย่าง" ฟังดูมีประโยชน์ แต่บ่อยครั้งบังคับให้ต้องเรียงช้าในชุดข้อมูลใหญ่ ให้เลือกไม่กี่ตัวเลือกที่คาดเดาได้ เช่น created_at, updated_at, หรือ status และตรวจสอบให้คอลัมน์เหล่านั้นมีดัชนี
ระวังการรวมผลทางฝั่งเซิร์ฟเวอร์ COUNT(*) บนชุดกรองใหญ่, DISTINCT บนคอลัมน์กว้าง, หรือการคำนวณหน้าทั้งหมดอาจกินเวลาตอบกลับมาก
แนวทางปฏิบัติ:
- ปรับรูปแบบการตอบกลับของรายการให้เหลือ 5–10 ฟิลด์
- ดึงรายละเอียดแถวเมื่อเปิดแถวเท่านั้น
- อนุญาตการเรียง 2–4 แบบ โดยอิงดัชนี
- ถือว่า
COUNTและDISTINCTเป็นทางเลือก และแคชหรือประมาณเมื่อเป็นไปได้
ถ้าคุณสร้างเครื่องมือภายในบน Koder.ai ให้กำหนด query รายการแบบเบาแยกจาก query รายละเอียดในขั้นตอนวางแผน เพื่อ UI จะยังคงลื่นเมื่อข้อมูลโต
กลยุทธ์ฐานข้อมูลสำหรับตารางใหญ่
ถ้าต้องการหน้ารายการที่ยังเร็วที่ 100k แถว ฐานข้อมูลต้องทำงานน้อยลงต่อคำขอ รายการช้าที่สุดมักไม่ใช่เพราะข้อมูลมากเกินไป แต่เป็นเพราะรูปแบบการเข้าถึงข้อมูลผิด
เริ่มจากดัชนีที่สอดคล้องกับการใช้งานจริงของผู้ใช้ หากรายการมักถูกกรองด้วย status และเรียงด้วย created_at คุณต้องการดัชนีที่รองรับทั้งสองในลำดับนั้น ไม่เช่นนั้นฐานข้อมูลอาจสแกนแถวมากกว่าที่คิดแล้วค่อยเรียง ซึ่งแพงเร็ว
การแก้ที่มักให้ผลใหญ่:
- เพิ่มดัชนีผสมที่สะท้อนตัวกรอง + การเรียงที่พบบ่อย (เช่น
tenant_id, status, created_at) - ชอบ keyset (cursor) pagination มากกว่า
OFFSETลึก ๆOFFSETทำให้ฐานข้อมูลต้องก้าวผ่านแถวจำนวนมากเพื่อข้าม - ถือว่าจำนวนรวมเป็นทางเลือก ตัวจำนวนแม่นยำอาจช้า ให้แคช ล่วงหน้า หรือนำเสนอเป็นค่าประมาณ
- เก็บแถวให้บาง (thin) อย่าเลือกฟิลด์ข้อความยาว JSON blobs หรืออ็อบเจ็กต์ซ้อนสำหรับหน้ารายการ
- ปรับคำสั่งให้คืนเฉพาะที่ UI เรนเดอร์: คอลัมน์ไม่กี่อันสำหรับตาราง บวก cursor ของหน้าถัดไป
ตัวอย่างง่าย ๆ: ตาราง Orders ภายในที่แสดงชื่อผู้ใช้ สถานะ จำนวน และวันที่ อย่า join ทุกตารางที่เกี่ยวข้องและดึงบันทึกคำสั่งเต็มสำหรับมุมมองรายการ ให้คืนเฉพาะคอลัมน์ที่ใช้ในตาราง แล้วโหลดที่เหลือในคำขอแยกเมื่อลูกค้าเปิดดูคำสั่ง
ถ้าคุณสร้างด้วยแพลตฟอร์มอย่าง Koder.ai ให้ยึดแนวคิดนี้แม้ UI จะถูกสร้างจากแชท ตรวจสอบให้ endpoint ที่สร้างรองรับ cursor pagination และฟิลด์ที่เลือกได้ เพื่อให้งานฐานข้อมูลคาดเดาได้เมื่อโตขึ้น
แผนทีละขั้นตอนเพื่อเร่งหน้ารายการที่มีอยู่
ถ้าหน้ารายการรู้สึกช้าที่สุดตอนนี้ อย่าเริ่มด้วยการเขียนใหม่ทั้งหมด เริ่มจากล็อกลงว่าใช้งานปกติเป็นอย่างไร แล้วเพิ่มประสิทธิภาพเส้นทางนั้น
แผนปฏิบัติ 5 ขั้นตอน
-
กำหนดมุมมองเริ่มต้น. เลือกตัวกรองเริ่มต้น การเรียง และคอลัมน์ที่มองเห็น หน้าจะช้าเมื่อพยายามโชว์ทุกอย่างเป็นค่าพื้นฐาน
-
เลือกสไตล์การแบ่งหน้าที่ตรงกับการใช้งาน. ถ้าผู้ใช้สแกนแค่หน้าแรกสองสามหน้า การแบ่งหน้าแบบคลาสสิกพอเพียง ถ้าผู้ใช้ไปลึกมาก (หน้า 200+) หรือคุณต้องการประสิทธิภาพคงที่ ให้ใช้ keyset pagination (อิงการเรียงเสถียรเช่น
created_atบวกid) -
เพิ่ม virtualization สำหรับส่วนของตาราง. แม้ backend จะเร็ว เบราว์เซอร์อาจล่มเมื่อเรนเดอร์แถวเยอะเกินไป
-
ทำให้การค้นหาและตัวกรองรู้สึกทันที. ใส่ debounce สำหรับการพิมพ์เพื่อไม่เรียกคำขอทุก keypress เก็บสถานะตัวกรองใน URL หรือ state กลางเดียวเพื่อให้รีเฟรช ปุ่มย้อนกลับ และการแชร์มุมมองทำงานได้อย่างเชื่อถือได้ แคชผลสำเร็จล่าสุดเพื่อให้ตารางไม่กระพริบเป็นค่าว่าง
-
วัดผล แล้วปรับคำสั่งและดัชนี. บันทึกเวลาเซิร์ฟเวอร์ เวลาในฐานข้อมูล ขนาด payload และเวลาเรนเดอร์ จากนั้นตัดคำสั่ง: เลือกเฉพาะคอลัมน์ที่แสดง ใช้ตัวกรองตั้งแต่ต้น และเพิ่มดัชนีที่สอดคล้องกับตัวกรองเริ่มต้น + การเรียง
ตัวอย่าง: แดชบอร์ดซัพพอร์ตภายในที่มี 100k ตั๋ว ตั้งเป็นค่าเริ่มต้นเป็น Open มอบหมายให้ทีมของฉัน เรียงตามใหม่สุด แสดงหกคอลัมน์ และดึงแค่ ticket id, subject, assignee, status, และ timestamps ด้วย keyset pagination และ virtualization คุณจะรักษาความคาดเดาได้ทั้งฝั่งฐานข้อมูลและ UI
ถ้าคุณสร้างเครื่องมือภายในใน Koder.ai แผนนี้จับคู่ได้ดีกับเวิร์กโฟลว์ iterate-and-check: ปรับมุมมอง ทดสอบการเลื่อนและการค้นหา แล้วจูนคำสั่งจนหน้าไม่ช้า
ข้อผิดพลาดทั่วไปที่ทำให้รายการช้ามาก
วิธีที่เร็วที่สุดทำให้หน้ารายการพังคือถือว่า 100k แถวเป็นหน้าข้อมูลปกติ กับดักที่พบบ่อยมีไม่กี่แบบ
ข้อใหญ่ข้อหนึ่งคือเรนเดอร์ทุกอย่างแล้วซ่อนด้วย CSS แม้มันจะดูเหมือนแค่ 50 แถวที่มองเห็น เบราว์เซอร์ยังจ่ายค่าการสร้าง 100k โหนด DOM วัดพวกมัน และ repaint ตอนเลื่อน ถ้าต้องการรายการยาว ควรเรนเดอร์เฉพาะที่ผู้ใช้เห็น (virtualization) และทำให้คอมโพเนนต์แถวเรียบง่าย
การค้นหาก็สามารถทำลายประสิทธิภาพเงียบ ๆ เมื่อตัวพิมพ์ทุกตัวทริกเกอร์การสแกนตารางเต็ม นี่เกิดขึ้นเมื่อตัวกรองไม่มีดัชนี เมื่อคุณค้นหาหลายคอลัมน์ หรือเมื่อรัน contains queries บนฟิลด์ข้อความใหญ่โดยไม่มีแผน กฎที่ดีคือตัวกรองแรกที่ผู้ใช้จะใช้ควรถูกออกแบบให้ถูกในฐานข้อมูล ไม่ใช่แค่สะดวกใน UI
ปัญหาทั่วไปอีกอย่างคือดึงระเบียนเต็มเมื่อหน้าแค่ต้องการสรุป แถวรายหนึ่งมักต้องการ 5–12 ฟิลด์ ไม่ใช่อ็อบเจ็กต์ทั้งหมดหรือคำอธิบายยาว ๆ การดึงข้อมูลเกินจำเป็นเพิ่มงานฐานข้อมูล เวลาเครือข่าย และการแยกวิเคราะห์ฝั่ง frontend
การส่งออกและการคำนวณจำนวนรวมสามารถทำให้ UI ค้างถ้าคำนวณบน main thread หรือรอคำขอหนักก่อนตอบ ให้เก็บ UI โต้ตอบได้: เริ่มการส่งออกในพื้นหลัง แสดงความคืบหน้า และหลีกเลี่ยงการคำนวณจำนวนซ้ำทุกครั้งที่ตัวกรองเปลี่ยน
สุดท้าย ตัวเลือกการเรียงที่มากเกินไปกลับตลก ถ้าผู้ใช้เรียงได้ทุกคอลัมน์ คุณจะต้องเรียงชุดข้อมูลใหญ่ในหน่วยความจำหรือบังคับให้ฐานข้อมูลใช้แผนช้า ให้จำกัดการเรียงในชุดคอลัมน์ที่มีดัชนี และตั้งค่าเริ่มต้นให้ตรงกับดัชนีจริง
เช็ครวดเร็วด้วยสัญชาตญาณ:
- ถ้าคุณเลื่อนได้เรื่อย ๆ โดยไม่โหลดแสดงว่าคุณน่าจะเรนเดอร์มากเกินไป
- ถ้าการพิมพ์ในการค้นหาหน่วง คำสั่งของคุณน่าจะสแกน
- ถ้า API รายการคืน JSON ใหญ่ คุณดึงมากเกินไป
- ถ้าการส่งออกทำให้หน้าแขวน งานกำลังเกิดบนที่ผิด
- ถ้าการเรียงทุกอย่างช้า ดัชนีและตัวเลือกการเรียงต้องปรับ
เช็คลิสต์ด่วนและขั้นตอนต่อไป
ถือว่าประสิทธิภาพหน้ารายการเป็นฟีเจ็ตผลิตภัณฑ์ ไม่ใช่การปรับแต่งครั้งเดียว หน้ารายการจะเร็วเมื่อมันรู้สึกเร็วขณะที่คนจริงเลื่อน กรอง และเรียงในข้อมูลจริง
ใช้เช็คลิสต์นี้เพื่อตรวจสอบว่าคุณแก้ถูกจุด:
- First paint เร็ว: หน้าโหลด payload เล็กและแสดงแถวแรกทันที
- การเลื่อนลื่น: เปิด virtualization, CPU เบราว์เซอร์ไม่กระโดด, และความสูงแถวคาดเดาได้
- ตัวกรองตอบสนอง: การพิมพ์หรือเลือกตัวกรองอัปเดตผลเร็ว และตัวกรองไม่รีเซ็ตเมื่อคุณแบ่งหน้า/รีเฟรช
- การเรียงสมเหตุสมผล: อนุญาตการเรียงที่มีดัชนีหรือฟิลด์ที่คำนวณไว้ และรักษาลำดับเรียงให้คงที่ข้ามหน้า
- คำขอถูกปรับรูปแบบ: API คืนเฉพาะคอลัมน์ที่คุณแสดง บวก ID เสถียรและ cursors ไม่ใช่อ็อบเจ็กต์เต็มเผื่อไว้
การตรวจสอบง่าย: เปิดหน้ารายการ เลื่อน 10 วินาที แล้วใช้ตัวกรองที่ใช้บ่อย (เช่น Status: Open) ถ้า UI ค้าง ปัญหามักเป็นการเรนเดอร์ (เรนเดอร์ DOM เกิน) หรือการแปลงหนักฝั่งไคลเอนต์ (เรียง กลุ่ม จัดรูปแบบ) ที่เกิดขึ้นทุกอัปเดต
ขั้นตอนต่อไปตามลำดับเพื่อไม่เด้งระหว่างการแก้ไข:
- วัดหนึ่งฟลูว์ของผู้ใช้ตั้งแต่ต้นจนจบ (โหลด เลื่อน กรอง เรียง) และจดเวลาที่ต้องการ
- เปิด virtualization และจำกัดการจัดรูปแบบแพงในเซลล์ (วันที่ สกุลเงิน avatar)
- ย้ายการกรองและการเรียงไปที่เซิร์ฟเวอร์ และจำกัดตัวเลือกในสิ่งที่สามารถเร็วได้
- ตรึงคำสั่งและ payload แล้ววัดใหม่
ถ้าคุณสร้างกับ Koder.ai (koder.ai) ให้เริ่มที่ Planning Mode: กำหนดคอลัมน์รายการ ตัวฟิลเตอร์ และรูปแบบการตอบกลับก่อน แล้วทำซ้ำโดยใช้ snapshots และ rollback เมื่อการทดลองทำให้หน้าช้าลง
คำถามที่พบบ่อย
ทำไมหน้ารายการของฉันถึงดูโอเคตอนแรกแต่เริ่มช้าพอข้อมูลถึง 100k แถว?
เปลี่ยนเป้าหมายจาก “โหลดทุกอย่าง” เป็น “แสดงแถวที่เป็นประโยชน์แรกให้เร็ว” ให้เพิ่มประสิทธิภาพที่เวลาเพื่อแถวแรกและการตอบสนองที่ลื่นไหลเมื่อกรอง เรียง และเลื่อน ถึงแม้จะไม่ได้โหลดข้อมูลทั้งชุดพร้อมกันก็ตาม
ตัวชี้วัดอะไรที่ควรติดตามสำหรับหน้ารายการที่ช้า?
วัดเวลาเพื่อแถวแรกหลังการโหลดหรือเปลี่ยนตัวกรอง, เวลาที่ตัวกรอง/การเรียงใช้ผลใหม่, ขนาด payload ของการตอบกลับ, คำสั่งฐานข้อมูลช้า (โดยเฉพาะ SELECT กว้าง ๆ และ COUNT(*)), และการกระโดดของ main-thread ในเบราว์เซอร์ ตัวเลขเหล่านี้จะสื่อถึงสิ่งที่ผู้ใช้รับรู้ว่าเป็น “หน่วง” ได้ตรงจุด
ฉันจะบอกได้อย่างรวดเร็วว่า slowdown มาจาก frontend หรือ backend อย่างไร?
จำกัด API ชั่วคราวให้คืนแค่ 20 แถวโดยใช้ตัวกรองและการเรียงแบบเดิม หากหน้ารวดเร็วขึ้น แสดงว่าคอขวดมาจากขนาดการตอบกลับหรือเวลา query ถ้ายังช้าอยู่ ปัญหามักอยู่ที่การเรนเดอร์ การจัดรูปแบบ หรืองานฝั่งไคลเอนต์ต่อแถว
การเปลี่ยนแปลง frontend แบบง่าย ๆ อะไรที่ช่วยให้ตารางใหญ่เร็วขึ้นได้?
อย่าเรนเดอร์หลายพันแถวใน DOM พร้อมกัน, ทำให้คอมโพเนนต์แถวเรียบง่าย และควรใช้ความสูงแถวคงที่ นอกจากนี้หลีกเลี่ยงการประมวลผลหนัก ๆ สำหรับแถวที่อยู่นอกหน้าจอ; คำนวณและแคชการจัดรูปแบบเมื่อแถวปรากฏหรือเมื่อผู้ใช้เปิดดู
เมื่อไหร่ควรใช้ virtualization และจะมีอะไรที่พังเมื่อเพิ่มมัน?
Virtualization จะติดตั้งเฉพาะแถวที่มองเห็นได้ (และสำรองเล็กน้อย) แล้วนำ DOM element เดิมมาใช้ซ้ำเมื่อเลื่อน มันคุ้มเมื่อต้องเลื่อนเยอะหรือแถวหนัก แต่จะทำงานได้ดีที่สุดถ้าความสูงแถวสม่ำเสมอและเลย์เอาต์คาดเดาได้
การแบ่งหน้าดีกว่าการเลื่อนไม่รู้จบสำหรับชุดข้อมูลใหญ่หรือเปล่า?
การแบ่งหน้าเป็นค่าพื้นฐานที่ปลอดภัยสำหรับงานแอดมินและงานภายใน เพราะช่วยให้ผู้ใช้คงทิศทางและจำกัดงานฝั่งเซิร์ฟเวอร์ การเลื่อนไม่รู้จบเหมาะสำหรับการท่องแบบสบาย ๆ แต่จะทำให้การนำทางและการใช้หน่วยความจำแย่ลงถ้าไม่มีการจัดการสถานะที่ชัดเจน
ฉันควรใช้การแบ่งหน้าแบบ offset หรือ cursor (keyset)?
การแบ่งหน้าแบบ offset ง่ายแต่จะช้าลงเมื่อไปลึกเพราะฐานข้อมูลต้องข้ามแถวจำนวนมาก page=10&size=50 แบบคลาสสิกเหมาะกับรายการเล็กหรือเมื่อจำเป็นต้องไปยังหน้าที่ระบุ คีย์เซ็ต (cursor) pagination มักเร็วกว่าเพราะไม่ต้องข้ามแถวจำนวนมาก แต่ไม่เหมาะกับการกระโดดไปหน้าที่ระบุเสมอไป
ทำอย่างไรให้การค้นหาและการกรองรู้สึกทันทีโดยไม่ทำให้เซิร์ฟเวอร์หนักเกินไป?
อย่าเรียกคำขอทุกครั้งที่พิมพ์ ใส่ debounce ให้ช่องค้นหา ยกเลิกคำขอที่ยังรันเมื่อมีคำขอใหม่ และตั้งค่าเริ่มต้นเป็นตัวกรองที่แคบ (เช่น รายการ 7 วันที่ผ่านมา หรือ My items) เพื่อให้คำขอแรกมีขนาดเล็กและมีประโยชน์
API ของหน้ารายการควรคืนอะไรเพื่อให้ยังเร็ว?
API ควรคืนเฉพาะฟิลด์ที่หน้ารายการต้องแสดง เช่น id, label, status, owner และ timestamps ข้อมูลยาว ๆ, JSON blobs และฟิลด์คำนวณสามารถโหลดเมื่อเปิดแถวเพื่อให้การแสดงผลครั้งแรกเบาและคาดเดาได้
การเปลี่ยนแปลงฐานข้อมูลอะไรที่มักช่วยกับรายการ 100k แถวได้มากที่สุด?
ทำให้ตัวกรองและการเรียงสอดคล้องกับพฤติกรรมจริงของผู้ใช้ แล้วสร้างดัชนี (index) ที่รองรับรูปแบบนั้น เช่น ดัชนีผสมที่รวม tenant_id, status, created_at การนับแบบแม่นยำอาจช้า ให้เก็บแคช หรือนำเสนอเป็นค่าประมาณเมื่อไม่จำเป็นต้องแม่นยำทันที