Aggregation (P2/3): Server sắp xếp lại, index ở đầu, giới hạn 100 MB
Ở 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.
- Cần đọc trước: Pipeline là dây chuyền và ví dụ chính
- Dẫn tới: $unwind, $facet, $merge
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ật | Trước | Sau |
|---|---|---|
$match sau $project/$set/$addFields | $set → $match | phầ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 + $limit | hai stage | một $sort chỉ giữ top-n |
$sort + $skip + $limit | ba stage | $sort giữ top-(skip+limit), rồi $skip |
$limit + $limit, $skip + $skip | hai stage | một stage (min / tổng) |
$match + $match | hai stage | mộ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ả$sortlẫ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 10Index {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.
| Pipeline | keys / docs | median lượt 1 | median lượt 2 |
|---|---|---|---|
| A (viết ngược) | 203 / 203 + sort trong RAM | 1,5 ms | 1,1 ms |
| A2 (đúng thứ tự) | 10 / 10 | 0,7 ms | 0,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
$matchlê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 2D 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ÁCBa hệ quả thực tế:
$sortsau$groupluôn là sort trong RAM. Kết quả của$grouplà dữ liệu mới, không có index nào. [tài liệu]$groupcũng không đảm bảo thứ tự output. Muốn có thứ tự thì phải có$sortsau nó. Tin tốt: số nhóm thường nhỏ hơn rất nhiều so với số document.- 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 đó. $lookuplà 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 (peakTrackedMemBytes103,8 MB).spilledByteslà ướ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.spilledDataStorageSizelà 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.
| Pipeline | median lượt 1 | median lượt 2 |
|---|---|---|
| SORTALL, giới hạn 100 MB (spill) | 2.881 ms | 2.747 ms |
| SORTALL, giới hạn sort nâng lên 1 GB (không spill) | 1.936 ms | 2.343 ms |
| TOPK (sort + limit 10) | 288 ms | 465 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 1 | median lượt 2 |
|---|---|---|
| giới hạn 100 MB → spill 2 lần | 10.501 ms | 10.248 ms |
| giới hạn 500 MB → không spill | 2.708 ms | 2.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ử:
- Lọc sớm hơn.
$matchtheo thời gian, theo tenant. Ít document đi vào$groupthì ít nhóm hơn. - 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$addToSettrên mảng lớn trong$group, vì mỗi nhóm sẽ phình theo số document. - Thêm
$limitngay sau$sortkhi chỉ cần top-n. - Có index cho
$sortở đầu pipeline để sort không còn blocking. - Tính trước bằng
$merge(phần cuối bài). - 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?
Pipeline [ { $set: { day: { $dateTrunc: ... } } }, { $match: { day: X, tenantId: "t0042" } } ]. Explain trong lab cho thấy gì?
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?
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?