CRUD (P3/3): Mảng và thí nghiệm 1 triệu document
Ở phần trước: update operator sửa từng field, còn replacement thay cả document, và mọi thao tác ghi chỉ atomic trong một document. Cần atomic trên nhiều document thì phải dùng multi-document transaction.
- Cần đọc trước: Ghi và xoá: update, upsert, bulkWrite
- Dẫn tới: Index Fundamentals, bài tiếp theo. Phần này là phần cuối của bài CRUD & Query Model.
Mảng: khi một field chứa nhiều giá trị
Hình dung trước: hai câu hỏi về một lớp học
Hai câu nghe giống nhau nhưng khác nhau hoàn toàn:
- "Tìm lớp có ai đó cao trên 1m50, và có ai đó thấp dưới 1m60." Người cao có thể là bạn A, người thấp có thể là bạn B.
- "Tìm lớp có một bạn cao trong khoảng 1m50 đến 1m60." Một người phải thoả cả hai điều kiện.
MongoDB có cả hai cách hỏi. Cách thứ nhất là cách mặc định khi bạn đặt điều kiện thẳng lên field mảng. Cách thứ hai là $elemMatch.
Quy tắc gốc: multikey matching
Khi field là mảng, một điều kiện như { vals: { $gt: 1 } } khớp nếu ít nhất một phần tử thoả điều kiện. Nhưng khi đặt nhiều điều kiện cùng lúc ($gt và $lt), tài liệu MongoDB giải thích: mảng chỉ cần thoả các điều kiện theo một tổ hợp nào đó của các phần tử. Một phần tử có thể thoả $gt, một phần tử khác thoả $lt, hoặc một phần tử thoả cả hai.
Thí nghiệm với 5 document nhỏ để thấy từng trường hợp:
db.readings.insertMany([
{ _id: "A", vals: [3] },
{ _id: "B", vals: [0, 10] },
{ _id: "C", vals: [7, 8, 2] },
{ _id: "D", vals: [9, 12] },
{ _id: "E", vals: 4 }
]);
db.readings.find({ vals: { $gt: 1, $lt: 5 } });
db.readings.find({ vals: { $elemMatch: { $gt: 1, $lt: 5 } } });plain : ["A","B","C","E"]
elemMatch : ["A","C"]| Doc | vals | Không dùng $elemMatch | $elemMatch | Vì sao |
|---|---|---|---|---|
| A | [3] | ✓ | ✓ | 3 nằm trong (1, 5) |
| B | [0, 10] | ✓ | ✗ | 10 thoả > 1, 0 thoả < 5, nhưng không phần tử nào nằm giữa 1 và 5 |
| C | [7, 8, 2] | ✓ | ✓ | 2 nằm trong (1, 5) |
| D | [9, 12] | ✗ | ✗ | không phần tử nào < 5 |
| E | 4 (không phải mảng) | ✓ | ✗ | $elemMatch chỉ khớp với field là mảng |
Document B là cái bẫy. Nếu vals là các lần đo nhiệt độ và bạn muốn "có lần đo nằm trong ngưỡng", B là kết quả sai. Document E cho thấy thêm một khác biệt: $elemMatch loại các document mà field không phải mảng.
Trên dữ liệu thật: mảng các object
Với mảng các object như items, bẫy này còn khó thấy hơn. "Đơn có món nào mua từ 9 cái trở lên với đơn giá từ 950.000đ":
const plain = { "items.qty": { $gte: 9 }, "items.price": { $gte: 950000 } };
const em = { items: { $elemMatch: { qty: { $gte: 9 }, price: { $gte: 950000 } } } };
db.orders.countDocuments(plain);
db.orders.countDocuments(em);orders plain : 45633
orders elemMatch: 23977Cách viết dot notation trả về gần gấp đôi. 21.656 đơn thừa ra là những đơn mà điều kiện số lượng và điều kiện giá nằm ở hai món khác nhau, ví dụ đơn này:
{ tenantId: 't0478',
items: [ { sku: 'SKU-4259', qty: 7, price: 990000 }, ← giá cao, nhưng qty 7
{ sku: 'SKU-4897', qty: 10, price: 820000 } ] } ← qty 10, nhưng giá thấpMột báo cáo "món bán sỉ giá cao" viết bằng dot notation sẽ sai gần gấp đôi, không có lỗi nào được báo, và rất khó phát hiện bằng mắt.
Quy tắc ngắn gọn:
Nhiều điều kiện phải đúng trên CÙNG một phần tử → $elemMatch
Mỗi điều kiện chỉ cần đúng ở MỘT phần tử nào đó → điều kiện thường / dot notation
Chỉ có một điều kiện → hai cách gần như tương đương
(trừ chuyện $elemMatch đòi field là mảng)Cả hai query trên đều đang là COLLSCAN. Khi có index trên một field mảng (gọi là multikey index), cách viết điều kiện còn ảnh hưởng đến việc index được dùng hiệu quả tới đâu. Đó là chủ đề của bài Specialized Indexes.
Thí nghiệm: một query chọn lọc trên 1 triệu document
Giờ ghép mọi thứ lại cho câu query mà gần như mọi ứng dụng SaaS đều có: "các đơn đang chờ xử lý của tenant này trong tháng gần đây, mới nhất trước."
Setup
MongoDB version : 8.3.11 (Docker image mongo:8, standalone)
Hardware : Apple M4 host; container giới hạn 4 CPU, 4 GB RAM
Configuration : WiredTiger cache 1 GB (--wiredTigerCacheSizeGB 1)
Dataset : lab05.orders, 1.000.000 document
Kích thước : 215 MB chưa nén (avgObjSize 215 byte), 72,6 MB trên đĩa
Indexes : chỉ có _id_ (mặc định)Dataset
500 tenant, mỗi tenant 200 user, trạng thái phân bố 70% completed, 10% pending, 10% cancelled, 10% refunded, createdAt rải đều trong 365 ngày trước 2026-10-01. Số sinh từ PRNG có seed cố định, nên chạy lại sẽ ra đúng dữ liệu này.
// trích gen.js: 100 lần insertMany, mỗi lần 10.000 document
docs.push({
tenantId: "t" + String(Math.floor(rnd() * 500)).padStart(4, "0"),
userId: "u" + String(Math.floor(rnd() * 200)).padStart(4, "0"),
status: pickStatus(rnd()),
createdAt: new Date(END - Math.floor(rnd() * 365 * DAY)),
total: items.reduce((s, it) => s + it.qty * it.price, 0), // VND, số nguyên
items // 1–3 món { sku, qty, price }
});
db.orders.insertMany(docs, { ordered: false });inserted 1000000 docs in 22561 msGom thành batch 10.000 thay vì insert từng cái là ứng dụng trực tiếp phần bulkWrite ở phần trước (Ghi và xoá: update, upsert, bulkWrite): 100 round-trip thay vì 1 triệu.
Query và explain
db.orders.find({
tenantId: "t0042",
status: "pending",
createdAt: { $gte: ISODate("2026-09-01T00:00:00Z") }
}).sort({ createdAt: -1 }).explain("executionStats")Output thật (đã cắt bớt):
explainVersion: '1'
winningPlan: {
stage: 'SORT', sortPattern: { createdAt: -1 },
inputStage: {
stage: 'COLLSCAN',
filter: { '$and': [ { status: { '$eq': 'pending' } },
{ tenantId: { '$eq': 't0042' } },
{ createdAt: { '$gte': ISODate('2026-09-01T00:00:00.000Z') } } ] },
direction: 'forward'
}
}
rejectedPlans: 0
executionStats: {
nReturned: 21,
executionTimeMillis: 161,
totalKeysExamined: 0,
totalDocsExamined: 1000000
}Đọc từng con số
winningPlan: SORT ← COLLSCAN. Không có index nào dùng được, nên planner chỉ có một lựa chọn (rejectedPlans: 0). COLLSCAN đọc lần lượt mọi document, áp filter lên từng cái, rồi SORT xếp những cái còn lại.totalKeysExamined: 0. Không đụng tới index nào.totalDocsExamined: 1.000.000,nReturned: 21. Để trả về 21 document, server xem cả 1 triệu. Tức là khoảng 47.600 document bị đọc rồi vứt cho mỗi document có ích.executionTimeMillis: 161ở lần chạy này. Theo tài liệu, con số này gồm thời gian chọn plan và thực thi, không gồm thời gian gửi dữ liệu qua mạng. Một lần đo thì chưa đủ tin, nên phần tiếp theo đo lặp lại.
Mấy field này (plan thắng, keys examined, docs examined, nReturned, thời gian) là đủ cho tới hết Phần Index & Query. Từng stage làm việc ra sao bên trong (works, advanced, needTime...) để dành cho bài Query Planner & Plan Cache và bài Query Execution Engine.
Đo lặp lại: chi phí đi theo kích thước collection, không theo số kết quả
Mỗi query chạy explain("executionStats") 9 lần, cả bộ chạy 2 lượt. Bảng ghi median của từng lượt:
| Query | nReturned | totalDocsExamined | median lượt 1 | median lượt 2 |
|---|---|---|---|---|
| tenant + pending + từ 01/09 | 21 | 1.000.000 | 143 ms | 143 ms |
| chỉ tenant | 2.003 | 1.000.000 | 138 ms | 144 ms |
status: "completed" | 699.990 | 1.000.000 | 150 ms | 152 ms |
| tenant không tồn tại | 0 | 1.000.000 | 149 ms | 138 ms |
Gọi find(...).toArray() thật (gồm cả đường về app) cho query đầu: median 144 ms và 141 ms ở hai lượt.
Đây là điểm quan trọng nhất của bài. Query trả về 0 document, 21 document hay 700.000 document đều tốn khoảng 140–150 ms, vì cả bốn đều làm cùng một việc: đọc 1 triệu document. Với COLLSCAN, chi phí do kích thước collection quyết định, không phải do kích thước kết quả. Thêm điều kiện để kết quả nhỏ hơn không làm query rẻ hơn.
Bài bức tranh tổng thể đo query tương tự trên 200.000 document mất 33–39 ms. Ở đây có gấp 5 lần document và mất khoảng 4 lần thời gian. Hình dạng document hai bài khác nhau nên đây chỉ là so sánh thô, nhưng nó khớp với nhận định "COLLSCAN tăng tuyến tính theo kích thước collection".
Và đây vẫn là trường hợp tốt nhất. 215 MB dữ liệu nằm gọn trong 1 GB cache (lúc đo, cache đang giữ 491 MB), nên không có lần đọc đĩa nào. Khi collection lớn hơn cache, mỗi lần quét còn kéo theo I/O và làm các page được đọc thường xuyên khác bị evict (bỏ khỏi cache để lấy chỗ) (bài về cache và working set).
Từ một query sang cả hệ thống
143 ms cho một request nghe vẫn chấp nhận được. Nhưng server không chỉ chạy một request: mỗi lượt quét giữ một CPU bận khoảng 140 ms chỉ để lật 1 triệu document, nên khi nhiều client cùng gọi, chúng xếp hàng chờ CPU và latency của mọi người tăng theo. Khác biệt giữa query performance và system performance là chủ đề của bài Pagination & Production Query Patterns; đo tải và tính capacity nằm ở bài Connection Pool & Capacity.
Kết luận của thí nghiệm
Quay lại kho lưu trữ. Ta muốn 21 hồ sơ. Nhân viên lật 1.000.000 bìa để tìm chúng, và lần nào cũng lật lại từ đầu, vì kho không có cuốn sổ nào ghi "hồ sơ của t0042 nằm ở đâu".
HIỆN TẠI ĐIỀU TA MUỐN
Query Query
↓ ↓
COLLSCAN: 1.000.000 document ??? đi thẳng tới đơn của t0042,
↓ status pending, từ 01/09
filter: vứt 999.979 ↓
↓ ~21 document
21 kết quảKhông cú pháp nào trong bài này lấp được khoảng cách đó. Filter, projection, limit đều không giảm totalDocsExamined khi kết quả hiếm hoặc khi phải sort. Thứ duy nhất làm được việc đó là cho server biết trước dữ liệu nằm ở đâu.
So với PostgreSQL
Phần lớn khái niệm trong bài có bản tương ứng bên SQL. Khác biệt nằm ở chi tiết, và chính chi tiết gây bug:
| Việc | MongoDB | PostgreSQL |
|---|---|---|
| Lọc và chọn cột | find(filter, projection) | SELECT cột ... WHERE ... |
| Giá trị vắng mặt | field không tồn tại; $ne khớp cả field vắng | NULL; col <> 'x' không khớp hàng có NULL |
| Lấy kết quả từng phần | cursor + batch là mặc định | libpq mặc định nhận cả kết quả; muốn từng phần thì dùng DECLARE CURSOR/FETCH hoặc fetch size của driver |
| Sửa và trả về | findOneAndUpdate(..., { returnDocument: "after" }) | UPDATE ... RETURNING * |
| Upsert | upsert: true, chạy được cả khi không có unique index (nhưng có thể tạo trùng khi chạy đồng thời) | INSERT ... ON CONFLICT ... DO UPDATE bắt buộc có unique index hoặc constraint làm đích xung đột. MERGE (từ PostgreSQL 15) không cần constraint, nhưng cũng không chống được chạy đồng thời: hai MERGE cùng thấy "chưa có" thì cùng insert, và nếu có unique index thì một bên nhận lỗi uniqueness violation |
| Xoá và trả về | findOneAndDelete | DELETE ... RETURNING * |
| Không có index phù hợp | COLLSCAN | Seq Scan |
Dòng upsert đáng chú ý nhất. Với ON CONFLICT, PostgreSQL buộc bạn khai báo ràng buộc trước khi cho upsert, và đổi lại đảm bảo mỗi hàng hoặc được insert hoặc được update. MongoDB cho chạy ngay, và để ràng buộc về sau cho bạn tự nhớ. MERGE của PostgreSQL giống MongoDB ở điểm này: tài liệu PostgreSQL nói rõ nó không đảm bảo insert hay update sẽ xảy ra khi có thao tác đồng thời.
Những lỗi thường gặp
- Truyền document không có
$vào lệnh update vì muốn "đổi một field". Driver hiện đại chặn ởupdateOne, nhưngreplaceOnehayfindOneAndReplacesẽ xoá sạch các field còn lại. - Dùng
$ne/$ninmà quên field có thể vắng mặt. Kết quả gồm cả những document "không liên quan". - Đặt nhiều điều kiện lên field mảng mà không dùng
$elemMatch. Trong lab, báo cáo đếm ra 45.633 thay vì 23.977. - "Ghi rồi đọc lại" để lấy giá trị vừa tạo. Hãy dùng
findOneAndUpdatevớireturnDocument: "after". - Quên rằng
findOneAndUpdatemặc định trả bản cũ. - Tin rằng
bulkWrite,updateManyhaydeleteManylà all-or-nothing. Lỗi giữa chừng không rollback những gì đã ghi. - Phân trang bằng
skiplớn, sort theo field không duy nhất. Vừa chậm dần theo offset, vừa có thể lặp hoặc sót document. - Nghĩ rằng
limithay projection làm query rẻ đi. Chúng giảm thứ gửi về, không giảm thứ phải đọc, nhất là khi có sort.
Cột mốc: Bạn đã dùng
$elemMatchđúng cho mảng, đọc được explain của một COLLSCAN, và biết chi phí đi theo kích thước collection chứ không theo số kết quả. Bài CRUD & Query Model khép lại ở đây.
Hỏi & đáp
Filter { scores: { $gte: 80, $lte: 90 } } có khớp với document { scores: [50, 95] } không?
Một kho lưu trữ 1 triệu bìa hồ sơ, không có sổ nào ghi hồ sơ nằm ở đâu. Ba yêu cầu: tìm hồ sơ của một khách không tồn tại, tìm 21 hồ sơ, tìm 700.000 hồ sơ. Thời gian nhân viên tìm khác nhau thế nào?
Nếu phải giải thích bài này mà không dùng thuật ngữ MongoDB nào: bạn gửi một tờ phiếu mô tả hồ sơ cần tìm, kho mang ra theo từng xe đẩy. Phiếu sửa chỉ sửa đúng những dòng ghi trên phiếu. Và khi kho không có sổ ghi chỗ để, nhân viên lật mọi bìa, dù bạn chỉ cần hai mươi bìa.
Bài tiếp theo
Thí nghiệm cuối bài để lại một con số khó chịu: 1.000.000 document bị đọc để trả về 21, và thời gian gần như không đổi dù kết quả là 0 hay 700.000. Mọi công cụ trong bài này (filter chặt hơn, projection, limit) đều không chạm được tới con số đó.
Index Fundamentals mở đầu Phần Index & Query và trả lời câu hỏi bỏ ngỏ ở sơ đồ "điều ta muốn": làm sao để server đi thẳng tới đúng những document cần, cái "mục lục" đó trông thế nào bên trong, và nó phải trả giá gì (RAM, đĩa, mỗi lệnh ghi). Ta sẽ chạy lại đúng query này trên đúng 1 triệu đơn hàng này, đặt explain("executionStats") trước và sau cạnh nhau, rồi xem totalDocsExamined thay đổi ra sao.
Tài liệu tham khảo
- Query Predicates (danh sách query operator)
- Query an Array
- $elemMatch (query)
- Cursors
- getMore command
- Iterate a Cursor in mongosh
- cursor.skip()
- Update Operators
- db.collection.updateOne()
- db.collection.findOneAndUpdate()
- db.collection.deleteMany()
- db.collection.findOneAndDelete()
- db.collection.bulkWrite()
- Atomicity and Transactions
- Explain Results
- PostgreSQL: MERGE và Transaction Isolation (hành vi của MERGE khi chạy đồng thời)