Bất cứ khi nào chúng tôi muốn hiển thị lượng lớn dữ liệu cho người dùng, việc phân trang phản hồi cơ sở dữ liệu sẽ là tùy chọn đầu tiên của chúng tôi
Bản thân việc triển khai phân trang có thể dễ dàng, nhưng việc tạo ra một giải pháp hiệu quả thì phức tạp hơn một chút và liên quan đến một số sự đánh đổi
Trong bài viết này, chúng ta sẽ xem xét các tùy chọn phân trang khác nhau trong MongoDB và xem các lợi ích cũng như nhược điểm chính của chúng
2. Phân trang với Bỏ qua và Giới hạn
Cách tiếp cận phổ biến nhất để phân trang là sử dụng
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
1 của Mongo. Nó thực sự đơn giản và dễ dàng để bắt đầu, nhưng có một vấn đề lớn. giá trị bỏ qua càng lớn, yêu cầu nhận được càng chậmHãy tưởng tượng chúng ta đang phục vụ các yêu cầu bằng bộ điều khiển sau
const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
Đây là biểu đồ hiển thị thời lượng yêu cầu cho cơ sở dữ liệu có hơn 5.000.000 bản ghi
Như chúng ta có thể thấy, offset càng lớn thì yêu cầu càng chậm. Ví dụ: so với độ lệch 0, độ lệch 5.000.000 mất nhiều thời gian hơn 50% - biểu đồ này trông rất giống O[n]
Đâu là vấn đề chính xác ở đó?
Để tìm ra nguyên nhân gốc rễ của sự chậm lại rõ ràng, chúng ta có thể sử dụng Mongo
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
2 để hiểu logic chính xác của truy vấn. Hãy thực hiện lệnh sau trong trình bao Mongodb.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
Chúng ta sẽ thấy một phản ứng như thế này
{
"executionStats":{
"executionSuccess":true,
"nReturned":20,
"executionTimeMillis":792,
"totalKeysExamined":0,
"totalDocsExamined":1000020,
"executionStages":{
"stage":"LIMIT",
"nReturned":20,
"executionTimeMillisEstimate":70,
"works":1000022,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":1,
"limitAmount":20,
"inputStage":{
"stage":"SKIP",
"nReturned":20,
"executionTimeMillisEstimate":65,
"works":1000021,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"skipAmount":0,
"inputStage":{
"stage":"COLLSCAN",
"nReturned":1000020,
"executionTimeMillisEstimate":57,
"works":1000021,
"advanced":1000020,
"needTime":1,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"direction":"forward",
"docsExamined":1000020
}
}
}
}
}
Đào sâu hơn, chúng ta cần xem chìa khóa at
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
3. Kiểm tra các khóa cấp gốc db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
4 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
5, chúng ta có thể thấy rằng để phục vụ truy vấn db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
1 với offset 1.000.000, cơ sở dữ liệu cần quét 1.000.020 bản ghi trong khi chỉ trả về 20 bản ghi. Thật là lãng phí tài nguyênTrong một thế giới lý tưởng, bất cứ khi nào chúng tôi quét một bản ghi, chúng tôi cũng muốn trả lại nó [i. e. mối quan hệ giữa
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
4 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
5 phải là 1. 1]. Ghi nhớ điều này sẽ giúp chúng ta viết các truy vấn cơ sở dữ liệu rất hiệu quả3. Phân trang bằng con trỏ
Phân trang bằng con trỏ là một cách thay thế khả dĩ cho cách tiếp cận
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
1Thay vì trả về phần bù số trong dữ liệu phản hồi, phản hồi phải chứa tham chiếu đến mục tiếp theo trong danh sách. Khi thực hiện truy vấn để tìm nạp trang tiếp theo của dữ liệu, chúng ta phải chuyển tham chiếu được trả về trở lại truy vấn thay vì phần bù số
Nếu trường được tham chiếu được lập chỉ mục chính xác, chúng ta sẽ thấy hiệu suất theo thời gian không đổi. Hãy tưởng tượng rằng chúng ta có bộ điều khiển sau
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
5Như chúng ta có thể thấy, thay vì trả về phần bù dưới dạng số, chúng ta trả về ID của mục cuối cùng trong mảng đóng vai trò là chỉ báo cho chúng ta biết chúng ta đã dừng lại ở đâu. Lần tới khi khách hàng chuyển một phần bù cho yêu cầu, chúng tôi sẽ truy vấn nó bằng cách sử dụng
{
"executionStats":{
"executionSuccess":true,
"nReturned":20,
"executionTimeMillis":792,
"totalKeysExamined":0,
"totalDocsExamined":1000020,
"executionStages":{
"stage":"LIMIT",
"nReturned":20,
"executionTimeMillisEstimate":70,
"works":1000022,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":1,
"limitAmount":20,
"inputStage":{
"stage":"SKIP",
"nReturned":20,
"executionTimeMillisEstimate":65,
"works":1000021,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"skipAmount":0,
"inputStage":{
"stage":"COLLSCAN",
"nReturned":1000020,
"executionTimeMillisEstimate":57,
"works":1000021,
"advanced":1000020,
"needTime":1,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"direction":"forward",
"docsExamined":1000020
}
}
}
}
}
2Nhìn vào biểu đồ trên, chúng ta có thể thấy rằng cho dù chúng ta duyệt qua danh sách các bản ghi bao xa, thì hiệu suất vẫn gần như giống nhau - O[1]
Thực thi
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
2 tiết lộ rằng lần này db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
4 chính xác bằng với db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
5const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
0const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
1Vì chúng tôi đang sử dụng toán tử so sánh khi yêu cầu bản ghi, nên chúng tôi luôn phải đảm bảo rằng giá trị offset có thể sắp xếp theo thứ tự và duy nhất
Ví dụ: bạn hoàn toàn có thể sử dụng số nguyên, ID Mongo hoặc UUID Phiên bản 1 có chứa dấu thời gian. Tuy nhiên, chúng tôi không thể sử dụng một giá trị không thể sắp xếp làm phần bù vì chúng tôi không có cách nào để xác định thứ tự chính xác của các bản ghi
Mặt khác, có một nhược điểm lớn đối với cách tiếp cận dựa trên con trỏ. Cụ thể, chúng tôi không thể chuyển đến một trang cụ thể trong danh sách liên hệ như chúng tôi làm với
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
13. Phân trang dựa trên nhóm
Phân trang dựa trên nhóm đưa ra giải pháp cho vấn đề trước đó. một mặt, nó nhanh hơn đáng kể so với cách tiếp cận dựa trên bỏ qua, nhưng nó cho phép chuyển đến một trang cụ thể
Hãy tưởng tượng rằng tất cả các bản ghi trong bộ sưu tập
{
"executionStats":{
"executionSuccess":true,
"nReturned":20,
"executionTimeMillis":792,
"totalKeysExamined":0,
"totalDocsExamined":1000020,
"executionStages":{
"stage":"LIMIT",
"nReturned":20,
"executionTimeMillisEstimate":70,
"works":1000022,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":1,
"limitAmount":20,
"inputStage":{
"stage":"SKIP",
"nReturned":20,
"executionTimeMillisEstimate":65,
"works":1000021,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"skipAmount":0,
"inputStage":{
"stage":"COLLSCAN",
"nReturned":1000020,
"executionTimeMillisEstimate":57,
"works":1000021,
"advanced":1000020,
"needTime":1,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"direction":"forward",
"docsExamined":1000020
}
}
}
}
}
8 đều ở dạng sauconst db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
5Cách tiếp cận dựa trên nhóm cung cấp một giải pháp thay thế bằng cách nhóm nhiều bản ghi vào một nhóm duy nhất
const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
6Ví dụ: nếu chúng tôi quyết định có 5.000 phần tử trong một nhóm, thì thay vì có 5.000.000 bản ghi trong cơ sở dữ liệu, chúng tôi sẽ chỉ có 1.000 bản ghi
Bộ điều khiển để duyệt qua các bản ghi trông như thế này
const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
7Lưu ý rằng một lần nữa, chúng tôi đang sử dụng
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0, nhưng lần này nó hiệu quả hơn nhiều. Ngay cả khi chúng tôi ở cuối bản ghi, chúng tôi chỉ cần quét tối đa 1.000 bản ghi so với tình huống trong Đoạn 2, nơi chúng tôi phải quét gần 5.000.000 phần tửNhìn vào biểu đồ thời lượng yêu cầu, chúng ta có thể thấy rằng nó trông giống như một biểu đồ tuyến tính, nghĩa là bất kể chúng ta đang ở trang nào, độ phức tạp của phương pháp này sẽ gần như giống nhau
Thật không may, không có viên đạn bạc nào trong trò chơi này và phương pháp này có nhược điểm của nó. Điều quan trọng nhất có lẽ là việc chèn, xóa hoặc cập nhật một bản ghi riêng lẻ bên trong nhóm sẽ phức tạp hơn một chút
Ví dụ: để chèn bản ghi vào nhóm, chúng ta phải thực hiện truy vấn này
const db = require['../../db'];
module.exports = async [req, res] => {
const { limit, offset } = req.query;
const units = await db.collection['units']
.find[{}]
.skip[Number[offset]]
.limit[Number[limit]]
.toArray[];
const documentsCount = await db.collection['units'].countDocuments[{}];
res.send[{
data: [],
pagination: {
hasNextPage: documentsCount >= Number[offset] + Number[limit],
nextOffset: Number[offset] + Number[limit]
}
}]
}
9Truy vấn tra cứu một bản ghi trong bộ sưu tập
{
"executionStats":{
"executionSuccess":true,
"nReturned":20,
"executionTimeMillis":792,
"totalKeysExamined":0,
"totalDocsExamined":1000020,
"executionStages":{
"stage":"LIMIT",
"nReturned":20,
"executionTimeMillisEstimate":70,
"works":1000022,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":1,
"limitAmount":20,
"inputStage":{
"stage":"SKIP",
"nReturned":20,
"executionTimeMillisEstimate":65,
"works":1000021,
"advanced":20,
"needTime":1000001,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"skipAmount":0,
"inputStage":{
"stage":"COLLSCAN",
"nReturned":1000020,
"executionTimeMillisEstimate":57,
"works":1000021,
"advanced":1000020,
"needTime":1,
"needYield":0,
"saveState":1000,
"restoreState":1000,
"isEOF":0,
"direction":"forward",
"docsExamined":1000020
}
}
}
}
}
8 có ít mục hơn ngưỡng 5.000 và sau đó đẩy mục đó vào đóVì kích thước tài liệu tối đa trong Mongo có thể là 16MB nên chúng ta phải chú ý không vượt quá giới hạn đó. Do đó, chúng ta nên cẩn thận khi lưu trữ các tài liệu lớn bên trong thùng
4. Phần kết luận
Trả lại phản hồi được phân trang là bắt buộc đối với hầu hết mọi ứng dụng hướng tới máy khách
Mặc dù việc phân trang dữ liệu phản hồi với Mongo's
db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 và db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
1 tương đối đơn giản, nhưng db.units.find[{}].limit[20].skip[1000000].explain["executionStats"]
0 khiến các truy vấn có độ phức tạp là O[n]Để tạo giải pháp phân trang theo thời gian không đổi hiệu suất, chúng ta cần xem xét sử dụng cách tiếp cận dựa trên con trỏ hoặc bắt đầu sử dụng bộ chứa để lưu trữ dữ liệu