Đừng phân trang bằng offset-limit nữa 😤 — câu chuyện từ hệ thống 1,9 triệu mã dự thưởng

4 min readKể chuyện công việc
Share:
Đừng phân trang bằng offset-limit nữa  😤 — câu chuyện từ hệ thống 1,9 triệu mã dự thưởng

Làm web đủ lâu, ai cũng từng phân trang bằng OFFSET và LIMIT. Nó tiện, đơn giản, và chạy ngon lành — cho đến khi hệ thống bắt đầu phình to. Tôi cũng từng nghĩ vậy, cho đến khi tôi phát triển và vận hành dự án "Mini Game - Mã Dự Thưởng".

Hệ thống đó lưu khoảng 1,9 triệu mã. Mỗi mã dự thưởng có trạng thái, ngày tạo, hàng đống metadata khác. Người dùng muốn tra soát: xem những mã đã quét, tìm mã trúng, lọc theo thời gian, v.v. Nghe chẳng có gì đặc biệt, nhưng lúc phải phân trang danh sách đó — ác mộng thật sự bắt đầu.

Khi OFFSET - LIMIT trở mặt

Phiên bản đầu tiên của API:

SELECT * FROM reward_codes
ORDER BY created_at DESC
LIMIT 50 OFFSET 5000;

Query này nhìn ổn. Nhưng khi dữ liệu chạm ngưỡng vài trăm nghìn dòng, thời gian trả về nhảy lên 2–3 giây, có khi hơn. Vì database vẫn phải skip qua 5.000 bản ghi đầu tiên để tìm 50 dòng kế tiếp.

Cảm giác của user thì sao?

  • Scroll xuống chờ load, xoay vòng cả nửa giây.
  • Quay lại trang 2, dữ liệu lại khác, mất luôn vị trí cũ.
  • Có lúc hệ thống sinh thêm mã mới → mấy bản ghi trên cùng thay đổi → trang 1 và trang 2 hiển thị trùng mất rồi.

Tôi nhớ hôm triển khai thử nghiệm hệ thống, sếp bảo: “Khang ơi, sao load danh sách 10k mã mà chờ mỏi mắt luôn” — đó là lúc tôi biết offset-limit không thể cứu vãn.

Chuyển mình sang cursor pagination

Tôi nghiên cứu lại cách phân trang và chọn hướng cursor-based pagination (thường gọi là keyset pagination).
Thay vì “đếm và bỏ qua” dữ liệu, ta chỉ cần biết “đi tiếp từ đâu”.

SELECT * FROM reward_codes
WHERE id < 12345
ORDER BY created_at DESC
LIMIT 50;
Các ID này dựa trên thuật toán tạo ID Snowflake của Twitter.

Cursor ở đây chính là id. Với cách này, việc truy vấn luôn ổn định, nhanh, và không bị lệch dữ liệu khi có bản ghi mới xuất hiện.

Khi người dùng scroll thêm, frontend chỉ cần gửi last_id xuống server là đủ.

Kết quả:

  • Query từ 3 giây xuống còn dưới 100ms.
  • UI mượt mà hơn hẳn, không còn “giật” khi load trang sau.
  • Người dùng có thể quay lại đúng vị trí cũ, không bị mất track.
  • Backend nhẹ hơn, cache hiệu quả hơn vì truy vấn cụ thể, không phải nhảy offset lung tung.

UI/UX thay đổi ra sao?

Trước đây, mỗi lần user “Next page”, danh sách nhảy trang — cảm giác bị reload toàn bộ. Sau cải tiến, tôi chuyển hẳn sang kiểu infinite scroll. Khi scroll đến cuối, app chỉ gọi API với cursor mới, nối kết quả vào danh sách hiện tại. Nếu có mã mới sinh ra, người dùng sẽ thấy banner nhỏ: “Có 5 mã mới được thêm 🎉” — click là load thêm ngay trước phần đang xem. Không cần reload hay mất vị trí cuộn.
Đây chính là thứ trước kia offset-limit làm không được.

Bài học ở đây là gì?

Tôi từng nghĩ tối ưu query chỉ là chuyện backend, hóa ra nó ảnh hưởng trực tiếp đến trải nghiệm người dùng. Cursor pagination không chỉ giúp hệ thống scale tốt hơn, mà còn tạo ra cảm giác “real-time” rõ rệt.

Nếu bạn đang làm app có danh sách lớn — feed, mã dự thưởng, log, order list,... — hãy thử bỏ offset-limit đi. Backend của bạn sẽ thở phào, và người dùng sẽ cảm ơn bạn vì mọi thứ trở nên nhẹ nhàng, tự nhiên hơn.

Và đến giờ, mỗi lần có ai nói “phân trang thì offset-limit thôi mà”, tôi chỉ cười: đã đến lúc chúng ta nên lật sang trang mới.

Cảm ơn bạn đã đọc đến đây. Hy vọng khi gấp lại tab này, bạn sẽ mang theo được ít nhất một điều gì đó - một ý tưởng, một câu hỏi, hoặc một chút kinh nghiệm áp dụng vào sản phẩm/dự án thực tế.
Xin chào và hẹn gặp lại bạn ở những bài viết tiếp theo.