Vượt qua OFFSET: Tại sao Cursor-Based Pagination là cách duy nhất để Scale API

Database tutorial - IT technology blog
Database tutorial - IT technology blog

Ngày API của tôi chạm ngưỡng giới hạn

Tôi còn nhớ một ngày thứ Ba cụ thể khi nền tảng thương mại điện tử của chúng tôi cuối cùng cũng chạm ngưỡng giới hạn. Ở môi trường staging, với khoảng 10.000 sản phẩm, tính năng ‘cuộn vô tận’ (infinite scroll) hoạt động hoàn hảo. Nhưng khi chúng tôi vượt mốc 2,5 triệu bản ghi trên môi trường production, người dùng bắt đầu phàn nàn. Các công cụ giám sát cho thấy một câu chuyện nghiệt ngã: các truy vấn cho trang đầu tiên chỉ mất 15ms, nhưng việc lấy dữ liệu cho ‘Trang 500’ kéo dài tới 2,4 giây và đẩy mức sử dụng CPU lên tới 85%.

Trong suốt thời gian làm việc với MySQL, PostgreSQL và MongoDB, tôi đã thấy mô típ này lặp đi lặp lại. Những engine này có những thế mạnh khác nhau, nhưng chúng đều có chung một điểm yếu chết người: OFFSET. Nếu hệ thống của bạn được thiết kế để mở rộng vượt quá vài nghìn hàng, bạn cần từ bỏ tư duy skip-based (bỏ qua bản ghi) truyền thống ngay lập tức.

Phép toán đằng sau sự đình trệ: Tại sao OFFSET thất bại

Hầu hết các lập trình viên tìm đến LIMITOFFSET vì nó tạo cảm giác tự nhiên. Nếu bạn cần trang thứ năm với 20 mục, logic có vẻ đơn giản:

-- Cách này chạy tốt với 1k dòng, nhưng sẽ "nghẹt thở" ở mức 1M
SELECT * FROM orders
ORDER BY created_at DESC
LIMIT 20 OFFSET 80;

Đây là lúc mọi thứ bắt đầu tồi tệ. Để trả về 20 bản ghi bắt đầu từ hàng thứ 81, database không chỉ nhảy thẳng đến đó. Nó phải quét 80 bản ghi đầu tiên, sắp xếp chúng, rồi mới loại bỏ chúng chỉ để đạt đến điểm bắt đầu của bạn. Khi offset là 1.000.000, engine phải thực hiện công việc nặng nhọc là đọc một triệu hàng vào bộ nhớ và sắp xếp chúng, chỉ để vứt bỏ 99,9% công sức đó vào sọt rác.

Điều này tạo ra độ phức tạp O(N). Thời gian truy vấn của bạn tăng tuyến tính theo lượng dữ liệu. Đó là một món nợ hiệu năng vẫn luôn ẩn mình cho đến khi database của bạn thực sự chịu tải nặng.

So sánh các chiến lược

1. OFFSET/LIMIT truyền thống

  • Ưu điểm: Cực kỳ dễ viết. Nó cũng cho phép người dùng nhảy đến một trang cụ thể, ví dụ: “Đi đến trang 10”.
  • Nhược điểm: Hiệu năng giảm mạnh khi người dùng cuộn sâu hơn. Nó cũng gặp vấn đề “trôi” kết quả (drifting)—nếu một mục mới được thêm vào khi người dùng đang ở trang 1, mục đó có thể xuất hiện lại ở trang 2.

2. Phân trang dựa trên Cursor (Keyset Pagination)

Thay vì bảo database bỏ qua bao nhiêu hàng, chúng ta cung cấp một tọa độ cụ thể nơi chúng ta đã dừng lại. Chúng ta sử dụng một cột duy nhất, có thứ tự—như khóa chính hoặc timestamp có độ chính xác cao—làm con trỏ (pointer).

-- Cách tiếp cận hiệu năng cao
SELECT * FROM orders
WHERE id < 12345
ORDER BY id DESC
LIMIT 20;
  • Ưu điểm: Mang lại hiệu năng O(1) ổn định nếu được index đúng cách. Nó cũng xử lý dữ liệu thời gian thực một cách mượt mà mà không bị bỏ sót hoặc lặp lại bản ghi.
  • Nhược điểm: Bạn không thể “nhảy” đến trang 500. Nó chỉ hỗ trợ điều hướng “Tiếp theo” (Next) và “Quay lại” (Previous).

Triển khai Cursor trong thực tế

Nếu bạn muốn phản hồi nhanh chóng, cursor là tiêu chuẩn. Đây là cách các nền tảng như Slack và Stripe xử lý các luồng dữ liệu khổng lồ. Dưới đây là quy trình tôi sử dụng trong môi trường production.

Bước 1: Xác định con trỏ của bạn

Cursor của bạn phải là duy nhất và có thứ tự nhất quán. Một id tiêu chuẩn thường hoạt động tốt, nhưng nếu bạn cần sắp xếp theo created_at, bạn phải kết hợp nó với id. Chỉ sử dụng timestamp thôi là rất rủi ro; nếu hai bản ghi có cùng một mili giây, một bản ghi có thể bị bỏ sót hoàn toàn.

Bước 2: Truy vấn tối ưu

Trong PostgreSQL hoặc MySQL, yêu cầu ban đầu của bạn sẽ trông như thế này:

-- Lấy trang đầu tiên
SELECT id, title, created_at 
FROM posts 
ORDER BY id DESC 
LIMIT 21; -- Chúng ta lấy 21 bản ghi để kiểm tra xem có trang tiếp theo hay không

Khi người dùng yêu cầu đợt dữ liệu tiếp theo, frontend sẽ gửi lại ID của mục cuối cùng (giả sử là ID 500). Truy vấn tiếp theo sẽ trở thành:

-- Nhảy thẳng đến tập dữ liệu tiếp theo
SELECT id, title, created_at 
FROM posts 
WHERE id < 500 
ORDER BY id DESC 
LIMIT 21;

Bước 3: Cấu trúc phản hồi API

Tránh để lộ ID nội bộ của database trong URL của bạn. Việc mã hóa cursor thành một chuỗi mờ (opaque string) giúp bạn tự do thay đổi logic bên dưới sau này mà không làm hỏng frontend. Đây là một cách triển khai sạch sẽ bằng Python và FastAPI:

import base64

def encode_cursor(record_id):
    return base64.b64encode(str(record_id).encode()).decode()

def decode_cursor(cursor_string):
    return int(base64.b64decode(cursor_string).decode())

@app.get("/posts")
def get_posts(cursor: str = None, limit: int = 20):
    # Truy vấn cơ bản
    query = "SELECT * FROM posts"
    
    if cursor:
        last_id = decode_cursor(cursor)
        query += f" WHERE id < {last_id}"
    
    query += " ORDER BY id DESC LIMIT 21"
    # ... thực thi truy vấn ...
    
    has_next = len(results) > limit
    data = results[:limit]
    
    # Tạo con trỏ cho yêu cầu tiếp theo
    next_cursor = encode_cursor(data[-1]['id']) if has_next else None
    
    return {
        "data": data,
        "paging": {
            "next_cursor": next_cursor,
            "has_next": has_next
        }
    }

Ghi chép thực tế: Những bài học đắt giá

1. Indexing là bắt buộc

Phân trang bằng cursor chỉ nhanh vì nó sử dụng index để nhảy trực tiếp đến hàng bắt đầu. Nếu các mệnh đề WHEREORDER BY của bạn không khớp với một composite index, database sẽ quay lại quét toàn bộ bảng (full table scan). Trong ví dụ trên, một index trên id chính là “phao cứu sinh” của bạn.

2. Sắp xếp theo các cột không duy nhất

Nếu bạn cần sắp xếp theo price, cursor của bạn phải bao gồm cả id để duy trì thứ tự ổn định. Câu lệnh SQL sẽ trông như thế này:

-- Sắp xếp theo giá với ID đóng vai trò phân tách khi trùng giá
SELECT * FROM products
WHERE (price, id) < (99.99, 500)
ORDER BY price DESC, id DESC
LIMIT 20;

PostgreSQL xử lý các so sánh giá trị hàng (row-value comparisons) này rất tuyệt vời. MySQL 5.7+ cũng hỗ trợ điều này, mặc dù các phiên bản cũ hơn có thể cần logic OR dài dòng hơn để đạt được kết quả tương tự.

3. Thành thật về giao diện người dùng (UI)

Bạn sẽ cần quản lý kỳ vọng của các bên liên quan. Phân trang dựa trên cursor đồng nghĩa với việc bạn không thể có phần chân trang hiển thị “Trang 1 trên 10.000”. Bạn đang đánh đổi số trang lấy tốc độ. Đối với các ứng dụng hiện đại sử dụng infinite scroll hoặc nút “Tải thêm” (Load More), đây hầu như luôn là bước đi đúng đắn.

Lời kết

Loại bỏ OFFSET là một trong những tối ưu hóa hiệu quả nhất mà tôi từng thực hiện. Nó đã biến các endpoint chậm chạp, khó dự đoán thành các phản hồi đáng tin cậy dưới 30ms, ngay cả khi các bảng của chúng tôi tăng lên hàng chục triệu dòng. Mặc dù việc thiết lập các hợp đồng API (API contracts) tốn thêm một chút công sức, nhưng lợi ích về hiệu năng mang lại là cực kỳ lớn. Nếu bạn đang bắt đầu một dịch vụ mới hôm nay, hãy xây dựng với cursor ngay từ commit đầu tiên.

Share: