Xin chào, lại là mình - Đức Phúc, anh chàng hơn 6 năm trong nghề vẫn nghèo technical nhưng thích viết Blog để chia sẻ kiến thức bản thân học được trong quá trình “cơm áo gạo tiền” đây. Các bạn có thể theo dõi mình thêm qua một số nền tảng bên dưới nhé:
- Linkedin: https://www.linkedin.com/in/phuc-ngo-728433346
- Viblo: https://viblo.asia/u/NHDPhucIT
- Patreon: https://www.patreon.com/felix_ngo
Nếu bài này hữu ích, đừng quên tặng mình một upvote, bookmark để ủng hộ các bạn nhé
Ở bài trước, chúng ta đã làm quen với đầy đủ 3 thành phần ESR, tức là Khi Filter chính xác, theo range và Sort cùng xuất hiện. Ở bài này, ta sẽ tìm hiểu về 1 trường hợp đặc biệt trong Filter, đó là toán từ $in
. Vậy nó thuộc thành phần nào trong ESR?
1. Bài toán
Trước tiên, ta vẫn sẽ làm quen với bài toán đã nhé. Chúng ta đã có bảng Orders rồi. Bây giờ, giả sử mỗi Order sẽ chứa 1 field mới, mình gọi là items
nha. Field này 1 mảng các số nguyên, lưu những thông tin nào đó. Schema của nó sẽ như thế này:
{ "_id": { "$oid": "68302946f185602f01445b09" }, "ordered_by": { "$oid": "682e9881cc777592fda2bab0" }, "product": "Handcrafted Concrete Ball", "price": 915, "quantity": 1, "status": "pending", "createdAt": { "$date": "2025-05-23T07:52:38.042Z" }, "updatedAt": { "$date": "2025-05-23T07:52:38.042Z" }, "items": [ 62, 526, 521, 980, 88 ]
}
Yêu cầu của bài toán, là ta sẽ query những documents mà trong items
của nó chứa 1 hoặc nhiều con số được xác định trước. Sau đó, sắp xếp theo thứ tự tăng dần của quantity
. Ở bài này, để giảm thời gian chờ đợi, ta sẽ giới hạn số document trả về là 1
thôi nhé
[ { $match: { items: {$in: [5, 10, 15, 20]} } }, { $sort: { quantity: 1 } }, { $limit: 100 }
]
Vậy là đã rõ bài toán, giờ ta tiến hành setup dữ liệu nha. Các bạn tạo thêm giúp mình 1 file là add_or_remove_order_items_from_orders.js
, nội dung file này như sau:
const { MongoClient, ObjectId } = require("mongodb");
const faker = require("faker"); const uri = "mongodb://localhost:27017";
const dbName = "demo_caching";
const collectionName = "Orders";
const BATCH_SIZE = 1000; // Helper to generate array of random numbers
function generateItems(size) { const items = []; for (let i = 0; i < size; i++) { items.push(Math.floor(Math.random() * 1000)); // Random number between 0-999 } return items;
} async function processOrders() { const operation = process.argv[2]; // 'add' or 'remove' const sizeFlag = process.argv[3]; // 'small' or 'large' (for add operation) if (!operation || (operation !== "add" && operation !== "remove")) { console.error( "Usage: node add_or_remove_order_items_from_orders.js <add|remove> [small|large]" ); process.exit(1); } if ( operation === "add" && (!sizeFlag || (sizeFlag !== "small" && sizeFlag !== "large")) ) { console.error( "Usage for add operation: node add_or_remove_order_items_from_orders.js add <small|large>" ); process.exit(1); } const client = new MongoClient(uri); try { await client.connect(); const db = client.db(dbName); const collection = db.collection(collectionName); console.log("Connected to MongoDB"); let totalProcessed = 0; let batch = []; const cursor = collection.find({}); for await (const doc of cursor) { let updateOperation; if (operation === "add") { const arraySize = sizeFlag === "small" ? Math.floor(Math.random() * 200) : Math.floor(Math.random() * 800) + 200; const itemsArray = generateItems(arraySize); updateOperation = { $set: { items: itemsArray }, }; } else { // remove operation updateOperation = { $unset: { items: "" }, }; } batch.push({ updateOne: { filter: { _id: doc._id }, update: updateOperation, }, }); if (batch.length >= BATCH_SIZE) { const result = await collection.bulkWrite(batch); totalProcessed += result.modifiedCount; console.log(`Processed ${totalProcessed} documents...`); batch = []; } } // Process any remaining documents in the batch if (batch.length > 0) { const result = await collection.bulkWrite(batch); totalProcessed += result.modifiedCount; console.log(`Processed ${totalProcessed} documents...`); } console.log( `${ operation === "add" ? "Added" : "Removed" } items field for ${totalProcessed} orders.` ); } catch (err) { console.error("Error:", err); } finally { if (client) { // Ensure client is connected before closing await client.close(); } }
} processOrders();
Mình giải thích thêm chút về nội dùng file này nha:
- File sẽ có nhiệm vụ tạo random field
items
vào toàn bộ 25 triệu documents của chúng ta trong bảng Orders. - Có 2 tùy chọn cho số lượng Number trong mỗi mảng
items
làsmall
vàlarge
. Vớismall
, số lượng sẽ < 200, vàlarge
là ngược lại. Mình chọn con số 200 này là ngẫu nhiên thôi nhé. - Ngoài ra, ta cũng có 2 tùy chọn là
add
vàremove
fielditems
. Để các bạn tiện test tùy vào bài toán nhé
Lúc này, câu lệnh để chạy file như sau:
node add_or_remove_order_items_from_orders.js add [size_option]
// Example:
node add_or_remove_order_items_from_orders.js add small // ta sẽ chạy phương án này trước nhé
// or
node add_or_remove_order_items_from_orders.js add large
Với remove, ta chỉ cần
node add_or_remove_order_items_from_orders.js remove
Lưu ý nhỏ: Ở bài này, các Index của chúng ta khá lớn, nên nếu các bạn muốn thực nghiệm, thì sau khi đánh INDEX, ta sẽ phải chờ thời gian khá lâu để MongoDB thực hiện đánh Index xong. Khoảng 30 phút. Do đó, các bạn chịu khó đợi nhé. Còn nếu không muốn thực nghiệm, thì mình đã để kết quả đầy đủ bên dưới rồi nha
2. Single Index cho field items
Rồi, vẫn như cũ thôi
Và đây là kết quả:
Nhìn cái đúng ngỡ ngàng phải không nào.:
- Thời gian thực thi: Hơn
11 phút
dù chỉ trả về đúng1
documents - IXSCAN tốn khoảng
12.6s
, với khoảng 7.7 triệu documents được trả về - Quá trình FETCH của chúng ta chiếm đến
10.6m
, thời gian rất lớn. Có thể nói là không chấp nhận được. Lý do là sau khi scan được 7.7 triệu documents, MongoDB sẽ phải fetch hết chúng vào memory, rồi mới thực hiện việc SORT. Với việc xuất hiện fielditems
, size của mỗi document cũng lớn hơn trước đó, do đó, việc fetch sẽ cực kì chậm - Và cuối cùng, SORT chiếm thấp nhất với chỉ
1.5s
3. Compound Index
Như thường lệ, ta vẫn sẽ kiểm tra trên cả 2 trường hợp nhé. Nhưng trước tiên, cùng mình tim hiểu về cách mà MongoDB xử lý Compound Index khi có toán từ $in
nha
3.1. MongoDB đánh Index cho toán tủ $in
như thế nào?
- Khi $in có < 200 phần tử, MongoDB coi như là nhiều điều kiện bằng ($eq) ⇒ vẫn theo nguyên tắc ESR ⇒ có thể dùng index tốt, kể cả với .sort()
- Khi $in có ≥ 200 phần tử, MongoDB coi nó như một range operator, giống như $gt, $lt, $regex ⇒ chỉ có thể áp dụng nguyên tắc ESR nếu $in nằm sau cùng trong index.
Con số 200 này, là con số mà MongoDB quy ước tùy theo phiên bản. Nhưng hiện tại, mình chưa tìm thấy bất kì offical document nào nói chính xác về con số này. Do đó, để biết được thật sự Filter theo items
của chúng ta là E hay R, ta nên kiểm nghiệm ở cả 2 phương án nhé:
3.2. Xem $in
là E
Theo như câu truy vấn của chúng ta, số lượng items cần truy vấn của ta trong toán từ $in
là 4 ([5, 10, 15, 20]). Do đó, ta sẽ coi nó như là E vì nó < 200.
Rồi, ta tạo INDEX thôi:
Và đây là kết quả:
Ồ, thời gian thực thi bây giờ chỉ là 4ms
, thay vì hơn 11 phút
như trước đó. Đó chính là nhờ ESR đấy:
-
Quá trình IXSCAN của chúng ta trải qua khá nhiều phần. Vì sao lại có nhiều phần như vậy? À, nếu để ý, bạn sẽ thấy nó có 4 phần, tương ứng chính là 4 điều kiện nằm trong
$in
. Với mỗi phần từ trong mảngitems
, khi ta đánh INDEX, mongoDB sẽ coi nó là multikey field. Tức là với INDEX như trên, và ta có mảngitems
ví dụ như:[1, 2, 3]
, MongoDB sẽ chuyển thành:{ items: 1, quantity: X }
,{ items: 2, quantity: X }
{ items: 3, quantity: X }
{ items: 1, quantity: Y }
{ items: 2, quantity: Y }
{ items: 3, quantity: Y }
- ... cứ thế cho mỗi cặp
items
vàquantity
-
Bạn có thể tăng, giảm số lượng phần từ trong
$in
để kiểm nghiệm nhé. Hoặc thử 2 giá trị trong$in
trùng nhau, xem thế nào nha -
Sau quá trình IXSCAN, MongoDB tiến hành SORT_MERGE chúng lại, quá trình này gần như bằng
0ms
. Tương tự cho quá trình FETCH và LIMIT, vì ta đã SORT theoquantity
rồi, nên quá trình này là siêu nhanh, MongoDB sẽ không cần FETCH hết rồi SORT lại nữa
3.3. Xem $in
là R
Giờ ta thử xem $in
là R xem sao nhé, lúc đó, Index ta sẽ thay đổi thành.
Và đây là kết quả:
- IXSCAN của chúng ta cũng khá nhanh, tốn
14ms
, tuy nhiên, nó cần phải quét đến**12590**
documents - Sau đó, mới FETCH chúng về, và tiến hành LIMIT
- Toàn bộ quá trình mất khoảng
6s
4. Kết luận
Như vậy, với toán từ $in
, ta nên kiểm nghiệm cả 2 phương án, xem xét nó trên cả vai trò là E và R, để xem phương án nào tối ưu hơn các bạn nhé. Các bạn cũng có thể tăng số lượng documents trả về để xem hiệu suất mà mỗi phương án mang lại nữa nhé.
Ngoài ra, như đã đề cập, các bạn sẽ thấy. việc MongoDB tách mảng items
thành multikey field, khiến cho chúng ta sẽ cần bộ nhớ khá lớn cho việc lưu trữ INDEX, như ví dụ này này khoảng 12.6GB
nha
Các bạn cũng có thể dùng options
là large
ở mẫu code trên, để tăng số lượng phần từ trong items
, nếu muốn test thêm nhé. Lưu ý là quá trình đánh INDEX sẽ tốn kha khá thời gian nha
Tất nhiên, bài này chỉ là ví dụ cho toán từ $in
. Trên thực tế, mình đã hướng dẫn các bạn một cách thiết kế schema khác, với hiệu suất cao hơn, tốt hơn cho việc mở rộng ở bài: MongoDB - Dynamic Fields: Câu truy vấn chậm đi hơn 200% vì một sai lầm - Bạn biết chưa?. Các bạn có thể tham khảo nhé.
Nếu bài này hữu ích, đừng quên tặng mình một upvote, bookmark để ủng hộ các bạn nhé. Nếu có thắc mắc gì hay ý kiến gì trao đổi, bạn có thể để lại comment nha.
Ngoài ra, các bạn có thể kết nối với mình qua:
- Linkedin: https://www.linkedin.com/in/phuc-ngo-728433346
- Viblo: https://viblo.asia/u/NHDPhucIT
- Patreon: https://www.patreon.com/felix_ngo
Hẹn gặp lại các bạn ở những bài tiếp theo