Lỗi gặp phải
MongoServerError: operation exceeded time limit
Bạn đã đặt maxTimeMS để bảo vệ ứng dụng khỏi các truy vấn chạy quá lâu. Giờ MongoDB đang dùng nó — hủy truy vấn trước khi nó hoàn thành. Phần đó hoạt động đúng như mong đợi. Vấn đề là truy vấn quá chậm ngay từ đầu.
Chín trên mười trường hợp, nguyên nhân là một trong ba điều: thiếu index, quét toàn bộ collection trên hàng triệu documents, hoặc một aggregation pipeline không được thiết kế để chạy với lượng dữ liệu lớn như vậy.
Tìm truy vấn chậm trước
Đừng đoán mò. Trước khi thay đổi bất cứ điều gì, hãy xác nhận chính xác những gì đang chạy chậm và tại sao.
Kiểm tra các thao tác đang chạy
db.currentOp({ "active": true, "secs_running": { "$gt": 5 } })
Lệnh này liệt kê tất cả những gì đã chạy hơn 5 giây. Tìm truy vấn của bạn trong kết quả và chú ý các trường ns (namespace), command, và planSummary.
Kiểm tra log truy vấn chậm
MongoDB ghi lại bất kỳ truy vấn nào vượt ngưỡng truy vấn chậm (mặc định là 100ms):
db.adminCommand({ getLog: "global" })
Hoặc theo dõi trực tiếp file log:
tail -f /var/log/mongodb/mongod.log | grep -i "slow query"
Chạy explain() trên truy vấn có vấn đề
Đây là bước chẩn đoán quan trọng nhất. Chạy truy vấn với explain("executionStats"):
db.orders.find({ status: "pending", userId: ObjectId("...") }).explain("executionStats")
Ba điều cần chú ý trong kết quả:
"stage": "COLLSCAN"— MongoDB đã quét toàn bộ collection, không dùng index nàodocsExaminedso vớinReturned— quét 2.000.000 documents để trả về 12 kết quả là vấn đề index điển hìnhexecutionTimeMillis— thời gian thực tế
Cách sửa 1: Thêm index còn thiếu (giải quyết hầu hết các trường hợp)
Kết quả COLLSCAN từ explain() có nghĩa MongoDB đang đọc từng document trong collection. Với dataset lớn, điều đó luôn chậm bất kể phần cứng ra sao. Tạo index phù hợp với điều kiện lọc và sắp xếp của truy vấn:
// Index đơn trường
db.orders.createIndex({ status: 1 })
// Index kết hợp khớp với dạng truy vấn
db.orders.createIndex({ status: 1, userId: 1 })
// Thêm trường sắp xếp để tránh bước sắp xếp trong bộ nhớ
db.orders.createIndex({ status: 1, userId: 1, createdAt: -1 })
Chạy lại explain() sau khi tạo index. Stage sẽ chuyển từ COLLSCAN sang IXSCAN, và docsExamined sẽ giảm đáng kể.
Tạo index trên collection đang chạy production
Trên MongoDB 4.2 và trước đó, việc tạo index sẽ chặn toàn bộ đọc và ghi trên collection. Dùng background: true để tránh gián đoạn dịch vụ:
db.orders.createIndex({ status: 1, userId: 1 }, { background: true })
MongoDB 4.4+ tạo index mà không chặn theo mặc định — bạn không cần tùy chọn này nữa.
Cách sửa 2: Tăng maxTimeMS để giải quyết tạm thời
Khi production đang bị ảnh hưởng và bạn cần thêm thời gian, hãy tăng timeout trong khi xử lý nguyên nhân gốc rễ.
Node.js / Mongoose
// MongoDB Node.js driver
const result = await db.collection('orders')
.find({ status: 'pending' })
.maxTimeMS(30000) // 30 giây
.toArray();
// Mongoose
const result = await Order.find({ status: 'pending' }).maxTimeMS(30000);
PyMongo
result = db.orders.find(
{"status": "pending"},
max_time_ms=30000
).to_list()
mongosh / mongo shell
db.orders.find({ status: "pending" }).maxTimeMS(30000)
Tăng giới hạn sẽ ngăn lỗi ngay lập tức nhưng truy vấn bên dưới vẫn quét toàn bộ documents. Khi tải cao, điều này chiếm hết kết nối và làm chậm mọi thứ khác đang chạy trên server. Hãy coi đây là giải pháp vá tạm thời, không phải giải pháp thực sự.
Cách sửa 3: Tối ưu hóa aggregation pipeline nặng
Các pipeline có $lookup, $group, hoặc $unwind trên các collection lớn thường là thủ phạm — đặc biệt khi các stage không được sắp xếp để lọc dữ liệu sớm.
Thêm allowDiskUse cho các aggregation tốn nhiều bộ nhớ
db.orders.aggregate(
[
{ $match: { status: "pending" } },
{ $group: { _id: "$userId", total: { $sum: "$amount" } } },
{ $sort: { total: -1 } }
],
{ allowDiskUse: true, maxTimeMS: 60000 }
)
Đưa $match và $limit lên sớm nhất có thể
// Sai — $lookup chạy trên toàn bộ collection, sau đó mới lọc
[
{ $lookup: { from: "users", ... } },
{ $match: { status: "active" } }
]
// Đúng — lọc trước, $lookup hoạt động trên tập dữ liệu nhỏ hơn
[
{ $match: { status: "active" } },
{ $lookup: { from: "users", ... } }
]
Di chuyển một $match từ stage thứ 4 lên stage thứ 1 có thể giảm tập dữ liệu làm việc đi nhiều bậc độ lớn trước khi bất kỳ phép join hoặc nhóm tốn kém nào chạy.
Cách sửa 4: Hủy thao tác bị treo
Đang chạy và chặn các truy vấn khác? Hủy nó đi:
// Tìm opid
db.currentOp({ "active": true })
// Hủy theo opid
db.killOp(12345)
Xác nhận bản sửa lỗi
- Chạy lại
explain("executionStats")— xác nhận stage bây giờ làIXSCAN, không phảiCOLLSCAN - Kiểm tra
docsExaminedgần bằngnReturned(ví dụ: examined 14, returned 12) - Chạy truy vấn thực tế với giá trị
maxTimeMSban đầu của bạn — nó sẽ hoàn thành bình thường - Theo dõi
db.currentOp()trong vài phút để xác nhận không còn truy vấn chạy lâu nào
Xác nhận index đang được sử dụng
db.orders.aggregate([
{ $indexStats: {} }
])
Index mới sẽ xuất hiện với số accesses.ops tăng dần khi các truy vấn sử dụng nó.
Phòng tránh sự cố này trong tương lai
- Chạy explain() khi đang viết truy vấn, không phải sau khi chúng hỏng trên production — phát hiện COLLSCAN trong quá trình phát triển không tốn gì; phát hiện lúc 2 giờ sáng thì tốn nhiều hơn nhiều
- Bật profiler truy vấn chậm:
db.setProfilingLevel(1, { slowms: 100 })— các truy vấn chậm sẽ ghi vàosystem.profileđể xem xét trước khi chúng trở nên nghiêm trọng - Tuân theo quy tắc ESR cho index kết hợp — Equality (bằng nhau) trước, rồi đến Sort (sắp xếp), rồi Range (khoảng); thứ tự các trường trong index quan trọng không kém việc chọn trường nào
- Phân trang các tập kết quả lớn — lấy 50.000 documents trong một lần gọi luôn chậm dù index có tốt đến đâu; dùng
limit()và phân trang dựa trên cursor - Định kỳ kiểm tra các index không dùng — mỗi index không dùng làm chậm mọi thao tác ghi; xóa chúng bằng
db.collection.dropIndex()sau khi xác nhận không còn hoạt động

