Cache (P2/3): Lab hit, miss và working set
Ở phần trước: cache giữ page đã đọc hoặc đã sửa. Hit thì xong trong vài micro giây; miss phải đọc file rồi giải nén. Khi working set lớn hơn cache, page đang dùng bị evict rồi đọc lại.
- Cần đọc trước: WiredTiger cache là gì
- Dẫn tới: Dirty page, eviction và chọn kích thước cache
Setup và dataset
MongoDB version: 8.3.11 (standalone), Docker Desktop VM 7,7 GB, Apple M4
Container main : --memory=2g --cpus=2, đĩa nhanh của Docker Desktop
Container slow : --memory=1280m --cpus=2 --device-read-iops /dev/vda:3000 (đĩa mạng mô phỏng)
Cấu hình : --wiredTigerCacheSizeGB 0.5 (cache_size=512M, maximum bytes configured 536.870.912)
l21a.items : 1.000.000 document, avgObjSize 2.050 B, 1.956 MB logic, 1.174 MB trên đĩa (snappy 1,67x), index _id 11,5 MB
Trong cache : khoảng 2,2 KB mỗi document: 61.000 document = 131,7 MB = 0,26x cache
Client : mongosh, một luồng, findOne({ _id }) lặp liên tục, đo từng lệnh bằng performance.now()"Working set / cache" ở dưới là tỉ lệ dung lượng trong cache của phần document được chạm tới so với 512 MB: 61.000 document ≈ 0,25x, 122.000 ≈ 0,5x, 244.000 ≈ 1x, 488.000 ≈ 2x, toàn bộ 1.000.000 ≈ 4,3x. Mỗi lần chạy bắt đầu bằng khởi động lại mongod (cache WiredTiger rỗng), warm-up 20 giây rồi đo 30 giây. Mọi output thô nằm trong lab21/ (xem bảng nguồn ở cuối).
Lab 1: cold cache, hit và miss
Một lần đọc đầu tiên đắt bao nhiêu
Lời hứa còn nợ từ bài Mental Model: đo cold cache. Ta đọc 2.000 document ngẫu nhiên, mỗi document một lần (lượt 1, cache WiredTiger rỗng), rồi đúng 2.000 id đó lần nữa (lượt 2). Có ba trạng thái, phân biệt bằng rbytes của cgroup (byte thật sự xuống thiết bị):
| Trạng thái | p50 lượt 1 | mean lượt 1 | Thời gian WiredTiger đọc một page | Byte từ thiết bị |
|---|---|---|---|---|
| (a) WiredTiger cache cold, cache OS cold (3 lần) | 0,64 / 0,79 / 0,65 ms | 0,83 / 0,88 / 0,89 ms | 191 / 191 / 201 µs | 64 MB mỗi lần |
| (b) WiredTiger cache cold, cache OS warm (lần 2, đo được đúng) | 0,29 ms | 0,33 ms | 8 µs | 0 |
| (c) WiredTiger cache warm (lượt 2) | 0,16–0,25 ms | 0,18–0,33 ms | không đọc | 0 |
[quan sát] Mỗi lần đọc đầu kéo trung bình 1,94 page vào cache (3.881 page cho 2.000 lần đọc: một page dữ liệu và một page của index _id), 89 MB sau giải nén cho 74 MB đọc từ file. Khi cả hai tầng cache đều cold, p50 của lần chạm đầu chậm khoảng 2,5 đến 4 lần lần chạm sau (cùng lần chạy), và chỉ chậm khoảng 1,7 lần khi OS đã giữ sẵn page nén. Dòng (b) chỉ tin lần 2: ở lần 1 và 3, "giữ cache OS" vẫn đọc 53–54 MB từ thiết bị vì một lab khác chạy cùng máy ảo có thể đã làm page bị evict khỏi cache OS, nên số đo này chỉ là cận dưới. [suy luận] Khác biệt giữa (a) và (b), khoảng 0,5 ms (mean) mỗi lần đọc đầu, là giá của riêng thiết bị đĩa trong lab này; khác biệt giữa (b) và (c) là giá của riêng đọc, giải nén, dựng page.
Working set lớn dần so với cache
Bảng 1 là đường cong chính. Cùng collection 1.000.000 document, chỉ khác phần được đọc (đều ngẫu nhiên), cấu hình main, cache OS không giữ nguyên từ lần trước (e1-hitmiss.out):
| Working set / cache | Cache sau warm-up | p50 | p95 | p99 | Thao tác/s | Page đọc vào cache/s | Miss mỗi thao tác |
|---|---|---|---|---|---|---|---|
| 0,25x (61.000 doc) | 132 MB | 0,123 ms | 0,189 | 0,455 | 7.171 | 0 | 0 |
| 0,5x | 263 MB | 0,121 | 0,149 | 0,291 | 7.743 | 0 | 0 |
| 1x | 448 MB | 0,122 | 0,172 | 0,305 | 7.531 | 1.262 | 0,17 |
| 2x | 411 MB | 0,130 | 0,172 | 0,295 | 7.106 | 4.189 | 0,59 |
| 4,3x (toàn bộ) | 448 MB | 0,141 | 0,179 | 0,313 | 6.727 | 5.451 | 0,81 |
Cùng bảng cho cấu hình slow (RAM 1,25 GB, 3.000 IOPS đọc), e1-hitmiss-slow.out:
| Working set / cache | p50 | p95 | p99 | Thao tác/s | Page đọc/s | Miss/thao tác | Application thread tự đọc page |
|---|---|---|---|---|---|---|---|
| 0,25x | 0,134 ms | 0,294 | 0,597 | 6.076 | 0 | 0 | 0 |
| 1x | 0,124 | 0,169 | 0,324 | 7.409 | 1.385 | 0,19 | 1,6 µs mỗi thao tác |
| 2x | 0,110 | 0,389 | 0,600 | 5.746 | 3.418 | 0,60 | 61 µs |
| 4,3x | 0,191 | 0,409 | 0,719 | 4.350 | 3.523 | 0,81 | 114 µs |
Bốn điều đáng đọc:
- Cache không bao giờ đầy 100%. Dù dữ liệu gấp 4,3 lần, cache dừng ở 411–448 MB trên 512 MB, tức 80–88%. Đó là
eviction_target80% vàtrigger95% đang làm việc. Bài Schema Design Patterns đã nói "WiredTiger không để cache đầy hẳn": đây là con số. - Ở 0,25x và 0,5x, p50 gần như bằng nhau và không có miss nào, dù dataset là 2 GB. Phần chạm tới vừa cache thì p50 quanh 0,12 ms, như thể database nhỏ. Kích thước database một mình không nói gì.
- Đường cong của
mainthoai thoải, không phải vách đá. Từ 0,25x lên 4,3x, p50 chỉ tăng từ 0,123 lên 0,141 ms (khoảng 15%), thao tác/s giảm 6%, dù trung bình mỗi thao tác kéo vào 0,81 page. [quan sát] Lý do giống dòng (b) của bảng cold cache: ởmain, page miss được cache OS phục vụ (application thread mất khoảng 10 µs mỗi page, kể cả giải nén), và cả lần chạy 4,3x chỉ đọc 1.186 MB từ thiết bị, xấp xỉ một lần đọc cả file (lần chạy giữ cache OS: 0 byte). Một máy có cache OS đủ lớn che được cache miss của WiredTiger (lab chỉ dùng snappy; giá giải nén của compressor nặng hơn khi cache bị ép không được đo ở đây). - Che đến một lúc. Ở
slow, file dữ liệu không nằm trọn trong cache OS và đĩa bị giới hạn IOPS: application thread tự đọc page tốn 114 µs mỗi thao tác (so với 8 µs ởmain), thao tác/s giảm 28% so với 0,25x (dòng 0,25x lại chậm hơn dòng 1x, dấu hiệu nhiễu của VM), p95 tăng từ 0,29 lên 0,41 ms. Vẫn là một đường cong chứ chưa phải vách đá, [suy luận] vì đĩa mô phỏng trong lab chỉ giới hạn tốc độ, không cộng thêm độ trễ mỗi lần đọc như đĩa mạng thật.
[suy luận] Điều bảng này không nói: "working set gấp 4 lần cache chỉ chậm 15%". Nó nói: trên máy có đĩa nhanh và RAM thừa cho cache OS, cái giá của một miss chỉ bằng vài chục micro giây. Đổi lấy đĩa mạng 1 ms mỗi lần đọc thì cùng 0,81 miss mỗi thao tác cộng khoảng 0,8 ms vào mỗi thao tác, tức chậm gấp nhiều lần. Dùng đúng cột "miss mỗi thao tác" của bạn nhân với độ trễ thật của đĩa bạn, đừng dùng cột latency của lab.
Cùng dữ liệu, working set khác
Cùng 1.000.000 document, cùng 2 GB, nhưng 90% lần đọc rơi vào 50.000 document đầu (5%), 10% còn lại rải đều. Đặt cạnh lần đọc đều toàn bộ (e2-skew.out, e2-skew-slow.out):
| Mẫu truy cập, cùng collection | main: p50 / thao tác/s / miss mỗi thao tác | slow: p50 / thao tác/s / miss mỗi thao tác |
|---|---|---|
| lệch: 90% vào 5% dữ liệu | 0,121 ms / 7.764 / 0,08 | 0,097 ms / 7.847 / 0,08 |
| đều trên toàn bộ | 0,141 ms / 6.727 / 0,81 | 0,191 ms / 4.350 / 0,81 |
Hai database giống hệt nhau về dung lượng, miss mỗi thao tác chênh 10 lần. Ở slow, thao tác/s chênh 1,8 lần. Đây là phiên bản thu nhỏ của cặp A/B ở đầu bài.
Lab 2: index, kích thước document và working set
Bài Index Fundamentals nói khi working set vượt cache, cả hệ thống chậm đi, không chỉ query dùng index. Thử ở đây bằng hai luồng chạy cùng lúc. Q1 (luồng đo) đọc ngẫu nhiên 100.000 document 2 KB theo _id (215 MB trong cache), không dùng index nào ngoài _id. Q2 (luồng gây áp lực) tra theo một key chuỗi 24 ký tự trên một collection khác (ev_n: 300.000 document 0,4 KB, 8 index; hoặc ev_w: 300.000 document 0,6 KB, một index), mỗi lần dùng một index rồi lấy document. Q1 và Q2 không chạm chung một byte dữ liệu. Chỉ khác nhau cái Q2 kéo vào cache (e3-indexes.out, e3-indexes-slow.out):
| Cấu hình | Cache (MB) | Q1 p50 | Q1 thao tác/s | Page đọc/s |
|---|---|---|---|---|
| B0: Q1 một mình | 215 | 0,120 ms | 7.802 | 0 |
| C1: Q1 + Q2 dùng 1 index, document hẹp | 364 | 0,132 | 6.644 | 0 |
| C2: Q1 + Q2 dùng 8 index luân phiên | 422 | 0,187 | 4.490 | 712 |
| C3: Q1 + Q2 dùng 1 index, document rộng | 419 | 0,160 | 5.009 | 314 |
Trên slow: B0 0,120 ms / 7.827; C1 0,143 / 6.085; C2 0,149 / 5.784 (854 page/s); C3 0,154 / 5.298 (345 page/s).
[quan sát] C1 so với B0: cache còn dư, không miss, mà Q1 vẫn từ 7.802 xuống 6.644 thao tác/s; [suy luận] phần này là do Q2 tranh CPU. C2 và C3 so với C1: tổng working set chạm mức 80%, bắt đầu có miss (bộ đếm là của cả server, không tách Q1 với Q2), và Q1 mất thêm khoảng 32% (C2) và 25% (C3) thao tác/s ở main. Hai cách làm working set vượt cache: thêm index được dùng (8 index thay vì 1) hoặc document rộng hơn (0,6 KB thay vì 0,4 KB). Nhưng độ lớn của hiệu ứng không ổn định: ở slow chỉ còn 5% (C2) và 13% (C3) so với C1, dù số miss tương tự. Đọc đúng: hướng nhất quán (có miss thì Q1 chậm); độ lớn, và cả thứ tự giữa C2 và C3, thì không. Đừng coi 32% là một hằng số.
Lab 3: COLLSCAN vượt cache làm hot set bị evict
Bài CRUD & Query Model hứa: một COLLSCAN lớn hơn cache gây I/O và làm dữ liệu đang dùng bị evict khỏi cache. Hot set (phần dữ liệu được đọc thường xuyên): 61.000 document (0,26x cache). Giữa chừng, một countDocuments({ tenant: "t7" }) (không index, explain báo COLLSCAN) quét cả 1.000.000 document, 1,9 GB trong cache.
Hot set bị dùng liên tục (khoảng 4.000 lần đọc/giây, e5-scan.out): trong lúc quét (1,9 giây), p50 của luồng đọc hot set tăng từ 0,19 lên 0,32 ms, p95 từ 0,32 lên 1,12 ms, thao tác/s giảm gần một nửa (3.724 xuống 1.928). Lần quét kéo 40.604 page vào cache, evict 34.509 clean page. Page của hot set cũng bị evict: 1.356 page phải đọc lại trong 8 giây sau đó, khoảng 40% trong 3.192 page đã nạp lúc warm-up. Nhưng mỗi page của hot set được chạm lại vài lần mỗi giây, nên chúng quay về gần như ngay, rồi không còn miss nào: p50 về 0,20 ms.
Hot set ít được đọc hơn (khoảng 170 lần đọc/giây, e5b-scan-slowhot.out): kết quả khác hẳn. Trước khi quét gần như không có miss (13 page/giây). Trong 9,4 giây quét: p50 0,30 lên 0,61 ms. Sau khi quét, luồng đọc hot set đọc lại 128 page/giây trong 10 giây đầu, 66 trong 20 giây kế, 22 trong 30 giây cuối (khoảng 0,75 page mỗi lần đọc lúc đầu): gần như cả hot set phải nạp lại, kéo dài cả phút. p50 là 0,39 ms trong 10 giây đầu rồi 0,32 ms (trước khi quét 0,30). Ở slow, cùng lệnh quét kéo dài 19,5 giây, và trong lúc đó p95 của luồng đọc hot set lên khoảng 39 ms (trước đó 1,8 ms).
[quan sát] Lời hứa đúng: COLLSCAN lớn hơn cache gây I/O (1,1–1,4 GB đọc từ file mỗi lần), làm chậm mọi thứ khác trong lúc chạy, và evict cả page của hot set. Khác nhau ở thời gian quay lại: dữ liệu được đọc liên tục nạp lại trong vài giây, dữ liệu ít được đọc hơn thì miss kéo dài cả phút. Thời gian của cùng một lệnh quét dao động mạnh (1,9 s và 9,4 s ở main, 19,5 s ở slow); [suy luận] nó phụ thuộc vào việc page nằm sẵn ở cache OS hay phải xuống đĩa, và vào độ bận của VM.
Lab 4: "9 document ở 3 collection" so với "1 document", khi working set vượt cache
Bài Data Modeling suy luận (chưa đo) rằng khi dữ liệu không còn nằm trong cache, mô hình reference sẽ đắt hơn embed nhiều hơn tỉ lệ khoảng 2 lần đo được khi mọi thứ ở trong cache. Đo ở đây: 600.000 đơn, mô hình embed (orders_emb: đơn, khách, 3–7 dòng hàng trong một document 1,6 KB, 898 MB logic) và mô hình reference (orders_ref 435 MB, order_items 3.000.011 document 331 MB với index orderId_1, customers 15 MB), đọc một đơn đầy đủ: findOne cho embed, aggregate $match rồi hai $lookup cho reference (cách của bài đó; ở dataset này trung bình 7 document: đơn, khoảng 5 dòng hàng, khách). Chế độ "trong cache": chỉ đọc 20.000 đơn đầu (33–50 MB). Chế độ "ngoài cache": đọc đều cả 600.000 đơn, và mỗi mô hình chạy riêng sau khi khởi động lại (e6-modeling.out, e6-modeling-slow.out):
| Cấu hình | Chế độ | Embed: p50 / thao tác/s | Reference: p50 / thao tác/s | Tỉ lệ ref/embed (p50; thao tác/s) |
|---|---|---|---|---|
main | trong cache | 0,132 ms / 5.822 | 0,355 ms / 2.386 | 2,7x; 2,4x |
main | ngoài cache | 0,235 / 3.668 | 0,381 / 2.352 | 1,6x; 1,6x |
slow | trong cache | 0,120 / 7.969 | 0,195 / 3.933 | 1,6x; 2,0x |
slow | ngoài cache | 0,134 / 6.364 | 0,312 / 2.651 | 2,3x; 2,4x |
Số page (ổn định giữa hai cấu hình): mỗi lần đọc embed xin 3 page và đọc vào cache 0,57 page khi ngoài cache; reference xin 10 page và đọc 1,25 page, tức khoảng 3,3 lần số page được xin và 2,2 lần số miss.
[quan sát] Suy luận cũ không được xác nhận như một quy luật. Ở main tỉ lệ còn giảm (2,7x xuống 1,6x): embed bị miss làm chậm 78%, reference gần như không đổi ([suy luận] công việc cố định của $lookup, 10 page và hai stage, đã chiếm phần lớn thời gian nên thêm miss rẻ không làm nó đắt hơn bao nhiêu). Ở slow tỉ lệ thao tác/s tăng rất nhẹ (2,0x lên 2,4x). Cả bốn dòng đều nằm trong khoảng 1,6–2,7 lần, và hai cấu hình đi hai hướng khác nhau, trong một VM có nhiễu. Điều đo được chắc chắn là số page: reference chạm nhiều gấp 3,3 lần và miss nhiều gấp 2,2 lần. Cái giá cuối cùng là số page đó nhân với giá của một miss trên máy của bạn.
Cột mốc: Bạn đã có thể đo một lần đọc cold cache, tách hit với miss, và thấy một COLLSCAN quét cả collection làm hot set bị evict. Tiếp theo: Dirty page, eviction và chọn kích thước cache.
Hỏi & đáp
Ở Lab 1, cùng workload 4,3x (0,81 page đọc vào cache mỗi thao tác) chỉ mất 6% thao tác/s ở main nhưng mất 28% ở slow. Điều gì giải thích khác biệt?
Một người pha chế có quầy vừa 30 loại nguyên liệu và kho 600 loại. 90% khách gọi cùng 20 loại. Tuần sau kho thêm 400 loại nữa nhưng khách vẫn gọi như cũ. Quầy phục vụ nhanh hay chậm hơn?
Một service có hot set (phần dữ liệu được đọc thường xuyên) vừa cache, được chạm vài lần mỗi phút trên mỗi page. Mỗi đêm một job báo cáo quét cả collection lớn hơn cache. Sáng hôm sau truy vấn đọc hot set chậm trong nhiều phút. Vì sao?