Aggregation (P2/3): Server sắp xếp lại, index ở đầu, giới hạn 100 MB

10 phút đọcSeries: MongoDB: từ gốc đến internals

Ở phần trước: chỉ đoạn đầu pipeline dùng được index, vì từ chỗ dữ liệu đã bị biến đổi, server không còn mục lục nào. Stage blocking phải gom đủ dữ liệu mới đi tiếp, và đó là phần đắt.

Server tự sắp xếp lại pipeline

Ý chính

Trước khi chạy, optimizer viết lại pipeline theo một số luật cố định [tài liệu]. Mục tiêu luôn là: lọc càng sớm càng tốt, và đưa được $match/$sort lên đầu để dùng index. Tài liệu nói rõ các tối ưu hoá này "có thể thay đổi giữa các phiên bản", và cách xem pipeline sau khi tối ưu là dùng explain.

Những luật chính theo trang Aggregation Pipeline Optimization:

LuậtTrướcSau
$match sau $project/$set/$addFields$set → $matchphần filter không phụ thuộc field tính ra được tách lên trước
$match sau $sort$sort → $match$match → $sort
$skip sau $project$project → $skip$skip → $project
$sort + $limithai stagemột $sort chỉ giữ top-n
$sort + $skip + $limitba stage$sort giữ top-(skip+limit), rồi $skip
$limit + $limit, $skip + $skiphai stagemột stage (min / tổng)
$match + $matchhai stagemột $match với $and
$lookup + $unwind (+ $match)hai, ba stage$lookup tự unwind (và lọc) bên trong (bài $lookup & Joins đo luật này)

Thí nghiệm A: viết ngược thứ tự

Giả sử ai đó viết pipeline lấy 10 đơn pending mới nhất của tenant t0042, kèm ngày giờ VN, theo đúng thứ tự họ nghĩ ra:

const A = [
  { $set:   { day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: "Asia/Ho_Chi_Minh" } } } },
  { $sort:  { createdAt: -1 } },
  { $match: { tenantId: "t0042", status: "pending" } },
  { $limit: 10 }
];

Đọc nguyên văn thì đây là thảm hoạ: tính day cho 1 triệu đơn, sắp xếp cả 1 triệu đơn, rồi mới lọc. Explain cho thấy server làm khác:

stages[0].$cursor  parsedQuery: { $and: [ { status: 'pending' }, { tenantId: 't0042' } ] }
  FETCH
    IXSCAN tenantId_1_status_1_createdAt_1      keys 203, docs 203
stages[1]  $set                                 nReturned 203
stages[2]  $sort { createdAt: -1 }, limit: 10   nReturned 10, peakTrackedMemBytes 4686
  • $match được kéo lên trước cả $sort lẫn $set, và vì đã nằm ở đầu nên dùng được index.
  • $limit được gộp vào $sort (limit: 10). Stage sort chỉ giữ 10 phần tử tốt nhất, chỉ tốn 4,7 KB.

Nhưng có một việc optimizer không làm: nó không đưa $set xuống sau $sort. Vì $set vẫn nằm trước $sort, $sort không dùng được index (luật "không có $project đứng trước"). Server phải lấy đủ 203 đơn pending của tenant rồi sắp trong RAM. Viết lại theo đúng thứ tự:

const A2 = [
  { $match: { tenantId: "t0042", status: "pending" } },
  { $sort:  { createdAt: -1 } },
  { $limit: 10 },
  { $set:   { day: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: "Asia/Ho_Chi_Minh" } } } }
];
stages[0].$cursor
  LIMIT 10
    FETCH
      IXSCAN tenantId_1_status_1_createdAt_1    keys 10, docs 10     ← không còn SORT
stages[1]  $set                                 nReturned 10

Index {tenantId, status, createdAt} đã có thứ tự createdAt bên trong mỗi cặp (tenant, status). Server đi ngược index 10 bước là xong, $set chỉ tính cho 10 document.

Pipelinekeys / docsmedian lượt 1median lượt 2
A (viết ngược)203 / 203 + sort trong RAM1,5 ms1,1 ms
A2 (đúng thứ tự)10 / 100,7 ms0,6 ms

Chênh 1 ms nghe không đáng kể, nhưng với một tenant có 200.000 đơn pending (minh hoạ), A phải đọc và sắp 200.000 document, còn A2 vẫn đọc 10.

Thí nghiệm B: $match trên field vừa tính ra

[ { $set: { day: { $dateTrunc: { ... } } } },
  { $match: { day: ISODate("2026-09-14T17:00:00Z"), tenantId: "t0042" } } ]
stages[0].$cursor  parsedQuery: { tenantId: 't0042' }     keys 2022, docs 2022
stages[1]  $set                                           nReturned 2022
stages[2]  $match { day: ... }                            nReturned 7

[tài liệu] $match bị tách đôi. Phần tenantId không phụ thuộc $set nên được đưa lên đầu và dùng index. Phần day phải chờ $set tính xong. Kết quả: server đọc 2.022 đơn của tenant để giữ lại 7. Nếu bạn viết điều kiện thẳng trên createdAt ($gte/$lt theo ranh giới ngày VN), cả điều kiện sẽ nằm trong index.

Phiên bản: trang tối ưu hoá hiện tại (9.0) có thêm một luật mới: đưa cả phép tính lẫn $match lên trước những stage đắt như $lookup. Luật đó ghi "new in 9.0", không áp dụng cho 8.3.11 trong lab này.

Thí nghiệm C và D: lọc sau $group

Trong SQL có HAVING. Trong pipeline, đó là $match đặt sau $group, và có hai trường hợp rất khác nhau:

// C: lọc theo khoá nhóm
[ { $group: { _id: { tenantId: "$tenantId", status: "$status" }, revenue: { $sum: "$total" } } },
  { $match: { "_id.tenantId": "t0042" } } ]
// D: lọc theo giá trị đã cộng dồn
[ { $group: { _id: "$tenantId", revenue: { $sum: "$total" } } }, { $match: { revenue: { $gt: 9e9 } } } ]
C:  GROUP ← FETCH ← IXSCAN tenantId_1_status_1_createdAt_1     keys 2.022, docs 2.022
D:  GROUP ← COLLSCAN   docs 1.000.000   →  $match revenue      nReturned 2

D là điều hiển nhiên: không ai biết revenue trước khi cộng xong, nên $match phải chờ $group duyệt hết 1 triệu đơn. C thú vị hơn. [quan sát] Trên 8.3.11, server nhận ra _id.tenantId chính là tenantId của input, nên đưa điều kiện lên trước $group và dùng index. Trang tối ưu hoá không liệt kê luật này, nên đừng dựa vào nó: tự đặt $match { tenantId } ở đầu thì rõ ràng hơn và không phụ thuộc phiên bản.

Index chỉ giúp được đoạn đầu dây chuyền

Gom các thí nghiệm lại thì được một bức tranh:

         ┌────────── vùng index dùng được ──────────┐
pipeline:  $match  →  $sort  →  $limit  →  $set  →  $group  →  $sort  →  $lookup
           IXSCAN     theo       gộp      ─────── từ đây: dữ liệu "trần" ───────
                      index      vào sort          không index, không thứ tự sẵn
                                                                        ↑
                                                         dùng index của collection KHÁC

Ba hệ quả thực tế:

  1. $sort sau $group luôn là sort trong RAM. Kết quả của $group là dữ liệu mới, không có index nào. [tài liệu] $group cũng không đảm bảo thứ tự output. Muốn có thứ tự thì phải có $sort sau nó. Tin tốt: số nhóm thường nhỏ hơn rất nhiều so với số document.
  2. Mọi thứ bạn lọc được bằng field gốc nên nằm trong $match đầu tiên, kể cả khi bạn sẽ lọc lại sau đó.
  3. $lookup là ngoại lệ duy nhất "ở giữa dây chuyền" dùng được index, nhưng là index của collection bị nối, không phải collection đầu vào (bài $lookup & Joins).

Giới hạn 100 MB và chuyện tràn ra đĩa

Ý chính

[tài liệu] Mỗi stage cần giữ dữ liệu trong RAM bị giới hạn 100 MB. Từ MongoDB 6.0, tham số server allowDiskUseByDefault quyết định chuyện gì xảy ra khi vượt mức. Nếu nó là true, stage ghi file tạm ra đĩa. Nếu false, stage báo lỗi. Bạn có thể đổi cho từng lệnh bằng option allowDiskUse. Ví dụ các stage có thể spill: $group, $sort (khi không có index hỗ trợ), $bucket, $bucketAuto, $setWindowFields, $sortByCount. Riêng $facet thì không spill được, vượt 100 MB là lỗi. (Từ 9.0 còn có thêm một giới hạn bộ nhớ cho cả operation; 8.3.11 trong lab chưa có.)

[quan sát] Trong lab, allowDiskUseByDefault là true. Hai tham số nội bộ internalQueryMaxBlockingSortMemoryUsageBytes và internalDocumentSourceGroupMaxMemoryBytes đều là 104857600 (100 MiB).

Thí nghiệm 1: sort cả collection, và sort top-k

// SORTALL: sắp 1 triệu đơn theo total, bỏ 999.990 cái đầu  →  phải giữ cả document
[ { $sort: { total: -1, createdAt: 1 } }, { $skip: 999990 } ]

// TOPK: 10 đơn lớn nhất
[ { $sort: { total: -1, createdAt: 1 } }, { $limit: 10 } ]

Chặn spill để xem giới hạn:

db.orders.aggregate(SORTALL, { allowDiskUse: false })
QueryExceededMemoryLimitNoDiskUseAllowed: ... Sort exceeded memory limit of 104857600 bytes,
but did not opt in to external sorting.

Với mặc định (cho phép spill), explain("executionStats"):

SORTALL                                         TOPK
SKIP                                            SORT limit=10
  SORT { total: -1, createdAt: 1 }                COLLSCAN
    COLLSCAN
usedDisk:               true                    usedDisk:            false
spills:                 4                       spills:              0
spilledRecords:         1.000.000               peakTrackedMemBytes: 3.720
spilledBytes:           240.685.951
spilledDataStorageSize: 62.769.246
peakTrackedMemBytes:    103.808.946

Đọc các trường spill [tài liệu: spills, spilledBytes, spilledRecords, spilledDataStorageSize có từ 8.1 ở stage aggregation, 8.2 ở stage của plan; peakTrackedMemBytes từ 8.3]:

  • usedDisk: true, spills: 4: stage ghi ra đĩa 4 lần, mỗi lần bộ nhớ chạm khoảng 100 MB (peakTrackedMemBytes 103,8 MB).
  • spilledBytes là ước lượng số byte ghi ra trước khi nén: 240,7 MB, tức gần như toàn bộ 1 triệu document. spilledDataStorageSize là dung lượng đĩa thật sự dùng: 62,8 MB. Dữ liệu spill được nén khoảng 3,8 lần.
  • TOPK đọc đúng 1 triệu document như SORTALL, nhưng chỉ giữ 3.720 byte. [tài liệu] Khi $limit được gộp vào $sort, sort chỉ cần giữ n phần tử tốt nhất trong lúc chạy.
Pipelinemedian lượt 1median lượt 2
SORTALL, giới hạn 100 MB (spill)2.881 ms2.747 ms
SORTALL, giới hạn sort nâng lên 1 GB (không spill)1.936 ms2.343 ms
TOPK (sort + limit 10)288 ms465 ms

Để thấy giá của spill, tôi nâng tham số nội bộ internalQueryMaxBlockingSortMemoryUsageBytes cho cùng pipeline. Explain ở mức 400 MB cho thấy sort không spill và cần 325,7 MB; bảng trên đo ở mức 1 GB. Spill làm SORTALL chậm hơn khoảng 15–50% trong lab này. Nhưng chênh lệch lớn nhất nằm ở chỗ khác: TOPK nhanh hơn SORTALL 6–10 lần, chỉ nhờ một $limit.

Một sự cố thật trong lab. Trong một lượt đo với giới hạn sort nâng lên 1 GB, container 3 GB bị kernel kill vì hết bộ nhớ (OOMKilled: true), mất kết nối giữa chừng. Bài học: giới hạn 100 MB tồn tại để một câu query không giết cả server. Tham số internal* là công cụ cho thí nghiệm, đừng chỉnh trên production.

Thí nghiệm 2: $group với gần 1 triệu nhóm

RAM của $group đi theo số nhóm. Gom theo khách × ngày trên cả năm thì gần như mỗi đơn là một nhóm:

const GRP = [
  { $group: { _id: { c: "$customerId",
                     d: { $dateTrunc: { date: "$createdAt", unit: "day", timezone: "Asia/Ho_Chi_Minh" } } },
              revenue: { $sum: "$total" }, n: { $sum: 1 } } },
  { $count: "groups" }      // → 986.470 nhóm
];
allowDiskUse: false  → Exceeded memory limit for $group, but didn't allow external spilling;
                       pass allowDiskUse:true to opt in

mặc định:
GROUP                       ← $count, được SBE dịch thành một GROUP nữa
  GROUP
    COLLSCAN
group: { usedDisk: true, spills: 2, spilledRecords: 989.056,
         spilledBytes: 49.452.800, spilledDataStorageSize: 22.372.352,
         peakTrackedMemBytes: 104.857.802 }

Nhóm này chạy bằng SBE. [quan sát] Ngưỡng spill của $group trong SBE là một tham số nội bộ khác, internalQuerySlotBasedExecutionHashAggApproxMemoryUseInBytesBeforeSpill, mặc định cũng 100 MiB. Nâng riêng nó lên 500 MB thì pipeline không spill, và explain cho biết nó thực sự cần 117,3 MB, chỉ hơn giới hạn 17%.

GRP (986.470 nhóm)median lượt 1median lượt 2
giới hạn 100 MB → spill 2 lần10.501 ms10.248 ms
giới hạn 500 MB → không spill2.708 ms2.507 ms

Vượt giới hạn 17% làm pipeline chậm khoảng 4 lần. [quan sát] Lượng dữ liệu ghi ra không lớn (22 MB trên đĩa), nên phần chậm khó có thể chỉ do ghi đĩa. [hình dung] Cách giải thích hợp lý nhất (tôi chưa kiểm chứng trong mã nguồn): khi spill, các nhóm bị chia thành nhiều phần, cuối cùng server phải đọc lại các phần đó và gộp những nhóm trùng khoá. Với gần 1 triệu nhóm, phần gộp lại này rất đắt.

Đây là kiểu sự cố khó chịu nhất trên production: dữ liệu tăng thêm một chút là thời gian nhảy vọt, dù không ai sửa code.

Khi pipeline chạm giới hạn: sửa thế nào

Theo thứ tự nên thử:

  1. Lọc sớm hơn. $match theo thời gian, theo tenant. Ít document đi vào $group thì ít nhóm hơn.
  2. Giảm số nhóm hoặc kích thước mỗi nhóm. Có thật sự cần khách × ngày trên cả năm? Tránh $push: "$$ROOT" hay $addToSet trên mảng lớn trong $group, vì mỗi nhóm sẽ phình theo số document.
  3. Thêm $limit ngay sau $sort khi chỉ cần top-n.
  4. Có index cho $sort ở đầu pipeline để sort không còn blocking.
  5. Tính trước bằng $merge (phần cuối bài).
  6. Chỉ sau đó mới chấp nhận spill như một trạng thái bình thường, và theo dõi nó: slow query log và profiler có ghi usedDisk.

allowDiskUse: true không phải cách sửa. Nó chỉ là van an toàn để query không lỗi.

Cột mốc: Bạn đã biết server tự sắp xếp lại pipeline, vì sao index chỉ giúp đoạn đầu, và điều gì xảy ra khi một stage vượt 100 MB. Tiếp theo: $unwind, $facet, $merge.

Hỏi & đáp

Pipeline [ { $group: { _id: "$tenantId", n: { $sum: 1 } } }, { $sort: { n: -1 } }, { $limit: 5 } ] trên collection có index { tenantId: 1 }. Stage $sort chạy thế nào?

  1. Sort theo index { tenantId: 1 }, vì $group đã gom theo tenantId

    Index sắp theo tenantId, còn $sort sắp theo n, một field chỉ có sau $group. Kết quả của $group là dữ liệu mới, không có index nào. Xem mục "Index chỉ giúp được đoạn đầu dây chuyền".

  2. Sort trong RAM toàn bộ output của $group, rồi $limit mới cắt còn 5

    Optimizer gộp $sort + $limit thành một $sort chỉ giữ top-n. Trong lab, TOPK giữ 3.720 byte trong khi sort không limit cần hơn 100 MB. Xem mục "Server tự sắp xếp lại pipeline".

  3. Sort trong RAM, nhưng chỉ giữ top 5 vì $limit được gộp vào

    $sort sau $group luôn là sort trong RAM, vì output của $group không có index. Nhưng $limit được gộp vào $sort, nên stage chỉ giữ 5 phần tử tốt nhất, rất ít bộ nhớ. Xem mục "Index chỉ giúp được đoạn đầu dây chuyền" và "Thí nghiệm 1: sort cả collection, và sort top-k".

Pipeline [ { $set: { day: { $dateTrunc: ... } } }, { $match: { day: X, tenantId: "t0042" } } ]. Explain trong lab cho thấy gì?

  1. $match bị tách: phần tenantId dùng index lên đầu, phần day chờ sau $set (còn 7)

    Phần filter không phụ thuộc field tính ra được tách lên trước $set. Server đọc 2.022 đơn của tenant để giữ lại 7. Viết điều kiện thẳng trên createdAt theo ranh giới ngày VN thì cả điều kiện nằm trong index. Xem mục "Thí nghiệm B: $match trên field vừa tính ra".

  2. Cả $match nằm sau $set, nên phải COLLSCAN cả 1.000.000 document rồi mới lọc

    Optimizer không để nguyên: nó tách $match theo field. Explain có parsedQuery: { tenantId: 't0042' } ở $cursor với 2.022 key. Xem mục "Thí nghiệm B: $match trên field vừa tính ra".

  3. Cả $match được kéo lên trước $set, vì optimizer tự tính day từ createdAt

    Optimizer không viết lại điều kiện trên field tính ra thành điều kiện trên field gốc. Phần day phải chờ $set tính xong. Xem mục "Thí nghiệm B: $match trên field vừa tính ra".

Một job nightly chạy khoảng 3 giây suốt nửa năm, rồi một tuần nhảy lên 12 giây dù dữ liệu chỉ tăng 10%. Bạn kiểm tra gì trước trong explain?

  1. totalKeysExamined, vì chắc chắn planner vừa đổi sang index khác

    Có thể, nhưng một bước nhảy gấp 4 lần khi dữ liệu tăng ít rất khớp với một stage blocking vừa vượt giới hạn bộ nhớ. Xem mục "Giới hạn 100 MB và chuyện tràn ra đĩa".

  2. spilledDataStorageSize, vì chỉ đĩa đầy mới làm job chậm gấp 4 như vậy

    Trong lab, $group chỉ ghi 22 MB ra đĩa mà vẫn chậm khoảng 4 lần; phần chậm khó có thể chỉ do ghi đĩa. Trường đáng nhìn trước là có spill hay không và bộ nhớ đỉnh. Xem mục "Thí nghiệm 2: $group với gần 1 triệu nhóm".

  3. Số stage trong pipeline, vì pipeline dài hơn thì chạy chậm hơn

    Không ai sửa pipeline. Thứ đổi là lượng dữ liệu đi vào các stage blocking. Xem mục "Giới hạn 100 MB và chuyện tràn ra đĩa".

  4. usedDisk/spills của $group/$sort: có thể nó vừa vượt 100 MB

    Trong lab, $group cần 117,3 MB, chỉ hơn giới hạn 17%, đã chậm từ 2,5–2,7 s lên 10,2–10,5 s vì spill. Dữ liệu tăng một chút là thời gian nhảy vọt. Xem mục "Thí nghiệm 2: $group với gần 1 triệu nhóm".

Pipeline gom theo khách × ngày trên cả năm (986.470 nhóm) cần 117 MB và spill, chậm khoảng 4 lần. Bạn nên thử cách nào trước?

  1. Thêm $project ở đầu pipeline để mỗi document nhỏ đi

    Projection optimization đã tự lấy đúng các field cần (scanFieldNames). RAM của $group đi theo số nhóm, không theo kích thước document đầu vào. Xem Pipeline là dây chuyền; ví dụ chính.

  2. Nâng tham số nội bộ giới hạn bộ nhớ lên 500 MB cho hết spill

    Lab làm vậy chỉ để đo, và một lượt với giới hạn sort nâng lên 1 GB đã làm container bị OOMKilled. Tham số internal* không dành cho production. Xem mục "Thí nghiệm 1: sort cả collection, và sort top-k".

  3. Lọc sớm hơn và giảm số nhóm, ví dụ $match theo tenant

    Ít document đi vào $group thì ít nhóm, ít RAM. Bài xếp lọc sớm và giảm số nhóm lên đầu danh sách; allowDiskUse chỉ là van an toàn để query không lỗi. Xem mục "Khi pipeline chạm giới hạn: sửa thế nào".

  4. Đặt allowDiskUse: true cho lệnh để pipeline chạy ổn định

    Trong lab allowDiskUseByDefault đã là true, và chính spill làm pipeline chậm từ 2,5–2,7 s lên 10,2–10,5 s. Spill là van an toàn, không phải cách sửa. Xem mục "Khi pipeline chạm giới hạn: sửa thế nào".