Hiệu suất phân trang MongoDB

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ậm

Hã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

Hiệu suất phân trang MongoDB

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 Mongo

db.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ên

Trong 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")
1

Thay 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")
5

Như 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
            }
         }
      }
   }
}
2

Hiệu suất phân trang MongoDB

Nhì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")
5

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)
        }
    })
}
0
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)
        }
    })
}
1

Vì 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")
1

3. 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 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)
        }
    })
}
5

Cá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)
        }
    })
}
6

Ví 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)
        }
    })
}
7

Lư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

Hiệu suất phân trang MongoDB

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)
        }
    })
}
9

Truy 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

MongoDB có hỗ trợ phân trang không?

Phân trang MongoDB cung cấp một cách hiệu quả để lập mô hình dữ liệu giúp phân trang nhanh và hiệu quả, trong MongoDB, chúng ta có thể khám phá một số lượng lớn dữ liệu một cách nhanh chóng và dễ dàng.

Phương pháp nào được coi là hiệu quả nhất để phân trang?

Hầu hết các trang web sử dụng phân trang bù đắp vì tính đơn giản của nó và mức độ trực quan của phân trang đối với người dùng. Để thực hiện phân trang bù đắp, chúng tôi thường sẽ cần hai mẩu thông tin. giới hạn - Số hàng để tìm nạp từ cơ sở dữ liệu. offset - Số hàng cần bỏ qua.

Làm cách nào để tối ưu hóa hiệu suất MongoDB?

Tối ưu hóa hiệu suất truy vấn .
Tạo chỉ mục để hỗ trợ truy vấn
Giới hạn số lượng kết quả truy vấn để giảm nhu cầu mạng
Sử dụng phép chiếu để chỉ trả lại dữ liệu cần thiết
Sử dụng $hint để chọn một chỉ mục cụ thể
Sử dụng toán tử gia tăng để thực hiện thao tác phía máy chủ