Nếu bạn đang làm việc với MongoDB mà chưa khai thác triệt để index, rất có thể bạn đang để hiệu năng rơi rụng trên sàn. Tin vui: chỉ cần 7 bài tập nhỏ, bạn sẽ tự mình hiểu được cách thiết kế index hiệu quả, đọc explain như một thói quen, và biết cách tránh những cái bẫy thường gặp khiến CPU và I/O đội lên vô nghĩa. Bài viết này là lộ trình tự học thực hành, có dữ liệu mẫu, câu truy vấn cụ thể, chỉ số cần đo và lời giải có lý do phía sau.
Chúng ta sẽ mô phỏng một ứng dụng thương mại điện tử với 3 collection chính: users, products, orders. Bạn có thể dùng MongoDB Atlas hoặc cài mongod cục bộ. Dưới đây là script khởi tạo dữ liệu tối giản (mongosh):
use shop
// Users
for (let i = 1; i <= 50000; i++) {
db.users.insertOne({
_id: i,
name: 'User ' + i,
email: 'user' + i + '@example.com',
createdAt: new Date(2022, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1)
})
}
// Products
const categories = ['electronics','beauty','books','home','toys','fashion']
const tagsPool = ['new','sale','popular','eco','premium','budget','gift','wireless','portable','classic']
for (let i = 1; i <= 200000; i++) {
const tags = Array.from({length: Math.floor(Math.random()*4)+1}, () => tagsPool[Math.floor(Math.random()*tagsPool.length)])
db.products.insertOne({
_id: i,
sku: 'SKU' + i,
name: 'Product ' + i,
category: categories[Math.floor(Math.random()*categories.length)],
price: Math.round(Math.random()*100000)/100,
active: Math.random() < 0.85,
tags: tags,
createdAt: new Date(2023, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1)
})
}
// Orders
for (let i = 1; i <= 1000000; i++) {
const cId = Math.floor(Math.random()*50000) + 1
const statusArr = ['PLACED','PAID','SHIPPED','DELIVERED','CANCELLED']
db.orders.insertOne({
_id: i,
customerId: cId,
items: [
{ productId: Math.floor(Math.random()*200000)+1, qty: Math.ceil(Math.random()*3), price: Math.round(Math.random()*100000)/100 },
{ productId: Math.floor(Math.random()*200000)+1, qty: Math.ceil(Math.random()*3), price: Math.round(Math.random()*100000)/100 }
],
status: statusArr[Math.floor(Math.random()*statusArr.length)],
createdAt: new Date(2023, Math.floor(Math.random()*12), Math.floor(Math.random()*28)+1)
})
}
Nếu dữ liệu quá lớn với máy bạn, hãy giảm số lượng tuỳ ý. Quan trọng là có đủ độ lệch phân phối giữa các trường.
Mục tiêu chung trong 7 bài tập:
Tóm tắt, đủ dùng cho thực hành:
Với những nguyên lý này, ta bước vào 7 bài tập.
Bài toán: Hiển thị 30 đơn hàng gần nhất của một khách hàng, dùng phân trang theo thời gian.
Truy vấn baseline:
const customerId = 1234
const pageSize = 30
const cursor = db.orders.find({ customerId: customerId })
.sort({ createdAt: -1 })
.limit(pageSize)
cursor.explain('executionStats')
Kỳ vọng baseline: Nếu chưa có index phù hợp, planner có thể chọn COLLSCAN hoặc dùng index kém hiệu quả, dẫn đến sort in-memory. Dấu hiệu trong explain:
Thiết kế index: Vì customerId là equality và sort theo createdAt giảm dần, index hợp lý là { customerId: 1, createdAt: -1 }.
db.orders.createIndex({ customerId: 1, createdAt: -1 }, { name: 'idx_orders_customer_created_desc' })
Chạy lại explain:
db.orders.find({ customerId: customerId })
.sort({ createdAt: -1 })
.limit(pageSize)
.explain('executionStats')
Kết quả tốt khi thấy:
Mẹo nâng cao:
Sai lầm hay gặp:
Bài tập tự làm: Thêm filter theo khoảng thời gian createdAt từ ngày A đến B, đo tác động đến totalKeysExamined.
Bài toán: Trang quản trị cần danh sách đơn hàng với cột _id, status, createdAt. Không cần toàn bộ document.
Truy vấn baseline:
db.orders.find(
{ status: { $in: ['PAID','SHIPPED','DELIVERED'] } },
{ _id: 1, status: 1, createdAt: 1 }
).sort({ createdAt: -1 }).limit(50).explain('executionStats')
Nếu chỉ có index ở bài 1, planner có thể dùng { customerId, createdAt } và phải FETCH để đọc status, vì status không nằm trong index. Giải pháp: tạo index bao phủ đúng bộ trường query, sort, projection.
db.orders.createIndex(
{ status: 1, createdAt: -1, _id: 1 },
{ name: 'idx_orders_status_created_cover' }
)
Lý do thêm _id: Projection có _id, nên đưa _id vào index giúp truly covered; nếu không, MongoDB vẫn có thể lấy _id từ index mặc định, nhưng khi compound khác thứ tự, có thể không cover đầy đủ trường hợp sort.
Kiểm tra explain sau khi thêm index:
Nhận xét thiết kế:
Bài tập tự làm: Thử bỏ _id khỏi projection và index để xem tác động; đo chênh lệch thời gian.
Bài toán: Trong products, 85% sản phẩm active: true. Đa số truy vấn frontend chỉ hiển thị sản phẩm đang bán. Tạo index toàn bộ trên active sẽ không chọn lọc tốt. Partial index giúp giảm kích thước và tăng hiệu quả.
Truy vấn điển hình:
db.products.find(
{ active: true, category: 'electronics', price: { $gte: 500, $lte: 1500 } }
).sort({ price: 1 }).limit(20).explain('executionStats')
Thiết kế index với partialFilterExpression chỉ index document active: true.
db.products.createIndex(
{ category: 1, price: 1 },
{ name: 'idx_products_active_category_price', partialFilterExpression: { active: true } }
)
Vì filter luôn có active: true, planner có thể chọn index nhỏ gọn chỉ chứa tập con cần thiết. Sắp xếp theo price tăng dần đồng bộ với index.
Lý do không đưa active vào key của index: Trường nhị phân ít phân biệt, thường không giúp nhiều. Hơn nữa, partial đã định nghĩa tập index rồi.
Kiểm chứng:
Sai lầm hay gặp:
Bài tập tự làm: Thử so sánh với sparse index trên active và phân tích vì sao partial linh hoạt hơn (support biểu thức điều kiện phức tạp như price > 0).
Bài toán: Mục lọc sản phẩm theo tags và khoảng giá. Document có trường tags là mảng, ví dụ ['sale','wireless','portable'].
Truy vấn điển hình:
db.products.find(
{ tags: { $all: ['wireless','portable'] }, price: { $lte: 300 } }
).sort({ price: 1 }).limit(30).explain('executionStats')
Thiết kế index multi-key:
db.products.createIndex(
{ tags: 1, price: 1 },
{ name: 'idx_products_tags_price' }
)
Giải thích:
Kiểm tra explain:
Góc nâng cao:
Cái bẫy phổ biến:
Bài tập tự làm: So sánh hiệu năng giữa $all và $in kết hợp pipeline $group để lọc đủ 2 tag, và sự khác nhau khi dùng index.
Bài toán: Ô tìm kiếm sản phẩm theo name và mô tả. Muốn sắp xếp theo độ liên quan (textScore) và fallback theo bán chạy nếu điểm ngang nhau.
Chuẩn bị: thêm trường description tối giản (nếu chưa có, bạn có thể update ngẫu nhiên). Tạo text index:
db.products.createIndex(
{ name: 'text', description: 'text' },
{ name: 'idx_products_text_name_desc', default_language: 'english' }
)
Truy vấn:
const q = 'wireless portable speaker'
db.products.find(
{ $text: { $search: q } },
{ score: { $meta: 'textScore' }, name: 1, price: 1 }
).sort({ score: { $meta: 'textScore' } }).limit(20).explain('executionStats')
Quan sát:
db.products.createIndex(
{ active: 1, _fts: 'text', _ftsx: 1 },
{ name: 'idx_products_text_active', weights: { name: 10, description: 2 } }
)
Lưu ý: Cú pháp hệ thống _fts là internal representation; thực tế, khi khai báo, bạn ghi { active: 1, name: 'text', description: 'text' }. Mongo sẽ chuyển thành cấu trúc đặc biệt. Chỉ nên thêm active nếu bạn luôn filter active.
Tối ưu trải nghiệm:
Cái bẫy cần tránh:
Bài tập tự làm: So sánh two-phase query: $text để lấy _id top 200 theo score, sau đó $lookup hoặc fetch bổ sung để sắp xếp thứ cấp theo soldCount.
Bài toán: Tìm cửa hàng gần vị trí người dùng trong bán kính 5km, ưu tiên danh mục cụ thể và sắp xếp theo khoảng cách.
Tạo collection stores:
for (let i = 1; i <= 20000; i++) {
db.stores.insertOne({
_id: i,
name: 'Store ' + i,
location: { type: 'Point', coordinates: [105.8 + Math.random()*0.5, 21.0 + Math.random()*0.5] },
category: ['pickup','showroom','service'][Math.floor(Math.random()*3)],
open: Math.random() < 0.9
})
}
db.stores.createIndex({ location: '2dsphere', category: 1, open: 1 }, { name: 'idx_stores_geo_category_open' })
Truy vấn gần nhất:
const userLng = 105.85
const userLat = 21.02
db.stores.find({
location: {
$near: {
$geometry: { type: 'Point', coordinates: [userLng, userLat] },
$maxDistance: 5000
}
},
category: 'pickup',
open: true
}, { name: 1, location: 1 })
.limit(20)
.explain('executionStats')
Giải thích:
Mẹo nâng cao:
Sai lầm phổ biến:
Bài tập tự làm: Thêm filter theo giờ mở cửa (ví dụ openAt/closeAt), cân nhắc design index và cách precompute trường isOpenNow cho truy vấn thực tế.
Bài toán: Collection sessions lưu token đăng nhập với thời hạn 30 phút không hoạt động (rolling expiration) hoặc hết hạn cứng sau 7 ngày.
Thiết lập dữ liệu:
for (let i = 1; i <= 200000; i++) {
db.sessions.insertOne({
_id: i,
userId: Math.floor(Math.random()*50000)+1,
token: 't' + i,
lastSeenAt: new Date(Date.now() - Math.floor(Math.random()*60*60*1000)),
createdAt: new Date(Date.now() - Math.floor(Math.random()*7*24*60*60*1000))
})
}
TTL rolling theo lastSeenAt:
db.sessions.createIndex(
{ lastSeenAt: 1 },
{ name: 'idx_sessions_ttl_lastSeen', expireAfterSeconds: 1800 }
)
Điều này đảm bảo document tự động bị xoá sau 30 phút kể từ lastSeenAt. Lưu ý:
Hết hạn cứng sau 7 ngày: Bạn có thể chạy job định kỳ, hoặc lưu thêm trường expireAt và dùng TTL theo thời điểm tuyệt đối.
db.sessions.updateMany({}, [ { $set: { expireAt: { $add: ['$createdAt', 7*24*3600*1000] } } } ])
db.sessions.createIndex(
{ expireAt: 1 },
{ name: 'idx_sessions_ttl_absolute', expireAfterSeconds: 0 }
)
Thực hành kiểm chứng:
Sai lầm thường gặp:
Bài tập tự làm: Tối ưu sessions bằng cách chỉ index các trường cần thiết khác như userId để tra cứu nhanh, và đo chi phí ghi khi mỗi request cập nhật lastSeenAt.
Để mỗi bài tập thực sự đọng lại, hãy quen thuộc với explain. Một checklist ngắn:
Ví dụ mẫu tóm lược explain tối ưu:
winningPlan: {
stage: FETCH,
inputStage: {
stage: IXSCAN,
indexName: 'idx_orders_customer_created_desc',
direction: 'backward',
indexBounds: { customerId: [1234,1234], createdAt: [MinKey,MaxKey] }
}
}
executionStats: {
nReturned: 30,
totalDocsExamined: 30,
totalKeysExamined: 30,
executionTimeMillis: 3
}
Trường hợp xấu cần sửa:
Đây là phần tổng hợp những kinh nghiệm nhỏ nhưng rất đáng giá khi triển khai ở môi trường thật:
Checklist triển khai index an toàn:
Để vượt qua mức cơ bản và làm chủ index trong hệ thống của bạn, hãy mở rộng 7 bài tập theo hướng sau:
Khi bạn hoàn tất những mở rộng này, bạn sẽ không chỉ biết tạo index, mà còn biết quản trị vòng đời index như một phần của vận hành hệ thống.
Hãy quay lại nhìn 7 bài tập: bạn đã học cách giải quyết các truy vấn phổ biến nhất trong ứng dụng — từ danh sách đơn hàng theo thời gian, màn hình quản trị cần covered query, lọc sản phẩm theo tags và khoảng giá, tìm kiếm toàn văn với độ liên quan hợp lý, bản đồ địa điểm gần bạn, cho tới dọn dẹp phiên đăng nhập bằng TTL. Mỗi bài đặt bạn trước một quyết định thiết kế index khác nhau, và chính explain là người bạn đồng hành trung thực nhất.
Từ đây, điều quan trọng nhất là thói quen: bất kỳ khi nào bạn viết một truy vấn mới, hãy nghĩ về index; bất kỳ khi nào bạn thấy CPU hoặc I/O tăng đột ngột, hãy mở explain và profiler. Sự khác biệt giữa một hệ thống ì ạch và một hệ thống mượt mà đôi khi chỉ là một compound index đúng chỗ, đúng thứ tự. Chúc bạn luyện tập vui vẻ và tối ưu được những phần trăm hiệu năng xứng đáng!