Mental Model (P2/2): Hành trình của một query, và COLLSCAN
Ở phần trước: mongod chứa database, database chứa collection, và collection chứa document. Driver giữ một connection pool. Lab đã có sẵn collection orders để bạn thử.
- Cần đọc trước: Bức tranh tổng thể và ba tầng đầu
- Dẫn tới: Document Model, bài tiếp theo. Phần này là phần cuối của bài MongoDB hoạt động thế nào.
Tầng 3: hành trình của một query bên trong mongod
Giờ đi theo đúng một câu query của ứng dụng multi-tenant: "lấy các đơn đã thanh toán của tenant t042".
db.orders.find({ tenantId: "t042", status: "PAID" })Chặng 1: query layer (parse và chuẩn hoá)
mongod nhận command, kiểm tra cú pháp, rồi chuẩn hoá filter thành một dạng thống nhất. Gọi explain("queryPlanner") sẽ cho ta thấy kết quả chuẩn hoá đó mà không thực sự chạy query:
db.getSiblingDB("lab01").orders.find({ tenantId: "t042", status: "PAID" }).explain("queryPlanner")queryPlanner: {
namespace: 'lab01.orders',
parsedQuery: {
'$and': [ { status: { '$eq': 'PAID' } }, { tenantId: { '$eq': 't042' } } ]
},
winningPlan: { isCached: false, stage: 'COLLSCAN', filter: { ... }, direction: 'forward' },
rejectedPlans: [],
planCacheShapeHash: '2DA7E177'
}Filter { tenantId: ..., status: ... } đã được viết lại thành $and của hai điều kiện $eq, và thứ tự đã đổi. Vậy query layer không chạy nguyên văn những gì bạn viết, mà chạy một dạng chuẩn hoá của nó.
Chặng 2: query planner (quyết định cách tìm)
Planner nhìn vào các index đang có và liệt kê các candidate plan (cách tìm có thể dùng). Nếu có nhiều cách, MongoDB chạy thử chúng trong một khoảng ngắn và chọn cách trả được kết quả với ít công nhất. Plan thắng được lưu vào plan cache để lần sau, query cùng "hình dạng" (cùng planCacheShapeHash) dùng lại luôn, khỏi chọn lại.
[tài liệu] Từ MongoDB 8.3, với các query đủ điều kiện, nếu lần chạy thử ngắn không tìm ra plan thắng thì MongoDB có thể nhờ cost-based ranker (CBR) chấm điểm các plan bằng ước lượng chi phí; tài liệu nói hiện CBR chỉ được dùng cho một số ít query. Bài Query Planner & Plan Cache và bài về CBR sẽ quay lại chuyện này.
Với query của ta, planner không có gì để cân nhắc. Index duy nhất là _id_, mà filter không đụng tới _id. Chỉ còn đúng một cách: đọc toàn bộ collection. Vì thế rejectedPlans rỗng và winningPlan.stage là COLLSCAN.
Chặng 3: execution (thật sự đi tìm)
Plan là một cây stage. Mỗi stage xin dữ liệu từ stage con và đẩy kết quả lên stage cha. Với COLLSCAN, cây chỉ có một nút: nó xin storage engine "document kế tiếp", kiểm tra filter, giữ lại nếu khớp, rồi lặp lại cho tới hết collection.
Chặng 4: storage engine WiredTiger, cache và đĩa
Storage engine là phần của mongod chịu trách nhiệm lưu và lấy dữ liệu thật. MongoDB mặc định dùng WiredTiger. Mỗi collection và mỗi index là một "table" riêng của WiredTiger, tương ứng với một file .wt trên đĩa:
const s = db.getSiblingDB("lab01").orders.stats({ indexDetails: true });
printjson({ uri: s.wiredTiger.uri, idIndexUri: s.indexDetails._id_.uri });{
uri: 'statistics:table:collection-55e02694-e9d5-4cf6-9926-ef3d5d15be0c',
idIndexUri: 'statistics:table:index-3a90955e-6764-4ede-8b19-9f8fae506343'
}docker exec mongo-lab sh -c 'ls -l /data/db/collection-55e02694-*.wt /data/db/index-3a90955e-*.wt'-rw------- 1 mongodb mongodb 16277504 ... collection-55e02694-e9d5-4cf6-9926-ef3d5d15be0c.wt
-rw------- 1 mongodb mongodb 2011136 ... index-3a90955e-6764-4ede-8b19-9f8fae506343.wtKích thước file khớp chính xác với storageSize (16.277.504 byte) và kích thước index _id_ (2.011.136 byte) ở trên.
WiredTiger không đọc từng document riêng lẻ từ đĩa. Nó làm việc theo page, mỗi page là một khối chứa nhiều document. Ở mức đơn giản hoá [hình dung], khi execution xin một document thì:
- WiredTiger tìm page chứa document đó trong WiredTiger cache (RAM).
- Nếu có sẵn (cache hit), trả về ngay.
- Nếu không (cache miss), đọc page từ file
.wt. Lần đọc này có thể được filesystem cache của hệ điều hành phục vụ nếu OS còn giữ page đó, nếu không thì phải xuống đĩa thật. Sau đó page được giải nén và đưa vào cache.
[tài liệu] MongoDB dùng cả hai tầng cache: WiredTiger cache, và filesystem cache chiếm phần RAM trống còn lại. Hai tầng giữ dữ liệu ở hai dạng khác nhau:
| Tầng | Dạng dữ liệu collection | Ghi chú |
|---|---|---|
Đĩa (file .wt) | nén (snappy mặc định) | |
| Filesystem cache của OS | nén, giống hệt trên đĩa | OS tự quản lý |
| WiredTiger cache | chưa nén, cấu trúc riêng trong bộ nhớ | dữ liệu phải giải nén thì server mới xử lý được |
Lab của ta đặt cứng cacheSizeGB: 1; kích thước mặc định của cache và cách nó được dùng là chuyện của bài về cache và working set.
Thử nghiệm 3: query của ta lấy dữ liệu từ đâu?
WiredTiger có các bộ đếm riêng cho từng collection. Ta đọc chúng trước và sau mỗi lần chạy query. Đây là thống kê nội bộ của WiredTiger: tên trường khá tự giải thích, nhưng manual MongoDB không mô tả chi tiết từng trường, nên các con số dưới đây là [quan sát].
// q3.js
const lab = db.getSiblingDB("lab01");
const filter = { tenantId: "t042", status: "PAID" };
function wt() {
const c = lab.orders.aggregate([{ $collStats: { storageStats: {} } }]).next().storageStats.wiredTiger.cache;
return { req: c["pages requested from the cache"], readIn: c["pages read into cache"],
bytesRead: c["bytes read into cache"], inCache: c["bytes currently in the cache"] };
}
for (let run = 1; run <= 3; run++) {
const a = wt();
const es = lab.orders.find(filter).explain("executionStats").executionStats;
const b = wt();
print(JSON.stringify({ run, nReturned: es.nReturned, totalDocsExamined: es.totalDocsExamined,
executionTimeMillis: es.executionTimeMillis, pagesRequested: b.req - a.req,
pagesReadIntoCache: b.readIn - a.readIn, bytesReadIntoCache: b.bytesRead - a.bytesRead,
collBytesInCacheMB: +(b.inCache / 1048576).toFixed(1) }));
}{"run":1,"nReturned":1913,"totalDocsExamined":200000,"executionTimeMillis":33,"pagesRequested":843,"pagesReadIntoCache":0,"bytesReadIntoCache":0,"collBytesInCacheMB":52}
{"run":2,"nReturned":1913,"totalDocsExamined":200000,"executionTimeMillis":34,"pagesRequested":843,"pagesReadIntoCache":0,"bytesReadIntoCache":0,"collBytesInCacheMB":52}
{"run":3,"nReturned":1913,"totalDocsExamined":200000,"executionTimeMillis":34,"pagesRequested":843,"pagesReadIntoCache":0,"bytesReadIntoCache":0,"collBytesInCacheMB":52}Đọc từng số:
pagesRequested: 843: để quét 200.000 document, execution xin WiredTiger 843 page. Đây là "đi dọc từng tủ" được đo bằng page.pagesReadIntoCache: 0: không page nào phải đọc từ file. Toàn bộ collection đã nằm sẵn trong cache, vì ta vừa ghi nó vào và nó nhỏ hơn nhiều so với cache 1 GB. Trường hợp cold cache (cache chưa có page, phải đọc từ file và giải nén) không được đo ở đây; bài về cache sẽ đo nó.collBytesInCacheMB: 52: trong RAM, collection chiếm khoảng 52 MB, lớn hơn cả kích thước logic 47,2 MB và gấp khoảng 3,2 lần 16,3 MB trên đĩa. Đây là cái giá của dạng dữ liệu "chưa nén, cấu trúc riêng trong bộ nhớ": RAM bạn cần được tính theo kích thước sau giải nén cộng thêm phần quản lý, chứ không phải theo dung lượng file trên đĩa.
Hệ quả thực tế: con số mà ta hay nhìn ("database của tôi chỉ 16 MB trên đĩa") không cho biết cần bao nhiêu RAM để giữ phần dữ liệu được đọc thường xuyên (working set) trong cache.
Working set: thứ thật sự cần vừa cache. Ứng dụng hiếm khi đọc đều toàn bộ dữ liệu. Phần dữ liệu và index mà nó thường xuyên chạm tới (đơn hàng 30 ngày gần nhất, user đang hoạt động, các index mà query hay dùng) gọi là working set. Nếu working set nằm vừa WiredTiger cache, phần lớn lần đọc là cache hit. Nếu không, các page liên tục bị đẩy ra rồi đọc lại từ file, và độ trễ tăng theo số lần phải xuống đĩa. Vì vậy chỉ nhìn kích thước database thì nói được rất ít; điều quan trọng là working set có vừa cache hay không.
dataset (toàn bộ dữ liệu + index) ví dụ minh hoạ: 100 GB
└── working set (phần hay được chạm) 5 GB
│
▼
WiredTiger cache (một phần RAM) 1 GB
│ hit: trả ngay miss: ↓
▼
RAM còn lại (filesystem cache của OS, dạng nén)
│ miss: ↓
▼
đĩa (file .wt) ──▶ mỗi miss là một lần I/O, độ trễ tăngCác con số trong sơ đồ là minh hoạ, không đo. Ở collection 200.000 đơn của lab, collection chỉ chiếm khoảng 52 MB trong cache 1 GB nên mọi lần đọc đều là cache hit. Bài Cache, Eviction & Working Set sẽ đo trường hợp working set lớn hơn cache.
Chặng 5: đường về, theo từng batch
Kết quả không được đổ về một lần. Server trả về theo batch và driver giữ một cursor; khi bạn duyệt hết batch đang có, driver tự gửi getMore để lấy batch tiếp, mỗi lần là thêm một chuyến đi về qua mạng. Kích thước batch, getMore và thời gian sống của cursor được đo ở bài CRUD & Query Model.
Còn đường ghi thì sao?
Bài này đi theo đường đọc, nhưng đường ghi đi qua cùng các tầng đó. Ở tầng dưới cùng, WiredTiger ghi thay đổi vào cache và vào journal (write-ahead log), rồi định kỳ ghi một bản checkpoint nhất quán xuống file dữ liệu (mặc định mỗi 60 giây). Nếu mongod dừng giữa hai checkpoint, journal được dùng để phát lại những gì chưa vào checkpoint. Write concern và j:true hứa gì với ứng dụng là chuyện của bài Durability & Consistency; journal và checkpoint vận hành ra sao là chuyện của bài Journal & Checkpoint.
COLLSCAN: điều xảy ra khi không có index
Gom các số liệu ở trên lại, ta thấy rõ câu query "đơn PAID của tenant t042" đang tốn gì:
nReturned: 1913 // số document trả về
totalKeysExamined: 0 // không đọc index nào
totalDocsExamined: 200000 // đọc HẾT collection
executionTimeMillis: 33-39 // qua 15 lần đo trong bàiĐể trả về 1.913 document, MongoDB phải xem 200.000 document, khoảng 105 document bị xem rồi bỏ cho mỗi document có ích. Với 200.000 đơn trong RAM thì 33–39 ms nghe chưa đáng sợ. Nhưng công việc của COLLSCAN tăng tuyến tính theo kích thước collection, không theo số kết quả. Collection lớn gấp 10 lần thì phải quét gấp khoảng 10 lần. Đó là ước lượng chứ chưa đo, và bài CRUD & Query Model sẽ đo với 1 triệu document. Nếu dữ liệu không còn vừa cache, mỗi lần quét còn kéo theo đọc đĩa.
Hai phép so sánh giúp thấy rõ COLLSCAN to cỡ nào.
Có limit thì COLLSCAN dừng sớm:
db.getSiblingDB("lab01").orders.find({ tenantId: "t042", status: "PAID" }).limit(5).explain("executionStats")stages: LIMIT -> COLLSCAN
nReturned: 5, totalDocsExamined: 864, executionTimeMillis: 0Chỉ cần 5 kết quả nên quét 864 document là đủ. Nhưng đây là may mắn: nếu dữ liệu khớp nằm ở cuối collection, hoặc không tồn tại, nó vẫn quét hết.
Tìm theo _id, thứ duy nhất có index:
const one = lab.orders.findOne({ tenantId: "t042" }, { _id: 1 });
lab.orders.find({ _id: one._id }).explain("executionStats");winningPlan: { stage: 'EXPRESS_IXSCAN', keyPattern: '{ _id: 1 }', indexName: '_id_' }
nReturned: 1, totalKeysExamined: 1, totalDocsExamined: 1, executionTimeMillis: 0Một key, một document, không tới 1 ms. [tài liệu] EXPRESS_IXSCAN thuộc nhóm stage EXPRESS có từ MongoDB 8.0: một đường tắt cho các truy vấn rất đơn giản theo index, bỏ qua bước lập kế hoạch thông thường.
Đặt cạnh nhau, hai query khác nhau ở lượng việc chứ không chỉ ở thời gian:
QUERY THEO tenantId + status QUERY THEO _id
find({tenantId, status}) find({_id})
↓ ↓
COLLSCAN EXPRESS_IXSCAN trên _id_
↓ ↓
đọc 200.000 document 1 index key → 1 document
↓ ↓
lọc, bỏ 198.087 trả về 1
↓
trả về 1.913Query theo tenantId + status | Query theo _id | |
|---|---|---|
| Stage | COLLSCAN | EXPRESS_IXSCAN |
| Document phải xem | 200.000 | 1 |
| Thời gian | 33–39 ms | 0 ms |
| Chi phí khi collection lớn lên | tăng tuyến tính | tăng rất chậm (đi theo index) |
Trong PostgreSQL, thứ tương đương COLLSCAN là Seq Scan: không có index phù hợp thì planner chỉ còn cách đọc cả bảng. Điểm cần nhớ với MongoDB: COLLSCAN là mặc định cho mọi filter không đụng tới _id, vì ngoài _id ra MongoDB không tự tạo index nào. Nó không báo lỗi, không cảnh báo, chỉ chậm dần theo dữ liệu. Bài Index Fundamentals sẽ biến bảng trên thành câu chuyện "trước và sau khi có index".
Những hiểu lầm thường gặp
- Tạo
MongoClientmới cho mỗi request. Mỗi client có pool riêng, nên mỗi request lại phải mở connection từ đầu và server phải gánh thêm connection. Hãy tạo một client khi app khởi động và dùng chung. - Tăng
maxPoolSizeđể chữa query chậm. Nếu query chậm vì COLLSCAN, thêm connection chỉ làm nhiều COLLSCAN chạy cùng lúc hơn và giành CPU của nhau. Bài Connection Pool & Capacity sẽ đo điều này. - Nhìn dung lượng đĩa để đoán RAM cần có. Trên đĩa dữ liệu đã nén, trong WiredTiger cache thì không. 16,3 MB trên đĩa thành khoảng 52 MB trong cache.
- Nghĩ rằng thiếu index thì MongoDB sẽ báo. Không có lỗi hay cảnh báo nào. Query vẫn đúng, chỉ chậm dần theo dữ liệu. Cách trực tiếp nhất để biết là xem
explain(). - Gõ sai tên collection hay database. Ghi vào tên sai thì MongoDB lặng lẽ tạo collection mới. Đọc từ tên sai thì nhận về kết quả rỗng. Cả hai trường hợp đều không báo lỗi.
Cột mốc: Bạn đã có thể đi theo một query qua các chặng của mongod, đọc explain để thấy COLLSCAN, và hiểu vì sao thiếu index làm query chậm dần. Bài này khép lại ở đây.
Hỏi & đáp
Explain của find({ tenantId: "t042", status: "PAID" }) trong lab có rejectedPlans: []. Vì sao danh sách này rỗng?
Collection orders trong lab chiếm 16,3 MB trên đĩa và 47,2 MB dữ liệu logic. Khi nằm trọn trong WiredTiger cache, nó chiếm khoảng bao nhiêu?
find({ tenantId: "t042", status: "PAID" }).limit(5) vẫn là COLLSCAN, nhưng lab đo totalDocsExamined: 864 và 0 ms. Kết luận nào đúng?
Tiếp theo: vì sao bài này quan trọng cho Document Model
Ta đã thấy document đi từ đĩa lên cache rồi về ứng dụng, nhưng chưa hỏi chính document là gì. Vì sao đơn hàng nên chứa luôn mảng items? _id từ đâu ra? Ghi một document có phải là thao tác nguyên tử không? Và "schema linh hoạt" có nghĩa là không cần schema không?
Bài tiếp theo, "Document Model: suy nghĩ bằng document thay vì bảng", sẽ trả lời những câu hỏi đó. Bức tranh ở bài này là lý do những câu hỏi đó quan trọng. Mỗi document là đơn vị mà execution xin từ WiredTiger, và WiredTiger giữ trong cache. Document của bạn được thiết kế ra sao sẽ quyết định một query phải đọc bao nhiêu thứ, cache chứa được bao nhiêu, và mỗi lần ghi động tới đâu.