MongoDB hoạt động thế nào: bức tranh tổng thể
Phần lớn chúng ta bắt đầu với MongoDB theo cùng một cách: cài driver, gọi insertOne, gọi find, thấy dữ liệu trả về, và thế là xong. Mọi thứ chạy ổn cho tới ngày collection có vài triệu document. Lúc đó một API vốn mất 20 ms bỗng mất 2 giây (con số minh hoạ), connection bị dồn ứ, RAM của server lúc nào cũng gần đầy, và ta không biết bắt đầu tìm nguyên nhân từ đâu.
Thường thì vấn đề không nằm ở cú pháp. Ta thiếu một bức tranh tổng thể: một câu query đi qua những chặng nào, mỗi chặng tốn gì, và chặng nào có thể nghẽn. Bài mở đầu này vẽ bức tranh đó. Các bài sau sẽ phóng to từng mảng.
Series này dành cho ai
Series dành cho backend developer đã viết API, đã dùng qua một database (thường là SQL) và muốn hiểu MongoDB ở mức đủ sâu để tự đưa ra quyết định chứ không chỉ copy ví dụ.
Đọc hết series, bạn sẽ:
- Thiết kế được document và chọn embed hay reference dựa trên cách ứng dụng đọc ghi dữ liệu.
- Đọc được output của
explain()và biết vì sao một query chậm. - Chọn được index phù hợp cho một query cụ thể.
- Hiểu WiredTiger, cache, replication và sharding đủ để vận hành MongoDB trên production.
Mỗi con số trong series đều được đo thật trên môi trường lab dưới đây, trừ khi được ghi rõ là minh hoạ.
Môi trường lab: MongoDB 8.3.11 chạy trong Docker (image
mongo:8), giới hạn 4 CPU, 4 GB RAM, WiredTiger cache 1 GB, máy host Apple M4. Bài này dùng database riêng tênlab01.
Bạn đang ở đâu trong series
- Cần biết trước: lập trình backend cơ bản, một chút SQL.
- Bài này giới thiệu:
mongod, database, collection, document, driver, đường đi của một query (driver → query layer → query planner → execution → storage engine → cache/đĩa), và COLLSCAN, thứ xảy ra mặc định khi không có index phù hợp. Bài cũng cho cái nhìn đầu tiên về connection pool (bài 34 đi sâu) và working set (bài 21 đi sâu). - Dẫn tới: bài 02, document thật sự là gì.
Để phân biệt mức độ chắc chắn của từng thông tin, bài dùng ba nhãn: [tài liệu] là hành vi được tài liệu chính thức mô tả, [quan sát] là điều đo được trong lab nhưng không phải cam kết của MongoDB, và [hình dung] là mô hình đơn giản hoá để dễ nhớ.
Giải thích trong 30 giây
Ứng dụng của bạn không nói chuyện trực tiếp với dữ liệu. Nó gọi driver. Driver mượn một connection có sẵn trong pool và gửi lệnh tới mongod. Ở đó, query planner quyết định cách tìm, execution thực hiện việc tìm, và storage engine WiredTiger lấy dữ liệu từ cache trong RAM, hoặc từ đĩa nếu cache không có. Không có index phù hợp thì cách tìm duy nhất là đọc hết collection: COLLSCAN.
Hình dung trước: một kho lưu trữ hồ sơ
Trước khi nói thuật ngữ, hãy hình dung một kho lưu trữ hồ sơ của một công ty lớn.
- Cả toà nhà là mongod, tức tiến trình server MongoDB.
- Mỗi tầng là một database.
- Mỗi tủ hồ sơ trên một tầng là một collection.
- Mỗi tập hồ sơ trong tủ là một document. Một tập hồ sơ tự chứa đủ thông tin của nó, kể cả các tờ phụ lục kẹp bên trong (dữ liệu lồng nhau). Ta không phải chạy sang tủ khác để ghép mới đọc được.
Bạn (ứng dụng) không tự vào kho. Bạn gửi yêu cầu qua một đội shipper riêng của công ty mình (driver). Đội này có sẵn một số xe đang nổ máy (connection pool) để khỏi phải mua xe mới mỗi lần đi.
Tới quầy lễ tân, yêu cầu được đọc và chuẩn hoá (query layer). Một trưởng ca quyết định cách tìm: tra sổ mục lục hay đi dọc từng tủ (query planner). Rồi nhân viên đi lấy hồ sơ theo đúng kế hoạch đó (execution).
Hồ sơ nằm ở đâu? Hồ sơ hay dùng được bày sẵn, mở sẵn trên một dãy bàn lớn cạnh quầy (WiredTiger cache trong RAM). Phần còn lại được đóng thùng hút chân không cho gọn và cất trong kho sau (đĩa, dữ liệu đã nén). Lấy từ bàn thì nhanh. Phải vào kho sau, mở thùng và trải ra bàn thì chậm hơn nhiều.
Còn một chi tiết sẽ theo ta suốt series: mặc định kho chỉ có một cuốn sổ mục lục, đánh theo mã hồ sơ (_id). Nếu bạn hỏi "đưa tôi mọi hồ sơ của khách hàng t042 đã thanh toán", nhân viên không có mục lục nào cho câu hỏi đó. Họ phải đi dọc từng tủ và lật từng tập. Trong MongoDB, việc đó có tên là COLLSCAN.
[hình dung] Đây chỉ là cách hình dung. MongoDB thực tế không hoạt động chính xác như vậy. Trong MongoDB, "bàn" và "kho" là cùng một dữ liệu ở hai dạng, do WiredTiger tự chuyển qua lại chứ không phải bạn sắp xếp. Nhân viên cũng không bê từng tập hồ sơ mà bê cả một chồng (một page). Nhưng khung này đủ dùng cho cả bài.
Mô hình tư duy trong một sơ đồ
Đây là toàn bộ đường đi của một câu find. Phần còn lại của bài đi lần lượt qua từng tầng của sơ đồ này, có số đo đi kèm.
Ứng dụng của bạn (Go / Node / Java ...)
| orders.find({ tenantId: "t042", status: "PAID" })
v
+------------------- DRIVER (chạy trong process của app) -------------+
| 1. đóng gói lệnh thành BSON: { find: "orders", filter: {...} } |
| 2. mượn 1 connection từ pool: [c1] [c2] [c3] ... |
| 3. gửi qua TCP (wire protocol, OP_MSG, cổng 27017) |
+--------------------------------------------------------------------+
| ^
v | kết quả theo từng batch (BSON)
+------------------------------ MONGOD ------------------------------+
| Query layer : parse, chuẩn hoá filter |
| Query planner : có index nào dùng được? -> chọn plan |
| Execution : chạy cây stage (COLLSCAN, IXSCAN, FETCH, LIMIT...)|
| | "cho tôi document kế tiếp" |
| v |
| Storage engine: WIREDTIGER |
| WiredTiger cache (RAM, dữ liệu collection ở dạng chưa nén) |
| | page chưa có trong cache? |
+--------|-----------------------------------------------------------+
v
filesystem cache của OS (dạng nén) -> đĩa (file .wt, nén snappy)Nhớ ba ý là đủ cho bài này:
- Driver không chỉ là "thư viện gọi API". Nó quản lý connection, theo dõi server và lấy kết quả về theo từng batch.
- mongod tách việc quyết định cách tìm (planner) khỏi việc thật sự tìm (execution).
- WiredTiger quyết định dữ liệu đang ở RAM hay phải đọc từ đĩa. Chuyện "nhanh hay chậm" phần lớn được định đoạt bởi hai thứ: plan mà planner chọn (phải đọc bao nhiêu), và dữ liệu cần đọc có sẵn trong cache hay không.
Tầng 1: mongod, database, collection, document
Bốn khái niệm, một cấu trúc lồng nhau
- mongod là tiến trình server. Một mongod chứa nhiều database.
- Database là một nhóm collection có tên (ví dụ
lab01). - Collection là một nhóm document. Đây là thứ gần nhất với "bảng" trong SQL.
- Document là một bản ghi dạng BSON, tức một dạng nhị phân của JSON có thêm kiểu dữ liệu như
Date,ObjectId, số nguyên 64-bit... (bài 03 sẽ mổ xẻ BSON).
Nếu bạn quen PostgreSQL, bảng dưới đây giúp định vị nhanh. Đừng coi nó là phép dịch một-một: điểm khác nhau mới là phần quan trọng.
| PostgreSQL | MongoDB | Khác nhau ở đâu |
|---|---|---|
server (process postgres) | mongod | |
| database | database | |
| table | collection | collection không bắt buộc khai báo cột trước |
| row | document | document có thể chứa object lồng nhau và mảng |
| column | field | hai document trong cùng collection có thể có field khác nhau |
CREATE TABLE trước khi INSERT | tự tạo khi ghi lần đầu | xem thử nghiệm bên dưới |
Thử nghiệm 1: database và collection được tạo "ngầm"
Với PostgreSQL, bạn phải CREATE TABLE rồi mới INSERT được. MongoDB thì khác. Hãy xem danh sách database trước và sau một lệnh insertOne duy nhất:
// s1.js
print("before:", db.getSiblingDB("admin").adminCommand({ listDatabases: 1, nameOnly: true })
.databases.map(d => d.name).join(","));
const lab = db.getSiblingDB("lab01");
print("collections in lab01:", JSON.stringify(lab.getCollectionNames()));
lab.orders.insertOne({ tenantId: "t001", userId: "u00001", status: "PAID", amount: 250000,
createdAt: new Date("2026-01-15T08:30:00Z"), items: [{ sku: "SKU-1", qty: 2 }] });
print("after:", db.getSiblingDB("admin").adminCommand({ listDatabases: 1, nameOnly: true })
.databases.map(d => d.name).join(","));
print("collections in lab01:", JSON.stringify(lab.getCollectionNames()));docker exec -i mongo-lab mongosh --quiet < s1.jsbefore: admin,config,local
collections in lab01: []
after: admin,config,lab01,local
collections in lab01: ["orders"]Không có lệnh "create" nào, nhưng cả database lab01 lẫn collection orders đều đã xuất hiện. [tài liệu] Database và collection được tạo khi bạn lưu dữ liệu vào lần đầu (createIndex() cũng tạo collection nếu nó chưa có).
Cái giá của sự tiện lợi này: gõ sai một chữ, ví dụ db.oders.insertOne(...), MongoDB sẽ vui vẻ tạo collection oders mà không báo lỗi gì. Muốn có kỷ luật về cấu trúc dữ liệu, bạn phải tự thêm (schema validation, bài 02 sẽ nói).
Thử nghiệm 2: một bộ dữ liệu thật để soi
Thiết lập cho mọi thử nghiệm phía sau:
MongoDB version : 8.3.11 (standalone, Docker image mongo:8)
Hardware : 4 CPU, 4 GB RAM (giới hạn của container), host Apple M4
Configuration : storage.wiredTiger.engineConfig.cacheSizeGB = 1
Dataset : lab01.orders, 200.000 document, ~236 byte/document
Indexes : chỉ có _id_ (mặc định)Để có thứ mà đo, ta sinh 200.000 đơn hàng cho một hệ thống multi-tenant: 50 tenant, 20.000 user, trạng thái phân bố lệch về PAID, thời gian rải trong 240 ngày từ đầu năm 2026. Seed cố định nên chạy lại vẫn ra cùng dữ liệu.
// gen.js
const lab = db.getSiblingDB("lab01");
lab.orders.drop();
let seed = 42;
function rnd() { seed = (seed * 1103515245 + 12345) % 2147483648; return seed / 2147483648; }
const statuses = ["PENDING","PAID","PAID","PAID","SHIPPED","SHIPPED","CANCELLED"];
const N = 200000, B = 5000;
const start = new Date("2026-01-01T00:00:00Z").getTime();
const t0 = Date.now();
for (let b = 0; b < N / B; b++) {
const docs = [];
for (let i = 0; i < B; i++) {
const t = Math.floor(rnd() * 50) + 1;
const nItems = Math.floor(rnd() * 4) + 1;
const items = [];
for (let k = 0; k < nItems; k++) items.push({ sku: "SKU-" + (Math.floor(rnd()*2000)+1),
qty: Math.floor(rnd()*3)+1, price: (Math.floor(rnd()*50)+1)*10000 });
docs.push({
tenantId: "t" + String(t).padStart(3, "0"),
userId: "u" + String(Math.floor(rnd() * 20000) + 1).padStart(5, "0"),
status: statuses[Math.floor(rnd() * statuses.length)],
amount: items.reduce((s, it) => s + it.qty * it.price, 0),
createdAt: new Date(start + Math.floor(rnd() * 240 * 86400000)),
items
});
}
lab.orders.insertMany(docs, { ordered: false });
}
print("inserted", lab.orders.countDocuments(), "in", Date.now() - t0, "ms");docker exec -i mongo-lab mongosh --quiet --eval "$(cat gen.js)"inserted 200000 in 3460 msMột document trông như sau:
{
_id: ObjectId('6ac7116fa428c425f868dfd8'),
tenantId: 't030',
userId: 'u19941',
status: 'SHIPPED',
amount: 1450000,
createdAt: ISODate('2026-06-11T03:09:31.179Z'),
items: [
{ sku: 'SKU-1830', qty: 3, price: 380000 },
{ sku: 'SKU-1379', qty: 2, price: 80000 },
{ sku: 'SKU-99', qty: 1, price: 150000 }
]
}Để ý mảng items nằm ngay trong đơn hàng. Ở PostgreSQL bạn sẽ có bảng order_items riêng và một phép JOIN. Ở đây một lần đọc là đủ cả đơn. Đó là lý do bài 02 và 04 sẽ dành nhiều thời gian cho việc "nên đặt gì vào trong document".
Giờ hỏi collection xem nó chiếm bao nhiêu chỗ:
db.getSiblingDB("lab01").orders.aggregate([{ $collStats: { storageStats: {} } }]){
count: 200000,
size: 47221803, // kích thước logic (BSON chưa nén), byte
avgObjSize: 236, // byte / document
storageSize: 16277504, // dung lượng file trên đĩa, byte
nindexes: 1,
indexSizes: { _id_: 2011136 }
}Có hai điều đáng chú ý:
- Dữ liệu logic 47,2 MB, nhưng trên đĩa chỉ 16,3 MB (nhỏ hơn khoảng 2,9 lần). [tài liệu] Mặc định WiredTiger nén collection bằng snappy khi ghi xuống đĩa. Tỉ lệ 2,9 lần là [quan sát] cho bộ dữ liệu này, dữ liệu khác sẽ nén khác.
- Chỉ có một index:
_id_. MongoDB tự tạo index trên_idcho mọi collection, và chỉ thế thôi. Không có index nào trêntenantIdhaystatus. Hãy nhớ chi tiết này, ta sẽ cần nó ở cuối bài.
Tầng 2: driver và connection pool
Driver làm nhiều hơn bạn tưởng
Driver là thư viện MongoDB chính thức cho ngôn ngữ của bạn (Go, Node.js, Java, Python...). Nó chạy bên trong process của ứng dụng. Khi bạn gọi collection.find(filter), driver:
- Biến lời gọi thành một command dạng BSON, ví dụ
{ find: "orders", filter: {...} }. - Mượn một connection TCP từ connection pool.
- Gửi command qua wire protocol của MongoDB. Đây là giao thức request–response trên TCP (cổng mặc định 27017), trong đó mọi request và reply hiện nay đều dùng một loại message tên là
OP_MSG. - Nhận về batch kết quả đầu tiên, trả connection về pool, và đi lấy tiếp các batch sau khi bạn duyệt cursor.
Ngoài ra driver còn liên tục theo dõi server bằng lệnh hello, để biết server nào là primary và server cho phép những giới hạn gì (document lớn nhất 16 MiB, message lớn nhất, số thao tác tối đa trong một batch ghi...). Bạn có thể tự gọi db.adminCommand({ hello: 1 }) để thấy những gì driver nhìn thấy.
Connection pool: nhìn lướt
Mở một connection mới không rẻ (bắt tay TCP, có thể thêm TLS, hello, xác thực), nên driver giữ sẵn một connection pool. [tài liệu] Mỗi MongoClient có pool riêng, maxPoolSize mặc định là 100, và khi pool đã dùng hết thì request tiếp theo xếp hàng chờ ở phía app. Tài liệu cũng khuyến nghị mỗi ứng dụng tạo một MongoClient và dùng chung. [hình dung] Hãy nghĩ tới một nhà hàng có 4 đầu bếp và vài quầy order: thêm quầy giúp khách bớt đứng chờ, nhưng không thêm đầu bếp. Pool là một giới hạn đồng thời, không phải nút tăng tốc: khi CPU của server đã bận hết, thêm connection chỉ làm các query giành giật nhau.
request của app ──▶ [ hàng chờ (wait queue) ] ◀── phía app, trong driver
│ mượn connection
▼
pool: [c1] [c2] ... [cN] N ≤ maxPoolSize
│
▼
mongod: 4 CPU làm việc thật ◀── phía serverChọn maxPoolSize thế nào, đo độ trễ chờ connection, và tính số connection khi nhân với số instance của app là chủ đề của bài 34 (Connection Pool & Capacity).
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 10 (planner và plan cache) và bài 14 (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 21.
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 cache "lạnh" (page phải đọc từ file và giải nén) không được đo ở đây; bài 21 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ữ dữ liệu nóng 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 21 (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 06 (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 18; journal và checkpoint vận hành ra sao là chuyện của bài 22.
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 06 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õ "hình dạng" của COLLSCAN.
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. Index fundamentals (bài 07) 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 34 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.
Tóm tắt: mang theo gì sang bài sau
- mongod → database → collection → document. Database và collection được tạo ngầm khi ghi lần đầu, tiện nhưng dễ tạo nhầm.
- Driver chạy trong app: đóng gói command thành BSON, gửi qua wire protocol (
OP_MSG), theo dõi server bằnghello, và quản lý connection pool (mặc định tối đa 100 connection mỗiMongoClient). Pool là giới hạn đồng thời, không phải nút tăng tốc (bài 34). - Đường đi của một query: query layer chuẩn hoá → planner chọn plan (và cache plan) → execution chạy cây stage → WiredTiger lấy page từ cache, hoặc từ file khi cache miss → kết quả về theo batch, driver lấy tiếp bằng
getMore. - Dữ liệu có ba dạng: nén trên đĩa và trong filesystem cache, chưa nén trong WiredTiger cache. Trong lab, 16,3 MB trên đĩa chiếm khoảng 52 MB trong cache.
- Working set (phần dữ liệu và index hay được chạm tới) có vừa cache hay không quan trọng hơn kích thước database (bài 21).
- COLLSCAN là điều xảy ra khi không có index phù hợp: 200.000 document bị xem để trả về 1.913.
Tự kiểm tra
Nếu trả lời được bốn câu này mà không cần lật lại bài, bạn đã có mô hình tư duy cần thiết:
- Ứng dụng có 50 request đồng thời nhưng
maxPoolSizelà 10. 40 request còn lại ở đâu, và chúng chờ ở phía app hay phía server? (Chờ trong hàng đợi của driver, phía app.) - Vì sao
rejectedPlanscủa querytenantId + statuslại rỗng? (Không có index nào dùng được, nên chỉ có một plan: COLLSCAN.) - Collection 16 MB trên đĩa. Có thể kết luận nó cần khoảng 16 MB RAM để nằm gọn trong WiredTiger cache không? (Không. Trong cache dữ liệu chưa nén. Lab đo được khoảng 52 MB.)
- Vì sao
find(...).limit(5)có thể nhanh dù là COLLSCAN, và khi nào thì nó hết nhanh? (Nó dừng khi đủ 5 kết quả. Nếu kết quả hiếm hoặc nằm cuối collection, nó vẫn phải quét gần hết.)
Tiếp theo: vì sao bài này quan trọng cho bài 02
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 02, "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.