Mental Model (P2/2): Hành trình của một query, và COLLSCAN

11 phút đọcSeries: MongoDB: từ gốc đến internals

Ở 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ử.

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.wt

Kí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ì:

  1. WiredTiger tìm page chứa document đó trong WiredTiger cache (RAM).
  2. Nếu có sẵn (cache hit), trả về ngay.
  3. 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ầngDạng dữ liệu collectionGhi chú
Đĩa (file .wt)nén (snappy mặc định)
Filesystem cache của OSnén, giống hệt trên đĩaOS tự quản lý
WiredTiger cachechư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ăng

Cá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: 0

Chỉ 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: 0

Mộ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.913
Query theo tenantId + statusQuery theo _id
StageCOLLSCANEXPRESS_IXSCAN
Document phải xem200.0001
Thời gian33–39 ms0 ms
Chi phí khi collection lớn lêntăng tuyến tínhtă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 MongoClient mớ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?

  1. Planner đã chạy thử nhiều plan, COLLSCAN thắng nên các plan thua bị lược khỏi output

    Nếu có nhiều candidate plan thì các plan thua nằm trong rejectedPlans. Ở đây không có plan nào khác để thua. Xem mục "Chặng 2: query planner".

  2. Plan đã có trong plan cache nên planner không liệt kê lại các plan khác

    Output ghi rõ isCached: false. Lý do nằm ở các index đang có, không phải ở plan cache. Xem mục "Chặng 2: query planner".

  3. Index duy nhất là _id_, filter không đụng _id, nên chỉ có một cách: COLLSCAN

    Planner liệt kê candidate plan từ các index dùng được. Không index nào khớp với tenantId hay status, nên chỉ còn đọc toàn bộ collection, và không có plan nào bị loại. Xem mục "Chặng 2: query planner".

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?

  1. Khoảng 52 MB, nhiều hơn cả kích thước logic

    Lab đo collBytesInCacheMB: 52. Trong WiredTiger cache dữ liệu chưa nén và có cấu trúc riêng trong bộ nhớ, nên RAM cần được tính theo kích thước sau giải nén cộng phần quản lý. Xem mục "Thử nghiệm 3: query của ta lấy dữ liệu từ đâu?".

  2. Khoảng 16,3 MB, vì cache giữ đúng các page như trong file .wt

    Dạng nén giống trên đĩa là của filesystem cache của OS. WiredTiger cache giữ dữ liệu chưa nén: lab đo khoảng 52 MB, gấp khoảng 3,2 lần. Xem bảng ba tầng ở mục "Chặng 4: storage engine WiredTiger, cache và đĩa".

  3. Khoảng 47,2 MB, đúng bằng kích thước BSON chưa nén

    Gần đúng hướng nhưng thiếu phần quản lý trong bộ nhớ: lab đo khoảng 52 MB, lớn hơn 47,2 MB. Xem mục "Thử nghiệm 3: query của ta lấy dữ liệu từ đâu?".

  4. Ít hơn 16,3 MB, vì chỉ page đang được đọc mới nằm trong cache

    Trong lab, toàn bộ collection đã nằm sẵn trong cache (pagesReadIntoCache: 0) và chiếm khoảng 52 MB. Xem mục "Thử nghiệm 3: query của ta lấy dữ liệu từ đâ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?

  1. limit khiến MongoDB chuyển sang dùng index _id_ thay cho COLLSCAN

    Explain cho thấy LIMIT -> COLLSCAN: vẫn là quét collection, chỉ là dừng sớm. Xem mục "COLLSCAN: điều xảy ra khi không có index".

  2. limit giữ query luôn nhanh, dù collection lớn tới đâu

    Nhanh ở đây là may mắn: 5 kết quả đầu nằm trong 864 document đầu. Kết quả hiếm hoặc nằm cuối thì vẫn quét gần hết. Xem mục "COLLSCAN: điều xảy ra khi không có index".

  3. MongoDB vẫn quét đủ 200.000 document rồi mới cắt lấy 5 kết quả

    Nếu vậy totalDocsExamined đã là 200.000. Lab đo 864: COLLSCAN dừng khi đủ 5 kết quả. Xem mục "COLLSCAN: điều xảy ra khi không có index".

  4. Quét dừng khi đủ 5 kết quả; kết quả hiếm hoặc nằm cuối thì quét gần hết

    Chỉ cần 5 kết quả nên quét 864 document là đủ. Nhưng 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. Xem mục "COLLSCAN: điều xảy ra khi không có index".

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.

Tài liệu tham khảo