Vì sao mở lại một tệp lần thứ hai gần như tức thì, trong khi lần đầu mất một nhịp chờ? Câu trả lời là page cache — một trong những tối ưu quan trọng nhất của hệ điều hành: Linux giữ các trang tệp vừa đọc trong phần RAM còn dư, để lần sau lấy thẳng từ bộ nhớ thay vì đọc lại từ đĩa. Bài này đo trực tiếp khác biệt "lần đầu từ đĩa" so với "lần hai từ cache" — và trên đường đo, tôi vấp đúng cái bẫy khiến hiệu ứng cache biến mất khỏi phép đo của chính mình.
Page cache: RAM làm đệm cho đĩa
Đĩa (kể cả SSD) chậm hơn RAM nhiều bậc. Để giấu độ chậm đó, nhân giữ một page cache: mỗi trang tệp được đọc từ đĩa sẽ được lưu lại trong RAM, và lần truy cập sau lấy thẳng từ đây. Cache này trong suốt (chương trình không cần biết gì), tự động (không phải bật), chia sẻ (mọi tiến trình đọc cùng tệp đều dùng chung một bản trong cache), và cơ hội — nó dùng RAM đang rảnh, và nhường lại khi ứng dụng cần thêm bộ nhớ. Bạn xem được lượng RAM đang làm cache ở dòng Cached trong /proc/meminfo.
Điều quan trọng: cả đọc lẫn ghi đều đi qua page cache. Đây chính là chi tiết sắp làm tôi đo sai.
Đo: lạnh 93 ms, ấm 18 ms
Tôi tạo một tệp 500 MB rồi đo thời gian đọc trọn nó, đồng thời theo dõi Cached. Nhưng để thấy cả cái bẫy, hãy xem lần đo đầu tiên — cách mà nhiều người (kể cả tôi) làm theo bản năng:
A) Tạo file rồi đọc ngay, KHÔNG xóa cache:
Cached ngay sau khi tạo file: 1573 MB
đọc lần 1: 15 ms (33 GB/s)
đọc lần 2: 16 ms (31 GB/s) -> gần như bằng nhau
Hai lần đọc bằng nhau và đều nhanh như RAM. Nếu dừng ở đây, tôi đã kết luận "page cache chẳng tạo khác biệt gì". Nhưng con số Cached = 1573 MB ngay sau khi tạo tệp là một manh mối: tệp 500 MB của tôi đã nằm sẵn trong cache rồi. Nên tôi làm lại cho đúng — xóa cache trước khi đọc "lần đầu":
B) echo 1 > /proc/sys/vm/drop_caches (đẩy trang tệp ra khỏi cache):
Cached sau drop: 359 MB (đã tụt, tệp bị đẩy ra)
đọc LẠNH (từ đĩa): 93 ms (5,4 GB/s) -> Cached lại tăng lên 862 MB
đọc ẤM (từ cache): 18 ms (28 GB/s)
Bây giờ khác biệt hiện rõ: đọc lạnh từ đĩa mất 93 ms, đọc ấm từ cache chỉ 18 ms — nhanh gấp ~5 lần. (Với đĩa cứng quay HDD, tỉ lệ này còn lớn hơn nhiều — hàng chục tới hàng trăm lần — vì HDD chậm hơn SSD rất nhiều; ở đây tôi đo trên SSD nên chênh lệch chỉ ~5 lần, nhưng bản chất là như nhau: RAM nhanh hơn mọi loại đĩa.) Và cột Cached kể trọn câu chuyện: 1573 MB (có tệp) → 359 MB (sau khi drop, tệp bị đẩy ra) → 862 MB (sau đọc lạnh, tệp được nạp lại vào cache). Đó là page cache đang làm việc, đo được từng bước.
Ghi file đã làm nó ấm mất rồi
Sai lầm của tôi ở phần A giờ đã rõ. Tôi định đo "đọc lần đầu từ đĩa", nhưng "lần đầu" của tôi không hề lạnh. Lý do: hành động ghi tệp bằng dd cũng đi qua page cache — mỗi khối ghi ra được đặt vào cache trước (rồi mới ghi xuống đĩa sau). Nên ngay khi tạo xong tệp 500 MB, toàn bộ nó đã nằm trong RAM (Cached nhảy vọt lên 1573 MB). "Lần đọc đầu" của tôi thực ra là một lần cache hit ấm sực — không có lần đọc từ đĩa nào cả, nên đương nhiên nó nhanh ngang lần hai.
Chỉ khi tôi chủ động xóa cache (drop_caches) để đẩy các trang tệp ra khỏi RAM (thấy Cached tụt từ 1573 xuống 359 MB), lần đọc kế mới thật sự phải xuống đĩa, và con số chậm thật (93 ms) mới xuất hiện. Không có bước xóa cache, tôi đo hai lần đọc ấm và tưởng đó là "đĩa so với cache".
Điều rút ra: muốn đo một lần đọc lạnh, bạn phải chủ động làm cho nó lạnh. Đây là một trong những cái bẫy benchmark I/O phổ biến nhất — vô số phép đo "tốc độ đọc đĩa" thực ra đang đo tốc độ page cache vì quên xóa cache trước. Và nó cùng họ với những cái bẫy các bài trước đã gặp (readahead che major fault, THP gộp lỗi trang): cái đọc thấy nhanh vì một tầng đệm vô hình đang giúp bạn, và nếu không kiểm soát tầng đệm đó, bạn đo nhầm hẳn thứ mình nghĩ. Con số Cached là cái đèn báo giúp tôi phát hiện: nó không khớp với giả định "tệp còn nằm trên đĩa" của tôi.
Về đến code hằng ngày
Điều đầu tiên là hiểu vì sao "chạy lại thì nhanh hơn", và tận dụng nó. Lần khởi động đầu của một ứng dụng, lần đọc đầu một tệp cấu hình, lần truy vấn đầu chạm một bảng — tất cả có thể chậm vì dữ liệu còn trên đĩa; lần sau nhanh vì đã vào cache. Đây là lý do người ta làm ấm cache (cache warming) trước khi mở dịch vụ cho người dùng: đọc trước các tệp/dữ liệu nóng để lần truy cập thật của khách hàng là cache hit. Và là lý do đo hiệu năng phải nói rõ "cold" hay "warm" — hai con số khác nhau cả 5 lần.
Điều thứ hai là RAM "rảnh" thực ra đang làm việc, đừng hoảng khi thấy nó gần đầy. Trên Linux, free -m thường cho thấy phần lớn RAM "used" hoặc nằm ở cột buff/cache — đó là page cache, không phải rò rỉ bộ nhớ. Nó cơ hội: nhân sẽ tự nhường lại cache cho ứng dụng ngay khi cần, nên "RAM đầy vì cache" là dấu hiệu hệ thống đang dùng bộ nhớ hiệu quả, không phải sắp hết. Con số cần lo là bộ nhớ khả dụng (MemAvailable), không phải MemFree.
Điều cuối là một nguyên tắc benchmark: luôn kiểm soát trạng thái cache khi đo I/O. Gói lại bằng số: đọc lạnh từ đĩa (93 ms) chậm gấp ~5 lần đọc ấm từ cache (18 ms) cho cùng một tệp 500 MB; và cả ghi lẫn đọc đều nạp tệp vào page cache (Cached 1573→359→862 MB qua các bước) — nên phải drop_caches mới đo được một lần đọc lạnh thật, kẻo bạn đo hai lần đọc ấm mà tưởng là đĩa-so-với-cache. Page cache là lý do máy tính có vẻ nhanh hơn khả năng thật của đĩa; đo nó đúng là luôn hỏi: dữ liệu đang ở RAM hay ở đĩa?
Cái tầng đệm vô hình "khiến mọi thứ có vẻ nhanh, và làm benchmark nói dối nếu không kiểm soát" này chồng lớp ở mọi tầng, nên chẩn đoán hiệu năng luôn phải hỏi "đang đo cache tầng nào". Trong Java, một microbenchmark JMH cho kết quả tốt đẹp trên lần chạy đầu có thể chỉ đo JIT chưa kịp tối ưu (nên JMH bắt buộc có warmup), và một truy vấn "nhanh" trong test có thể đang hit cache của tầng dưới; đó là lý do đo phải tách rõ cold/warm và loại nhiễu JIT + page cache. Nhưng cơ sở dữ liệu mới là nơi chồng cache dày nhất và đánh lừa nhất: một truy vấn PostgreSQL chạm buffer pool của DB rồi page cache của OS rồi mới tới đĩa — EXPLAIN (ANALYZE, BUFFERS) phân biệt "shared hit" (từ buffer pool) với "read" (phải xuống OS/đĩa), và một benchmark quên làm lạnh cả hai tầng sẽ đo ra tốc độ RAM mà tưởng là tốc độ đĩa, y hệt cái bẫy dd ở đây. Redis/Memcached thì bản thân là cache. Mẫu số chung: gần như mọi hệ đều có nhiều tầng cache xếp chồng để "giấu" độ chậm của tầng dưới, và điều đó tuyệt cho production nhưng độc cho phép đo; khi benchmark bất cứ thứ gì chạm I/O, luôn hỏi "dữ liệu này thật sự đang được lấy từ đâu — RAM hay đĩa, cache nào?", và chủ động làm lạnh đúng tầng bạn muốn đo (drop_caches, tắt buffer pool, restart) — nếu không, con số đẹp bạn thấy chỉ là cache đang nói dối.
Muốn tự thấy thì trên máy Linux, chọn một tệp lớn ít khi đọc, rồi đo hai lần: echo 1 | sudo tee /proc/sys/vm/drop_caches để xóa cache, sau đó time cat tệp-lớn > /dev/null (lần lạnh), rồi chạy time cat tệp-lớn > /dev/null lần nữa ngay lập tức (lần ấm). Bạn sẽ thấy lần hai nhanh hơn hẳn — đó là page cache. Trong lúc đó, chạy free -m và nhìn cột buff/cache: nó tăng lên đúng bằng cỡ tệp sau lần đọc đầu, vì tệp vừa được nạp vào cache. Đó là toàn bộ điều bài này đo — độ chênh giữa đĩa và RAM, và cái đệm vô hình đang lặng lẽ thu hẹp nó.