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 viết trước về MongoDB - Giảm 99% lượng truy vấn đến Database nhờ Caching & Distributed Locking trên Redis. Cùng thử nhé!, chúng ta đã cùng nhau thực nghiệm sử dụng Distributed Lock trên bảng Users với 1 triệu documents. Hôm nay, chúng ta sẽ quay trở lại với INDEX trong MongoDB nhé.
1. ESR Index là gì?
Như chúng ta đã biết, MongoDB chia INDEX thành 2 loại chính xét về cách đánh INDEX là: Single INDEX và Compound INDEX. Trong Compound INDEX, ta sẽ có thể chọn nhiều field để hợp thành 1 INDEX. Tuy nhiên, đã bao giờ bạn thắc mắc, thứ tự các field đó có quan trọng hay không chưa? Giả sử mình muốn đánh INDEX trên 2 trường A và B, ta sẽ có các trường hợp như:
// TH1: {A: 1, B: 1}
// TH2: {B: 1, A: 1}
// TH3: {A: 1, B: -1}
// TH4: {B: 1, B: -1}
... và nhiều trường hợp khác nữa
Lúc này, để chọn được cách tạo INDEX phù hợp, MongoDB đã khuyến khích chúng ta tạo INDEX theo 1 tiêu chuẩn gọi là ESR. Trong đó:
- E: (Equality): Đầu tiên là thêm các trường được sử dụng trong điều kiện so sánh bằng (=), tức là những trường truy vấn theo giá trị chính xác.
- S: (Sort): Tiếp theo, thêm các trường được dùng để sắp xếp kết quả truy vấn (sort).
- R (Range): thêm các trường được sử dụng cho các điều kiện lọc theo khoảng giá trị (range), như
$gt
,$lt
,$gte
,$lte
.
Với tiêu chuẩn này, chúng ta sẽ có thể truy vấn nhanh hơn nhờ INDEX hoạt động hiệu quả. Lý thuyết thôi thì chưa đủ đúng không nào, nên giờ chúng ta sẽ cùng thực nghiệm nhé. Mình sẽ chia nội dung này thành nhiều phần, để các bạn có thể dễ nắm được từng ví dụ, và dễ nhớ hơn nha
2. Bài toán
Ví dụ, mình có 1 bảng Orders với schema đơn giản hóa như sau:
{ "_id": { "$oid": "68302946f185602f01445b52" }, "ordered_by": { "$oid": "682e9881cc777592fda2bab5" }, "product": "Practical Cotton Cheese", "price": 75, "quantity": 1, "status": "completed", "createdAt": { "$date": "2025-05-23T07:52:38.042Z" }, "updatedAt": { "$date": "2025-05-23T07:52:38.042Z" }
}
Và chúng ta cần thực hiện truy vấn theo yêu cầu:
Lấy những đơn hàng có trạng thái (status) là "X" và sắp xếp các đơn hàng đó theo thứ tự tăng dần của số lượng (quantity)
Okay, bài toán cũng không có gì lạ phải không? Lúc này, câu truy vấn của chúng ta sẽ gồm 2 phần:
- E: Lấy chính xác đơn hàng với status là X
- S: Sort kết quả theo thứ tự tăng dần của quantity
Ở bài trước, chúng ta đã có file user_seed.js
để tạo 1 triệu User documents. Trước khi vào thực nghiệm, bạn tạo thêm 1 file là order_seed.js
để thêm dữ liệu cho bảng Orders nha:
const { MongoClient, ObjectId } = require("mongodb");
const faker = require("faker"); const uri = "mongodb://localhost:27017";
const dbName = "demo_caching";
const usersCollection = "Users";
const ordersCollection = "Orders";
const BATCH_SIZE = 1000; function getRandomInt(min, max) { return Math.floor(Math.random() * (max - min + 1)) + min;
} function generateOrder(userId) { return { ordered_by: userId, product: faker.commerce.productName(), price: parseFloat(faker.commerce.price()), quantity: getRandomInt(1, 10), status: faker.random.arrayElement(["pending", "completed", "cancelled"]), createdAt: new Date(), updatedAt: new Date(), };
} async function seedOrders() { const client = new MongoClient(uri); try { await client.connect(); const db = client.db(dbName); const users = await db .collection(usersCollection) .find({}, { projection: { _id: 1 } }) .toArray(); const ordersCol = db.collection(ordersCollection); await ordersCol.deleteMany({}); console.log("Cleared Orders collection"); let totalOrders = 0; let batch = []; for (const user of users) { const numOrders = getRandomInt(0, 50); for (let i = 0; i < numOrders; i++) { batch.push(generateOrder(user._id)); if (batch.length >= BATCH_SIZE) { await ordersCol.insertMany(batch); totalOrders += batch.length; batch = []; } } } // Insert any remaining orders if (batch.length > 0) { await ordersCol.insertMany(batch); totalOrders += batch.length; } console.log(`Inserted ${totalOrders} orders for ${users.length} users.`); } catch (err) { console.error(err); } finally { await client.close(); }
} seedOrders();
Với file này, ta sẽ random mỗi User sẽ có từ 0 đến 50 đơn hàng nha. Sau khi chạy xong, thì mình có khoảng 25 triệu documents cho bảng Orders (Con số này có thể khác ở phía bạn vì chúng ta đang random). Quá trình chạy file có thể mất vài phút, nên bạn cố chờ nhé:
node order_seed.js
Có dữ liệu rồi, giờ là câu truy vấn sẽ được dùng xuyên suốt bài này nha. Mình giả sử status
cần tìm là completed
nhé:
[ { $match: { status: "completed" } }, { $sort: { quantity: 1 } },
]
3. Thử nghiệm với Single INDEX
Ủa, chúng ta đang nhắc đến Compund Index, sao giờ lại thử nghiệm trên Single Index nữa? À thì cũng đâu có sao, thử xem nếu chỉ dùng Single Index thì sao nhé
3.1. Single Index trên trường status
(Filter)
Rồi, đầu tiên, tạo giúp mình Index nhé:
Và đây là kết quả khi thực thi nè:
Các bạn có thể thấy:
- Chúng ta cần đến khoảng
13s
để hoàn thành truy vấn. Và 8_333_739 kết quả được trả về. - Việc thực hiện truy vấn được chia làm 3 giai đoạn:
- IXSCAN trên index
status
mà chúng ta đã tạo. Giai đoạn này chỉ mất khoảng1.5s
- FETCH dữ liệu: Số lượng dữ liệu khá lớn với hơn 8 triệu documents, do đó, thời gian cho nó khoảng
4.1s
- Cuối cùng, SORT dữ liệu: Công đoạn này mất nhiều thời gian nhất với
6.8s
để sắp xếp toàn bộ 8 triệu documents đó
- IXSCAN trên index
Vậy là với phương án này, dù INDEX để SCAN khá oke, nhưng lại phải tốn chi phí cho việc SORT khá nhiều vì INDEX của chúng ta chỉ đánh trên trường status
, tức là dành cho Filter mà thôi. MongoDB sẽ phải dùng memory để sắp xếp dữ liệu nên tổng thời gian của chúng ta cũng khá lớn.
3.2. Single Index trên trường quantity
(Sort)
Rồi, xóa giúp mình Index trên trường status
trước đó đã nhé. Sau đó, tạo mới Index cho trường quantity
nha. Chúng ta đang SORT dữ liệu theo thứ tự tăng dàn của quantity, nên ta sẽ chọn INDEX tăng dần - 1 (asc) nhé:
Chạy lại EXPLAIN và xem kết quả nào:
Ồ, nhìn vào là thấy ngay giờ truy vấn chỉ cần 2 giai đoạn là hoàn thành thôi. Ta đã bỏ được giai đoạn SORT rồi nè. Nhưng mà.... sao thời gian truy vấn lại tăng lên 17ms
thế kia?
Ta sẽ dễ nhận ra ngay, là giai đoạn FETCH đang chiếm đến 11.8s
. Nguyên do là vì ta đánh INDEX trên trường quantity
, dó đó việc SCAN sẽ thực hiện trên đó. Thời gian SCAN cũng chiếm nhiều hơn do do nó phải SCAN toàn bộ bảng với hơn 25 triệu documents, dù cho chiến lược thực thi là IXSCAN trên field quantity
. Với số lượng documents lớn thế, sau khi scan hoàn tất, MongoDB sẽ phải lọc lại những documents với status
là completed
. Rõ ràng là thời gian lọc này sẽ rất nhiều so với cách trước đó phải không nào? Vì nếu chúng ta đánh INDEX trên trường status
, MongoDB đã dựa theo INDEX và lọc ra đúng khoảng 8 triệu documents thôi, thay vì 25 triệu như bây giờ.
Vậy là phương án đánh INDEX trên trường quantity
này không tốt bằng đánh INDEX trên trường status
rồi.
4. Compound Index
Rồi, đến phần chính rồi đây. Giờ chúng ta sẽ thử cả 2 phương án nhé:
4.1. Tuân thủ ESR
Như đã phân tích, bài toán này sẽ chỉ có E và S, không có R. Dù cho vậy đi nữa, E vẫn được ưu tiên hơn S, do đó, Index mà chúng ta cần tạo theo đúng chuẩn sẽ là status
trước, sau đó đến quantity
.
Ròi, EXPLAIN lần nữa nhé:
Ồ, tốt hơn hẳn luôn nè:
- IXSCAN đã chọn INDEX mà chúng ta tạo, và chỉ quét đúng 8 triệu documents mong muốn mà thôi. Đó là nhờ phần E của chúng ta đã được xác định là
status
nhé - Tiếp theo, chúng ta cũng không thấy giai đoạn SORT đâu nữa. Đó là nhờ ta đã kết hợp phần S cho
quantity
. - Thời gian thực thi nhờ đó đã giảm xuống chỉ còn khoảng
**7.2s**
mà thôi cho 8 triệu documents
4.2. Không tuân thủ ESR
Good rồi đấy, tuân thủ quy tắc là lựa chọn đúng đắn phải không nào, nhất là khi quy tắc đó được tạo ra bởi chính "nhà sản xuất" MongoDB. Nhưng nếu bạn chưa phục, thì thử đảo ngược quy tắc xem sao nhé. Giờ ta sẽ cho S (quantity
) lên trước E (status
) nhé
Kết quả nè:
Ôi trời, các bạn thấy chưa? Trái quy tắc còn thảm họa hơn cả Single Index. Đó là lý do vì sao mình thực nghiệm trên Single Index ở trước đó nhé. Là để so sánh với cái này nè!
Các bạn nhìn vào số liệu, sẽ thấy vì sao nó cần đến gần 22s
cho câu truy vấn rồi ha. IXSCAN giờ sẽ quét full bảng với 25 triệu documents, sau đó lại FETCH dữ liệu dựa theo INDEX trái với quy tắc nên cần đến 16.3s
để hoàn thành.
5. Kết luận
Như vậy, qua phần 1 này, ta đã thực nghiệm với 2 phần trong quy tắc ESR là E và S. Bài học thì chắc chắn là chúng ta phải tuân thủ quy tắc rồi, nhưng hy vọng với những thực nghiệm trên, các bạn sẽ dễ hiểu hơn nhé.
Sẽ còn nhiều ví dụ nữa để chúng ta có thể kiểm nghiệm. Cùng chờ đón những phần tiếp theo nha.
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
Xin chào và hẹn gặp lại