Trước giờ làm phân trang sai bét mà nó còn cãi - Mongodb

Nội dung bài viết

Trước năm 2020 có rất nhiều REST API vẫn sử dụng limit và offset hay nếu sử dụng Mongodb thì là skip(). Không hẳn là sai, nhưng thực sự không đúng đối với một công ty có lượng dữ liệu lớn, và cũng không đúng nếu bạn là một dân back-end thứ thiệt. 


Hãy tượng tượng bạn là một nhân tố làm việc ở Viber Messenger và làm ở vị trí viết API. Bạn biết đấy, mỗi ngày lượng request vào để lấy tin nhắn giữa 1 và 1, hay 1 và N thì con số khó có thể thống kê được. Giả sử trong trường hợp này chúng ta bàn tới cuộc nói chuyện giữa A và B có 1 triệu records thì nếu là bạn, bạn sẽ viết REST API thế nào?


Có nhiều điều sai với việc triển khai REST API theo cách cũ


Chắc hăn là ai cũng tặc lưỡi rằng, đương nhiên là phân trang thôi. Chứ kéo hết 1 triệu records thì chết à, chết cả server cũng như thằng Clients cũng chết. Đúng, bạn nói điều mà ai cũng biết. Và rồi bạn làm thế nào??? Chắc hẳn sẽ là như vậy.


ENDPOINT -- https://api.message.com/v1/conversationAAndB
SUPPORTED PARAMETERS -- PAGE, LIMIT
DEFAULT PARAMETERS VALUE -- PAGE=1, LIMIT=100
DESCRIPTION -- Fetch conversationAAndB


Xem đoạn code trên kia thì rõ ràng, bạn đã có sự chuẩn bị kỹ, đó là mỗi lần A or B request thì chỉ lấy phạm vi từ 1 đến 100 records cho mỗi lần mà thôi. Giả sử lần tiếp theo bạn sẽ set PAGE = 2, thì lúc đó bạn sẽ lấy từ 101 - 200 records mà thôi. 


Cách tiếp cận này để tìm nạp danh sách các records từ cơ sở dữ liệu hoạt động rất tốt. Thậm chí có thể linh hoạt với tham số LIMIT nữa. Nói chung là tốt. Nếu như đến đây bạn đã hiểu thì bạn đã biết phân trang là gì? Và giờ chúng ta xem Mongodb sẽ thực hiện nó như thế nào?


Phân trang sử dụng Mongodb


Một cách tiếp cận rất phổ biến để thêm hỗ trợ phân trang cho một API hiện có là sử dụng toán tử skip và limit trong cơ sở dữ liệu. Đây là cách nó có thể được thực hiện trong MongoDB.


db.messages
  .find({})
  .skip(0)
  .limit(100); // 1 - returns messages from 0 to 100

db.messages
  .find({})
  .skip(100)
  .limit(100); // 2 - returns messages from 100 to 200

db.messages
  .find({})
  .skip(200)
  .limit(100); // 3 - returns messages from 200 to 300


Đây là một lý do dễ dàng để thực hiện và tôi nghĩ đó là lý do tại sao nó vẫn là cách phổ biến để thực hiện phân trang cho đến tận bây giờ (2020). Như tôi nói đầu bài viết, nếu như tổng số records không lớn như vậy thì cách tiếp cận này hoàn toàn ổn và chấp nhận là tốt. Nhưng nếu bạn đang làm việc trên quy mô lớn hoặc bạn có hàng nghìn khách hàng sử dụng API phân trang của mình, thì cách tiếp cận này có thể làm chậm hoặc chặn các truy vấn / hoạt động cơ sở dữ liệu khác của bạn. Để hiểu lý do vì sao tôi lại nói như vậy thì bạn phải hiểu cơ chế hoạt động của hai toán tử skip() và limit() ở cấp CSDL.

skip vs limit hiểu cách hoạt động

Trước tiên xem xét một ví dụ sau:

db.messages
  .find({})
  .skip(0)
  .limit(100); // 1 - returns messages from 0 to 100
  

Ở đây phân tích 3 điều sau: 

  • find({}) - Điều kiện tìm thoả mãn yêu cầu 
  • skip(0) - Từ các records được tìm ra thì bỏ qua 0 record.
  •  limit(100) - Cuối cùng là trả về 100 records.


Khi ba điều kiện trên được kết hợp, cơ sở dữ liệu sẽ đưa ra quyết định thông minh là chỉ tìm nạp 100 records từ bộ nhớ. Bỏ qua 0 records và trả lại 100 records đã tìm nạp trở lại. Hãy xem xét một truy vấn khác. Từ từ phân tích, rồi sẽ hấp dẫn:


db.messages
  .find({})
  .skip(500)
  .limit(100); // 3 - returns messages from 200 to 300


Ở đoạn code này tiếp tục phân tích: 

  • find({}) - Điều kiện tìm thoả mãn yêu cầu 
  • skip(500) - Tù các records được tìm ra thì bỏ qua 500 record. 
  • limit(100) - Cuối cùng là trả về 100 records. 


Đến đây dừng lại, có bạn nào nghĩ rằng nó cũng chỉ tìm 100 records như ví dụ trên không? SAI RỒI, nếu bạn nghĩ như vậy là sai. Thực chất câu query trên đã tìm đến 600 records. Bởi vì nó phải bỏ qua 500 records và sau đó nó cần gửi 100 records từ các records được tìm nạp còn lại nên nó cần tìm nạp tổng cộng 500 + 100 = 600 records. Đến đây bạn có thấy vấn đề? 


Mặc dù chúng tôi chỉ yêu cầu 100 records tin nhắn trong phản hồi. Cơ sở dữ liệu đã kiểm tra 600 tin nhắn. Đó là tôi ví dụ 500 skip thôi đó, thực tế con số trên là khủng nếu bạn đã từng làm với một hệ thống lớn. Tất nhiên tôi nói đến trường hợp này là đã có index rồi đấy, chứ không phải là không. Đừng tưởng bở.


Phân trang dữ liệu với hiệu suất cao


Anh nói cho sướng rồi cuối cùng anh chả có giải pháp gì thì sao tôi nghe được. Đương nhiên tôi chả nói suông, tôi có một cách giải quyết mà tôi đã được học từ một người bạn làm back-end ở SIN. Nếu bạn có thời gian hãy tham khảo bài viết của anh ấy.


Anh ấy đã làm rất tốt việc này, và từ đó giúp tôi cải thiện được hiệu suất tốt hơn hẳn. Sau một thời gian sử dụng, thì thật sử có sự cải thiện rõ rệt, như trên thì nếu như dữ liệu ít thì bạn sẽ không thấy sự khác biệt ở đây, nhưng nếu như trong tay bạn là một hệ thống có dữ liệu lơn thì con số chênh lệnh đáng kể. Vậy làm thế nào? Đó là Phân trang sử dụng con trỏ, vậy con trỏ là gì? Xin mới đi tiếp


Phân trang sử dụng cursor


Một cách tiếp cận tốt hơn để phân trang được thực hiện bằng cách sử dụng giá trị con trỏ. Trong triển khai cách mới này, không có khái niệm về pages. Khách hàng API cung cấp một giá trị con trỏ và dựa trên giá trị đó, API được phân trang trả về lượng tin nhắn cụ thể. 


Mục đích của việc làm này chính là hạn chế hay nói cách khác là loại bỏ toán tử skip() mà trên bài viết đã có nói. Con trỏ là gì? Con trỏ tương tự như một điểm đánh dấu cho cơ sở dữ liệu cho biết rằng dữ liệu tiếp theo nên lấy là từ đâu. Trước khi đi vào ví dụ thì để tôi giải thích thêm, ví dụ bạn đã request tới _id: 1000 rồi. Thay vì cách cũ bạn sẽ gọi ENDPOINT tiếp theo với limit và skip.


 Nhưng cách mới này, thì _id: 1000 chính là cursor. Hay chính xác là next_cursor. Một lần nữa, để hiểu tại sao đây là một cách hiệu quả, trước tiên chúng ta phải hiểu cách nó được triển khai và cách nó hoạt động ở cấp cơ sở dữ liệu.


let { next_cursor = 1, limit = 100 } = req.params;

const messages = await db.messages
  .find({ _id: { $gte: next_cursor } }) // tìm những records có _id lớn hơn con trở này 
  .limit(limit + 1);

const next_cursor = messages[limit]._id; // xác định lần sau mà gọi nữa thi dựa vào đây mà lấy

messages.length = limit;

res.send({
  data: messages,
  limit,
  next_cursor
});


Ở đây nhấn mạnh về lý thuyết, còn trong khi triển khai thì các bạn phải tự phân tích thêm một số trường hợp nữa.... Giờ nhìn lại ví dụ trên:


db.messages
  .find({})
  .skip(0)
  .limit(100); // 1 - returns messages from 0 to 100

db.messages
  .find({})
  .skip(100)
  .limit(100); // 2 - returns messages from 100 to 200

db.messages
  .find({})
  .skip(200)
  .limit(100); // 3 - returns messages from 200 to 300

Giờ đây tôi có thể viết lại rằng:

db.messages
  .find({})
  .limit(100); // 1 - returns messages from 0 to 100

db.messages
  .find({ _id: { $gte: 101 } })
  .limit(100); // 2 - returns messages from 100 to 200

db.messages
  .find({ _id: { $gte: 201 } })
  .limit(100); // 3 - returns messages from 200 to 300

Câu hỏi cuối bài viết


Quá tuyệt vời đúng không? Hãy đành thời gian phân tích thêm để thấy sự hiệu quả trong việc triển khai mới này. Và có một điều lưu ý, đó là. Rất nhiều lập trình viện đang theo dõi API của bạn chính vì thế bạn không muốn những thành phần đó biết bạn đang sử dụng _id để làm con trỏ. 


Vì vậy, bạn có cách nào không? Xin cho tôi một comments, nếu bế tắc thì tôi sẽ giúp bạn tiếp...

Có thể bạn đã bị missing