Các Chiến Lược Phân Trang Hiệu Quả cho API .NET

Khi bạn thiết kế các API trả về tập dữ liệu lớn, phân trang không phải là tùy chọn. Không có nó, bạn có nguy cơ làm quá tải cơ sở dữ liệu, mạng và người dùng. Phân trang là nghệ thuật chia nhỏ dữ liệu thành các phần có thể quản lý được, và giống như hầu hết mọi thứ trong kiến trúc phần mềm, không có một cách duy nhất tốt nhất để thực hiện. Mỗi chiến lược đều có sự đánh đổi ảnh hưởng đến hiệu suất, tính nhất quán và trải nghiệm nhà phát triển.

Trong bài viết này, tôi sẽ hướng dẫn bạn qua sáu chiến lược phân trang phổ biến: Dựa trên Offset, Dựa trên Con trỏ, Dựa trên Khóa, Dựa trên Trang, Dựa trên Thời gian và Các phương pháp Kết hợp. Với mỗi chiến lược, tôi sẽ giải thích cách hoạt động, cho bạn xem ví dụ C# và nêu bật ưu điểm và nhược điểm. Trong suốt bài viết, tôi sẽ sử dụng sơ đồ và phép loại suy để giúp các khái niệm dễ hiểu hơn.

Phân Trang Dựa Trên Offset

Khi các nhà phát triển lần đầu triển khai phân trang, hầu như không thể tránh khỏi việc sử dụng phương pháp dựa trên offset. Nó trực quan, đơn giản để hiểu và ánh xạ trực tiếp đến khái niệm quen thuộc về số trang.

Phân trang dựa trên offset hoạt động bằng cách sử dụng hai tham số chính: một offset (hoặc bỏ qua) và một giới hạn (hoặc lấy/kích thước trang). Hãy nghĩ về nó như đọc một cuốn sách: để đến trang ba với 10 mục mỗi trang, bạn nói với cơ sở dữ liệu “bỏ qua 20 mục đầu tiên, sau đó cho tôi 10 mục tiếp theo.”

Máy khách yêu cầu một phần dữ liệu cụ thể bằng cách chỉ định bao nhiêu bản ghi cần bỏ qua từ đầu và bao nhiêu bản ghi cần truy xuất.

  • limit=10&offset=0: Lấy bản ghi 1-10 (Trang 1)
  • limit=10&offset=10: Lấy bản ghi 11-20 (Trang 2)
  • limit=10&offset=20: Lấy bản ghi 21-30 (Trang 3)

C#


[HttpGet]
public async Task<IActionResult> GetProducts(
[FromQuery] int pageNumber = 1,
[FromQuery] int pageSize = 10)
{
// Basic validation
if (pageNumber < 1) pageNumber = 1;
if (pageSize < 1) pageSize = 10;

// Enforce a max page size
const int maxPageSize = 50;
pageSize = pageSize > maxPageSize? maxPageSize : pageSize;

var totalRecords = await _context.Products.CountAsync();

var products = await _context.Products
.OrderBy(p => p.Id)
.Skip((pageNumber - 1) * pageSize)
.Take(pageSize)
.ToListAsync();

var response = new
{
TotalRecords = totalRecords,
TotalPages = (int)Math.Ceiling(totalRecords / (double)pageSize),
PageNumber = pageNumber,
PageSize = pageSize,
Data = products
};

return Ok(response);
}

Phương thức nhân chỉ số trang với kích thước trang để xác định bao nhiêu hàng cần bỏ qua trước khi lấy phần được yêu cầu. Luôn thêm thứ tự xác định trước khi bạn bỏ qua; nếu không, cơ sở dữ liệu có thể tự do xáo trộn kết quả giữa các lần gọi.

Cái Bẫy Hiệu Suất Của Offset Lớn

Đối với tập dữ liệu nhỏ, phương pháp này hoạt động hoàn hảo. Tuy nhiên, khi bảng của bạn phát triển và người dùng điều hướng đến các trang sâu hơn, một quả bom hiệu suất ẩn sẽ phát nổ. Cơ sở dữ liệu, để thực hiện một yêu cầu với offset lớn, vẫn phải quét và sắp xếp các bản ghi, và chỉ sau đó loại bỏ những bản ghi đã bị bỏ qua.

Điều này tạo ra một sự ghép nối nguy hiểm, ẩn trong hệ thống của bạn. Hiệu suất của điểm cuối API của bạn không ổn định và nó tỷ lệ nghịch với tổng khối lượng dữ liệu. Khi cơ sở dữ liệu của bạn phát triển, thời gian phản hồi để truy xuất trang 1.000 âm thầm giảm, dẫn đến “sự leo thang hiệu suất” có thể đột ngột vượt qua ngưỡng quan trọng và gây ra sự cố toàn hệ thống. Hiệu suất API của bạn trở nên không thể đoán trước được gắn liền với số hàng trong bảng của bạn.

Vấn Đề Nhất Quán Dữ Liệu

Hiệu suất không phải là vấn đề duy nhất. Trong một hệ thống động với các ghi chép thường xuyên, phân trang offset về cơ bản là không ổn định. Hãy xem xét kịch bản này:

  1. Người dùng yêu cầu trang 1 (offset=0, limit=10) và thấy các mục 1 đến 10.
  2. Trước khi họ yêu cầu trang tiếp theo, một mục mới được thêm vào cơ sở dữ liệu ngay từ đầu.
  3. Người dùng yêu cầu trang 2 (offset=10, limit=10).

Vì mục mới, mục trước đây là 10 bây giờ là mục 11. Yêu cầu trang thứ hai sẽ trả về các mục 11 đến 20. Người dùng sẽ thấy mục 10 (bây giờ ở vị trí 11) hai lần và họ đã hoàn toàn bỏ lỡ mục 11 ban đầu. Điều ngược lại xảy ra nếu một mục bị xóa. “Cửa sổ” dữ liệu thay đổi này làm cho phân trang offset không đáng tin cậy cho các tập dữ liệu thay đổi thường xuyên.

Làm Rõ Nhanh: Phân Trang Dựa Trên Trang vs Dựa Trên Offset

Bạn có thể nhận thấy rằng ví dụ C# của tôi sử dụng pageNumber và pageSize, thường được gọi là phân trang “dựa trên trang”. Điều quan trọng là phải hiểu rằng đây gần như luôn luôn chỉ là một sự trừu tượng thân thiện với người dùng được xây dựng trên phân trang dựa trên offset.

Nội bộ, máy chủ API chỉ đơn giản chuyển đổi số trang và kích thước thành một offset bằng công thức đơn giản: offset = (pageNumber - 1) * pageSize.

Sự phân biệt này là quan trọng. Trong khi cung cấp pageNumber trực quan hơn cho người tiêu dùng API, nó không thay đổi cơ chế cơ bản. API của bạn vẫn sẽ chịu sự suy giảm hiệu suất chính xác và các vấn đề nhất quán dữ liệu vốn có của phương pháp offset. Sự tiện lợi cho máy khách không khắc phục một cách kỳ diệu sự kém hiệu quả trong cơ sở dữ liệu.

Phân Trang Khóa và Con Trỏ: Một Giải Pháp Hiệu Suất Cao

Để giải quyết các vấn đề sâu xa của phân trang offset, chúng ta cần một cách tiếp cận khác. Phân trang khóa, còn được gọi là “phương pháp tìm kiếm”, là mẫu hiệu suất cao được sử dụng bởi các hệ thống quy mô lớn như nguồn cấp dữ liệu mạng xã hội để xử lý các tập dữ liệu động lớn.

Các thuật ngữ “khóa” và “con trỏ” thường được sử dụng thay thế cho nhau, có thể gây nhầm lẫn. Đối với các nhà thiết kế API, hữu ích khi vẽ một sự phân biệt rõ ràng:

  • Phân Trang Khóa (Kỹ Thuật): Đây là chiến lược ở cấp cơ sở dữ liệu. Thay vì bỏ qua các hàng, nó sử dụng một mệnh đề WHERE để lọc các bản ghi đến sau mục cuối cùng đã thấy trên trang trước. Điều này đòi hỏi một thứ tự sắp xếp ổn định và duy nhất. “Khóa” đề cập đến tập hợp các giá trị cột từ bản ghi cuối cùng được sử dụng để lọc (ví dụ: Id, hoặc kết hợp CreatedAt và Id).
  • Phân Trang Con Trỏ (Triển Khai): Đề cập đến mã thông báo mà API cung cấp cho máy khách để đánh dấu vị trí của nó. “Con trỏ” là giá trị máy khách gửi lại để lấy trang tiếp theo. Con trỏ này có thể trong suốt (ví dụ: ID hoặc dấu thời gian thô) hoặc mờ (một chuỗi được mã hóa, vô nghĩa che giấu chi tiết triển khai).

Tóm lại, bạn sử dụng kỹ thuật khóa trong truy vấn cơ sở dữ liệu của mình, và bạn tiết lộ một con trỏ trong API của mình.

Triển Khai Phân Trang Khóa Trong C#

Sức mạnh của phân trang khóa nằm ở việc thay đổi truy vấn từ “bỏ qua N hàng” thành “lấy các hàng sau hàng cụ thể này”. Thay vì .Skip(), chúng ta sử dụng .Where().

Giả sử sản phẩm cuối cùng trên trang trước có Id là 100. Truy vấn cho trang tiếp theo trở nên cực kỳ đơn giản và hiệu quả:

C#


var lastSeenId = 100; // Value from the previous page's cursor
var pageSize = 10;

var products = await _context.Products
.OrderBy(p => p.Id)
.Where(p => p.Id > lastSeenId) // The "seek" condition
.Take(pageSize)
.ToListAsync();

Tại sao điều này nhanh hơn nhiều? Bởi vì cơ sở dữ liệu có thể sử dụng chỉ mục trên cột Id để nhảy trực tiếp đến điểm bắt đầu. Nó không cần phải quét và loại bỏ hàng ngàn hàng từ đầu bảng. Hiệu suất truy vấn vẫn nhất quán nhanh, cho dù bạn đang ở trang 2 hay trang 20.000.

Xử Lý Khóa Sắp Xếp Không Duy Nhất

Một yêu cầu quan trọng cho phân trang khóa là khóa sắp xếp phải duy nhất. Nếu bạn đang sắp xếp theo dấu thời gian CreatedAt, nơi nhiều bản ghi có thể chia sẻ cùng một giá trị chính xác? Nếu bạn chỉ lọc bằng WHERE CreatedAt > lastTimestamp, bạn có thể bỏ lỡ các bản ghi có cùng dấu thời gian.

Giải pháp là tạo một khóa tổng hợp bằng cách thêm một cột ngắt kết nối duy nhất vào các mệnh đề ORDER BY và WHERE. Id là ứng cử viên hoàn hảo.

Truy vấn trở thành:

C#


var lastTimestamp =...; // From the cursor
var lastId =...; // From the cursor

var products = await _context.Products
.OrderBy(p => p.CreatedAt)
.ThenBy(p => p.Id)
.Where(p => p.CreatedAt > lastTimestamp ||
(p.CreatedAt == lastTimestamp && p.Id > lastId))
.Take(pageSize)
.ToListAsync();

Điều này đảm bảo một thứ tự hoàn toàn ổn định và duy nhất, đảm bảo không có dữ liệu nào bị bỏ lỡ.

Sức Mạnh Của Con Trỏ Mờ

Trong khi bạn có thể chỉ cần trả về lastId cho máy khách như con trỏ, một cách tiếp cận mạnh mẽ hơn là sử dụng một con trỏ mờ. Đây là một chuỗi, thường được mã hóa Base64, chứa tất cả thông tin máy chủ cần để truy xuất trang tiếp theo nhưng vô nghĩa với máy khách.

Lợi ích chính là tách rời. Máy khách không còn biết về lược đồ cơ sở dữ liệu nội bộ hoặc logic sắp xếp của bạn. Bạn có thể thay đổi chiến lược phân trang từ sắp xếp theo Id sang sắp xếp theo CreatedAtId mà không làm hỏng bất kỳ máy khách nào. Hợp đồng của máy khách đơn giản là: “Đây là con trỏ bạn đã cho tôi lần trước; cho tôi trang tiếp theo.”

Đây là triển khai C# đơn giản để tạo và giải mã một con trỏ mờ:

C#


public record ProductCursor(DateTime CreatedAt, int Id);

public static class CursorEncoder
{
public static string Encode(ProductCursor cursor)
{
var json = System.Text.Json.JsonSerializer.Serialize(cursor);
var bytes = System.Text.Encoding.UTF8.GetBytes(json);
return Convert.ToBase64String(bytes);
}

public static ProductCursor Decode(string encodedCursor)
{
var bytes = Convert.FromBase64String(encodedCursor);
var json = System.Text.Encoding.UTF8.GetString(bytes);
return System.Text.Json.JsonSerializer.Deserialize<ProductCursor>(json);
}
}

API của bạn sẽ mã hóa CreatedAtId của mục cuối cùng thành một chuỗi và trả về nó như nextCursor. Máy khách sau đó chuyển chuỗi này lại trong yêu cầu tiếp theo.

Tương Tác Máy Khách-Máy Chủ Với Con Trỏ

Sơ đồ này minh họa luồng sử dụng con trỏ mờ. Lưu ý mẹo lấy limit + 1 mục để xác định xem có trang tiếp theo hay không mà không cần truy vấn COUNT riêng biệt.

Sự Đánh Đổi: Không “Nhảy Đến Trang”

Nhược điểm chính của phân trang khóa và con trỏ là nó không hỗ trợ truy cập ngẫu nhiên, tức là nhảy trực tiếp đến một số trang cụ thể. Thiết kế vốn đã tuần tự, được xây dựng cho điều hướng “tiếp theo” và “trước”. Điều này làm cho nó hoàn hảo cho giao diện người dùng cuộn vô tận nhưng không phù hợp cho các giao diện yêu cầu liên kết số trang truyền thống. Đây là một ví dụ điển hình về cách lựa chọn kiến trúc backend trực tiếp tác động và hạn chế trải nghiệm người dùng frontend.

Phân Trang Kết Hợp

Trong thế giới thực, yêu cầu hiếm khi đơn giản. Đôi khi, một chiến lược phân trang duy nhất là không đủ. Phân trang kết hợp liên quan đến việc kết hợp thông minh nhiều kỹ thuật để đáp ứng các nhu cầu phức tạp, cung cấp sự linh hoạt với cái giá là tăng độ phức tạp.

Phân Trang Khóa Với Lọc

Một yêu cầu phổ biến là phân trang qua một tập hợp con dữ liệu. Ví dụ, người dùng có thể muốn xem tất cả đơn đặt hàng của họ được đặt trong một phạm vi ngày cụ thể. Bạn có thể kết hợp phân trang khóa với các bộ lọc khác. Khóa cung cấp lặp hiệu quả trong tập kết quả đã lọc.

Yêu cầu API có thể trông giống như: GET /orders?cursor=abc&start_time=2023-01+01&end_time=2023-01-31.

Truy vấn phía máy chủ sẽ áp dụng cả hai bộ lọc:

C#


var ordersQuery = _context.Orders
.Where(o => o.OrderDate >= startTime && o.OrderDate <= endTime);

// Apply cursor logic on top of the filtered query
if (cursor!= null)
{
ordersQuery = ordersQuery.Where(o => o.Id > cursor.LastId);
}

var orders = await ordersQuery
.OrderBy(o => o.Id)
.Take(pageSize)
.ToListAsync();

Cuộn Vô Tận Với Nhảy-Đến-Trang

Giao diện người dùng hiện đại thường trình bày một yêu cầu đầy thách thức: người dùng muốn trải nghiệm mượt mà, hấp dẫn của cuộn vô tận để duyệt ngẫu nhiên, nhưng cũng có khả năng nhảy trực tiếp đến một trang cụ thể để điều hướng mục tiêu. Điều này đặt điểm mạnh của phân trang con trỏ (cuộn vô tận) trực tiếp chống lại tính năng chính của phân trang offset (truy cập ngẫu nhiên).

Một giải pháp kết hợp thực tế có thể cung cấp tốt nhất của cả hai thế giới bằng cách đưa ra một sự đánh đổi kiến trúc có ý thức:

  1. Sử Dụng Phân Trang Con Trỏ Cho Điều Hướng Tuần Tự: Tất cả các yêu cầu trang “tiếp theo” và “trước” nên sử dụng con trỏ. Điều này đảm bảo hành động người dùng phổ biến nhất, cuộn, có hiệu suất cao, khả năng mở rộng và nhất quán.
  2. Sử Dụng Phân Trang Offset Cho Truy Cập Ngẫu Nhiên: Khi người dùng nhấp rõ ràng để “nhảy đến trang 50”, máy khách thực hiện một yêu cầu duy nhất sử dụng phân trang dựa trên offset (pageNumber=50). Chúng ta thừa nhận rằng yêu cầu ban đầu này sẽ kém hiệu suất hơn, nhưng chúng ta chấp nhận điều này như một chi phí một lần cho một hành động ít thường xuyên hơn.
  3. Trả Về Con Trỏ Với Phản Hồi Offset: Đây là chìa khóa cho cách tiếp cận kết hợp. Phản hồi cho yêu cầu dựa trên offset cho trang 50 phải bao gồm không chỉ dữ liệu cho trang đó mà còn cả con trỏ cho trang tiếp theo (trang 51) và trang trước (trang 49).

Với những con trỏ này trong tay, máy khách có thể chuyển đổi liền mạch trở lại phương pháp dựa trên con trỏ hiệu quả cao cho tất cả các cuộn tiếp theo. Mô hình kết hợp này tối ưu hóa cho trường hợp phổ biến (cuộn) trong khi vẫn cho phép trường hợp ít thường xuyên hơn (nhảy), đại diện cho một sự thỏa hiệp trưởng thành giữa hiệu suất lý tưởng và trải nghiệm người dùng lý tưởng.

Hướng Dẫn Của Nhà Phát Triển Về Phân Trang

Cô đọng mọi thứ chúng ta đã thảo luận, đây là danh sách kiểm tra có thể hành động và tóm tắt các sự đánh đổi để hướng dẫn triển khai của bạn.

  • Luôn Áp Đặt Kích Thước Trang Tối Đa: Không bao giờ hoàn toàn tin tưởng máy khách cung cấp kích thước trang hợp lý. Triển khai mặc định phía máy chủ và giới hạn tối đa cứng để ngăn người dùng đơn lẻ yêu cầu hàng triệu bản ghi và gây ra từ chối dịch vụ, dù vô tình hay ác ý.
  • Thứ Tự Ổn Định Là Không Thể Thương Lượng: Mọi truy vấn phân trang phải có mệnh đề OrderBy. Không có nó, cơ sở dữ liệu không cung cấp bảo đảm về thứ tự kết quả giữa các yêu cầu, làm cho phân trang vô nghĩa. Đối với phân trang khóa/con trỏ, thứ tự này phải là duy nhất và được hỗ trợ bởi chỉ mục cơ sở dữ liệu.
  • Thiết Kế Phản Hồi Phân Trang DTO Phong Phú: Đừng chỉ trả về một mảng dữ liệu thô. Đối tượng phản hồi của bạn nên bao gồm siêu dữ liệu cho phép máy khách xây dựng giao diện người dùng phong phú, trực quan. Bao gồm các thuộc tính như totalRecords, totalPages (cho offset), hasNextPage, hasPreviousPagenextCursor.
  • Chỉ Mục Cơ Sở Dữ Liệu Của Bạn Cho Hiệu Suất: Hiệu suất phân trang của bạn liên quan trực tiếp đến chiến lược chỉ mục cơ sở dữ liệu của bạn. Đảm bảo rằng các cột được sử dụng trong mệnh đề ORDER BYWHERE của bạn được lập chỉ mục đúng cách. Đối với khóa tổng hợp (ví dụ: (CreatedAt,Id)), tạo chỉ mục tổng hợp trong cơ sở dữ liệu của bạn để có hiệu suất tối đa.

So Sánh Chiến Lược

Bảng này tóm tắt các sự đánh đổi cốt lõi giữa các chiến lược phân trang khác nhau, cung cấp tài liệu tham khảo nhanh cho quyết định kiến trúc của bạn.

Chiến Lược Cách Hoạt Động Hiệu Suất Tính Nhất Quán Dữ Liệu Trường Hợp Sử Dụng Nhảy Đến Trang?
Dựa Trên Offset SKIP/TAKE. Bỏ qua N hàng. Kém trên tập dữ liệu lớn (giảm với offset). Không ổn định với ghi thường xuyên (mục bị bỏ lỡ/trùng lặp). Tập dữ liệu nhỏ, tĩnh; giao diện quản trị. Có (Tính năng Chính)
Dựa Trên Trang page/pageSize. Trừu tượng trên Offset. Giống như Dựa trên Offset. Giống như Dựa trên Offset. Giao diện người dùng nơi số trang trực quan.
Dựa Trên Khóa WHERE id > lastId. Tìm đến một khóa. Xuất sắc & nhất quán; tận dụng chỉ mục. Ổn định. Không bị ảnh hưởng bởi ghi vào các trang khác. Tập dữ liệu lớn, cuộn vô tận, môi trường ghi cao. Không
Dựa Trên Con Trỏ Keyset với mã thông báo mờ. Giống như Dựa trên Khóa. Giống như Dựa trên Khóa. API công khai nơi tách rời máy khách quan trọng. Không
Dựa Trên Thời Gian Keyset sử dụng khóa dấu thời gian. Xuất sắc, nếu dấu thời gian được lập chỉ mục. Ổn định, nhưng yêu cầu ngắt kết nối duy nhất. Dữ liệu thời gian (nguồn cấp, nhật ký, sự kiện). Không
Kết Hợp Kết hợp nhiều chiến lược. Thay đổi. Có thể tối ưu hóa cho đường dẫn phổ biến. Thay đổi. Có thể phức tạp để quản lý. Giao diện người dùng phức tạp cần cả cuộn vô tận và nhảy trang. Có (với sự đánh đổi)

Kết Luận

Chúng ta đã thấy rằng phân trang không chỉ đơn giản là một tính năng để chia nhỏ dữ liệu. Nó là một thành phần cơ bản của kiến trúc API và chiến lược truy cập dữ liệu của nó. Sự lựa chọn giữa offset và con trỏ, trong suốt và mờ, là một quyết định trực tiếp tác động đến khả năng mở rộng, độ tin cậy và trải nghiệm người dùng bạn có thể cung cấp.

Do đó, tôi khuyến khích bạn đối xử với phân trang như một mối quan tâm thiết kế hạng nhất từ ngày đầu tiên. Trước khi viết một dòng mã, hãy suy nghĩ về dữ liệu của bạn. Nó sẽ lớn đến mức nào? Nó sẽ thay đổi thường xuyên như thế nào? Loại trải nghiệm điều hướng nào người dùng của bạn cần?

Chọn chiến lược phân trang phù hợp sớm là một hành động thiết kế kiến trúc có chủ ý. Nó sẽ cứu bạn khỏi việc tái cấu trúc đau đớn, các vấn đề hiệu suất không thể đoán trước và sự cố sản xuất vào đêm khuya. Bằng cách đưa ra lựa chọn này một cách có ý thức, bạn xây dựng các API không chỉ chức năng cho ngày hôm nay mà thực sự được xây dựng để tồn tại lâu dài.

Chỉ mục