Cache (P2/3): Lab hit, miss và working set

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

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

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áip50 lượt 1mean lượt 1Thời gian WiredTiger đọc một pageByte từ thiết bị
(a) WiredTiger cache cold, cache OS cold (3 lần)0,64 / 0,79 / 0,65 ms0,83 / 0,88 / 0,89 ms191 / 191 / 201 µs64 MB mỗi lần
(b) WiredTiger cache cold, cache OS warm (lần 2, đo được đúng)0,29 ms0,33 ms8 µs0
(c) WiredTiger cache warm (lượt 2)0,16–0,25 ms0,18–0,33 mskhông đọc0

[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 / cacheCache sau warm-upp50p95p99Thao tác/sPage đọc vào cache/sMiss mỗi thao tác
0,25x (61.000 doc)132 MB0,123 ms0,1890,4557.17100
0,5x263 MB0,1210,1490,2917.74300
1x448 MB0,1220,1720,3057.5311.2620,17
2x411 MB0,1300,1720,2957.1064.1890,59
4,3x (toàn bộ)448 MB0,1410,1790,3136.7275.4510,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 / cachep50p95p99Thao tác/sPage đọc/sMiss/thao tácApplication thread tự đọc page
0,25x0,134 ms0,2940,5976.076000
1x0,1240,1690,3247.4091.3850,191,6 µs mỗi thao tác
2x0,1100,3890,6005.7463.4180,6061 µs
4,3x0,1910,4090,7194.3503.5230,81114 µs

Bốn điều đáng đọc:

  1. 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_target 80% và trigger 95% đang làm việc. Bài Schema Design Patterns đã nói "WiredTiger không để cache đầy hẳn": đây là con số.
  2. Ở 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ì.
  3. Đường cong của main thoai 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).
  4. 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 collectionmain: p50 / thao tác/s / miss mỗi thao tácslow: p50 / thao tác/s / miss mỗi thao tác
lệch: 90% vào 5% dữ liệu0,121 ms / 7.764 / 0,080,097 ms / 7.847 / 0,08
đều trên toàn bộ0,141 ms / 6.727 / 0,810,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ìnhCache (MB)Q1 p50Q1 thao tác/sPage đọc/s
B0: Q1 một mình2150,120 ms7.8020
C1: Q1 + Q2 dùng 1 index, document hẹp3640,1326.6440
C2: Q1 + Q2 dùng 8 index luân phiên4220,1874.490712
C3: Q1 + Q2 dùng 1 index, document rộng4190,1605.009314

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ìnhChế độEmbed: p50 / thao tác/sReference: p50 / thao tác/sTỉ lệ ref/embed (p50; thao tác/s)
maintrong cache0,132 ms / 5.8220,355 ms / 2.3862,7x; 2,4x
mainngoài cache0,235 / 3.6680,381 / 2.3521,6x; 1,6x
slowtrong cache0,120 / 7.9690,195 / 3.9331,6x; 2,0x
slowngoài cache0,134 / 6.3640,312 / 2.6512,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?

  1. slow có ít CPU hơn nên xử lý mỗi thao tác chậm hơn

    Cả hai dùng 2 CPU. Khác nhau là RAM, giới hạn IOPS, và thời gian application thread tự đọc page: 8 µs so với 114 µs mỗi thao tác. Xem Lab 1.

  2. Ở main working set nhỏ hơn nên có ít miss hơn hẳn slow

    Cả hai cùng 0,81 miss mỗi thao tác ở 4,3x. Khác biệt là giá của mỗi miss, không phải số miss.

  3. Ở slow cache của WiredTiger bị đặt nhỏ hơn ở main

    Cả hai đặt 0,5 GB (maximum bytes configured 536.870.912). Chỉ RAM và đĩa khác nhau. Xem mục "Setup và dataset".

  4. main: cache OS phục vụ miss; slow: miss xuống đĩa giới hạn IOPS

    Đúng. Cache miss của WiredTiger chưa chắc là đọc đĩa: ở main cả lần chạy chỉ đọc từ thiết bị khoảng một lần cả file, còn ở slow application thread tự đọc page mất 114 µs mỗi thao tác. Xem Lab 1.

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?

  1. Gần như không đổi: chỉ số loại khách thật gọi mới quan trọng

    Đúng. Đó là working set so với cache; xem "Cùng dữ liệu, working set khác" (0,08 miss mỗi thao tác khi 90% truy cập vào 5% dữ liệu).

  2. Chậm hơn vì kho to hơn nên ra lấy món phải tìm lâu hơn

    Khách vẫn gọi 20 loại quen thuộc, vốn nằm sẵn trên quầy. Cùng ý với Lab 1: cùng 2 GB, ở 0,25x không có miss nào.

  3. Nhanh hơn vì kho nhiều lựa chọn nên quầy luôn có món thay thế

    Thêm hàng vào kho không làm quầy phục vụ nhanh hơn, nó chỉ làm kho lớn 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?

  1. Job làm hỏng index của collection nên phải xây lại

    Không có gì hỏng: dữ liệu và index vẫn nguyên, chỉ page của hot set bị evict khỏi cache. Xem Lab 3.

  2. Page hot set ít được chạm nên bị quét evict, phải nạp lại dần

    Đúng. Lab 3, hot set đã warm 170 lần đọc/giây: sau lần quét, 128 page/giây phải đọc lại trong 10 giây đầu, 66 trong 20 giây kế. Hot set được chạm liên tục (4.000 lần/giây) cũng mất khoảng 40% page nhưng nạp lại trong vài giây. Xem mục "Lab 3: COLLSCAN vượt cache làm hot set bị evict".

  3. Collection vẫn bị job giữ khoá sau khi quét xong

    Lab 3: luồng đọc hot set vẫn chạy trong lúc quét, chỉ chậm hơn (p50 0,30 lên 0,61 ms); sau khi quét, nó chậm vì miss.

  4. Eviction chỉ evict được dirty page nên cache bị kẹt sau khi quét

    Lần quét evict 34.509 clean page; evict clean page là rẻ nhất vì chỉ giải phóng bộ nhớ. Xem Clean page, dirty page và eviction.