Page cache — vì sao lần đọc thứ hai nhanh gấp trăm lần
Page cache đứng giữa ứng dụng và đĩa: read-ahead, write-back, dirty page, và khi nào O_DIRECT là lựa chọn hợp lý. Đọc cột buff/cache của free cho đúng.
TL;DR: Mỗi khi bạn đọc một file, kernel sao nội dung vào phần RAM đang rảnh và giữ đó — gọi là page cache. Lần sau cần lại cùng file, kernel trả từ RAM mà không chạm đĩa: đó là lý do lần đọc thứ hai nhanh hơn hàng trăm lần. Kernel còn chủ động tải trước dữ liệu kề cạnh để thưởng cho truy cập tuần tự (read-ahead), và mọi lần ghi ban đầu chỉ vào RAM rồi một tiến trình nền âm thầm đẩy xuống đĩa sau (write-back). Bốn ngưỡng của kernel — đặc biệt là dirty_ratio 20% RAM — quyết định khi nào ứng dụng bị chặn lại buộc phải tự ghi.
Cột buff/cache trong free -h luôn chiếm phần lớn RAM, và đây là thứ khiến nhiều người lo: "Máy sắp hết RAM rồi sao?" Câu trả lời là không. Kernel có triết lý rõ: RAM rảnh là RAM lãng phí. Thay vì để trống, kernel lấp đầy bằng nội dung file bạn vừa đọc hay ghi. Khi ứng dụng cần RAM thật, kernel dọn page cache trước tiên và không ứng dụng nào bị ảnh hưởng.
1. Analogy — Bàn kế toán và kho hầm
Kho hồ sơ của một công ty nằm ở tầng hầm. Mỗi sáng, nhân viên xuống lấy những tập hồ sơ hay dùng rồi xếp lên bàn. Tập đã trên bàn thì với tay lấy ngay — không mất công xuống hầm nữa. Ghi sửa đổi thì viết lên tờ nháp trên bàn trước, cuối ngày mới gộp lại và nộp xuống kho. Bàn hữu hạn nhưng nhân viên không tự dọn — chỉ khi bàn đầy mà cần chỗ mới dẹp tập ít dùng nhất sang một bên.
| Trong phòng hồ sơ | Trong kernel |
|---|---|
| Kho hầm (xa, phải xuống lấy) | Ổ đĩa |
| Bàn làm việc (gần, lấy ngay) | Page cache trong RAM |
| Lần đầu lấy hồ sơ — xuống kho | Read miss — đọc từ đĩa, nạp vào cache |
| Lần sau cùng hồ sơ — đã trên bàn | Read hit — trả từ RAM, không chạm đĩa |
| Ghi nháp trên bàn, chưa nộp xuống | Dirty page — sửa trong RAM, chưa ghi đĩa |
| Cuối ngày nộp hồ sơ xuống kho | Write-back — flush dirty pages xuống đĩa |
| Dọn bàn khi cần chỗ | Kernel evict page cache khi ứng dụng cần RAM |
2. Page cache là gì — và tại sao buff/cache luôn đầy?
Mỗi khi bạn gọi read() để đọc một file, kernel không chuyển thẳng dữ liệu từ đĩa vào bộ đệm ứng dụng. Nó đọc từ đĩa, sao vào page cache, rồi mới copy sang ứng dụng. Vùng RAM đó ở lại sau khi syscall kết thúc — lần sau đọc lại cùng file, kernel kiểm tra cache trước: tìm thấy thì trả ngay từ RAM mà không phát sinh I/O nào.

Dưới đây là kịch bản đo thật — hai lần đọc cùng file, một lần xoá cache trước, một lần không:
# Cold read: xoa page cache, buoc kernel doc tu dia
sync; echo 3 | sudo tee /proc/sys/vm/drop_caches > /dev/null
time dd if=/tmp/testfile of=/dev/null bs=4M status=none
# Warm read: giu nguyen cache
time dd if=/tmp/testfile of=/dev/null bs=4M status=none
Lần đọc thứ hai không xoá cache, không khởi động lại — số giây in ra sẽ thay đổi như thế nào? Và điều gì xảy ra trong kernel giữa hai lần chạy để tạo ra kết quả đó? Ghi giải thích của bạn trước khi đọc tiếp.
Lần đọc thứ hai nhanh hơn hàng chục tới hàng trăm lần — với file đủ nhỏ để nằm trong RAM, thời gian giảm từ hàng giây xuống dưới một phần mười giây. Cơ chế: kernel đã nạp toàn bộ nội dung vào page cache ở lần đầu và giữ đó. Lần thứ hai, từng page tìm thấy trong cache ngay — cache hit toàn bộ, không phát sinh I/O.
Bài đó giải thích page cache từ góc bộ nhớ ảo: kernel ánh xạ vùng file vào address space của tiến trình thay vì copy từng byte. Bài này nhìn từ góc I/O — read-ahead, dirty page, write-back và bốn ngưỡng quyết định khi nào dữ liệu thật sự chạm đĩa.
3. Read-ahead — vì sao truy cập tuần tự được thưởng hơn?
Kernel không thụ động chờ ứng dụng xin từng page. Nó quan sát kiểu truy cập: nếu bạn đọc page 0, rồi page 1, rồi page 2 theo thứ tự, kernel nhận ra mẫu tuần tự và chủ động nạp trước page 3, 4, 5 dù ứng dụng chưa xin — đây là read-ahead.
Bạn đã thấy ở bài Tuần tự vs ngẫu nhiên rằng chênh lệch hiệu năng giữa hai kiểu truy cập trên đĩa lớn đến bất ngờ. Một phần nguyên nhân nằm đúng ở đây: truy cập tuần tự được thưởng vì read-ahead luôn đi trước ứng dụng, nên gần như mọi page xin đến đều đã sẵn trong cache rồi — không cần đợi đĩa. Truy cập ngẫu nhiên thì kernel không dự đoán được page tiếp theo, read-ahead nhảy lộn xộn không trúng, mỗi lần truy cập vẫn phải xuống đĩa.
Kích thước cửa sổ read-ahead kernel tự điều chỉnh theo lịch sử đọc của từng file. Với file đọc tuần tự liên tục, cửa sổ tự mở rộng, kernel có thể tải trước hàng megabyte trước khi ứng dụng cần. Đó là lý do dd if=<file> đọc một file lớn tuần tự đạt gần băng thông tối đa của ổ, trong khi cùng workload nhưng nhảy ngẫu nhiên thì IOPS là nút thắt chứ không phải băng thông.
4. Dirty page và write-back — bốn ngưỡng quyết định ứng dụng bị chặn khi nào?
Khi bạn gọi write(), kernel không ghi xuống đĩa ngay. Nó chép dữ liệu vào page cache và đánh dấu trang đó là dirty — đã sửa trong RAM, chưa ghi đĩa. Syscall write() trả về ngay sau khi dữ liệu vào cache, bất kể đĩa có nhận được gì không. write() thành công vì thế chưa cam kết dữ liệu bền.
Một tiến trình nền — thường hiện ra dưới tên kworker — định kỳ quét page cache, thu gom các trang dirty và đẩy xuống đĩa theo đợt. Đây gọi là write-back. Bốn tham số sysctl quyết định khi nào việc đó xảy ra — cả bốn verify từ tài liệu chính thức kernel.org:
| Tham số | Mặc định | Ý nghĩa |
|---|---|---|
dirty_background_ratio | 10% RAM | Flusher bắt đầu ghi nền, không chặn ứng dụng |
dirty_ratio | 20% RAM | Tiến trình ghi bị chặn — phải tự ghi đồng bộ |
dirty_expire_centisecs | 3000 (30 giây) | Trang dirty sống tối đa bao lâu trước khi bị flush |
dirty_writeback_centisecs | 500 (5 giây) | Chu kỳ đánh thức flusher để kiểm tra |
Ngưỡng 20% là cái đáng để ý nhất. Khi lượng dirty page vượt 20% tổng RAM, kernel không đợi write-back daemon một mình xử lý — nó chặn tiến trình đang ghi lại và bắt tiến trình đó tự ghi đồng bộ xuống đĩa trước khi tiếp tục. Chính cú chặn này là nguồn gốc của những khựng không giải thích được khi ứng dụng đang xử lý batch ghi lớn: code không lỗi, đĩa không đầy, nhưng thỉnh thoảng một lần write mất vài giây rồi tự hết.
O_DIRECT cho phép bỏ qua page cache hoàn toàn — kernel ghi thẳng từ buffer của ứng dụng xuống đĩa. Man page open(2) phát biểu rõ rằng O_DIRECT thường làm giảm hiệu năng với workload thông thường vì mất đi lợi ích batching và read-ahead. Trường hợp hợp lý là khi ứng dụng tự quản cache riêng — như hầu hết database engine làm — và không muốn kernel cache thêm một lớp trên cache của nó.
5. Pitfall của riêng concept này
❌ Nhầm 1 — cột buff/cache đầy là dấu hiệu hết RAM.
✅ Đó là page cache. Kernel chủ động lấp RAM rảnh vì RAM trống là RAM lãng phí. Khi ứng dụng cần RAM thật, kernel thu hồi page cache trước và không ứng dụng nào bị ảnh hưởng. Cột available trong free -h mới là số đáng xem: nó tính lượng RAM kernel có thể giải phóng ngay. Chỉ đáng lo khi available gần về 0.
❌ Nhầm 2 — write() trả về 0 lỗi nghĩa là dữ liệu đã bền.
✅ write() chỉ ghi vào dirty page trong RAM. Mất điện hoặc crash trước khi write-back chạy thì dữ liệu mất. Muốn chắc dữ liệu xuống đĩa, phải gọi fsync(fd) — bài fsync và độ bền mổ kỹ khi nào cần và những cạm bẫy khi làm không đủ.
❌ Nhầm 3 — O_DIRECT luôn nhanh hơn vì bỏ qua một tầng.
✅ Bỏ qua page cache cũng đồng nghĩa bỏ qua read-ahead, caching và batching. Man page open(2) ghi rõ đây thường là lựa chọn giảm hiệu năng. Thêm vào đó, O_DIRECT yêu cầu buffer và offset căn chỉnh theo block size của đĩa — thêm độ phức tạp không đáng với workload thông thường.
6. 📚 Đào sâu (tuỳ chọn)
- Linux kernel — admin-guide/sysctl/vm — nguồn gốc của bốn tham số dirty page:
dirty_ratio,dirty_background_ratio,dirty_expire_centisecs,dirty_writeback_centisecs. Đọc trực tiếp để thấy từng tham số được mô tả chính xác thế nào và đơn vị thực tế của centisecs. - open(2) — man7.org — cờ
O_DIRECTkèm yêu cầu alignment bộ đệm; man page tự nhận đây thường là lựa chọn làm giảm hiệu năng và liệt kê những điều kiện cụ thể khi nó hợp lý.
7. Liên hệ các bài khác
- Tuần tự vs ngẫu nhiên — read-ahead là cơ chế phần mềm thưởng thêm cho truy cập tuần tự, trên cả những gì phần cứng đã làm; bài này giải thích con số.
- Filesystem, inode và metadata — inode và directory entry cũng được cache theo cơ chế riêng; page cache là tầng đứng trên filesystem.
- fsync và độ bền — mổ kỹ vòng đời dirty page từ khi tạo ra đến khi xuống đĩa, và khi nào cần
fsync()để đảm bảo bền. - Mini-challenge — đo ổ đĩa của chính bạn — lab bắt quả tang page cache làm méo benchmark: đo mà quên xoá cache là đo RAM chứ không phải đĩa.
8. Tóm tắt
- Kernel lấp RAM rảnh bằng nội dung file (page cache); cache hit trả từ RAM không chạm đĩa — vì vậy
buff/cacheđầy là bình thường và lần đọc thứ hai nhanh hàng trăm lần. - Read-ahead dự đoán kiểu tuần tự và tải trước; truy cập ngẫu nhiên không được thưởng vì kernel không đoán được page tiếp theo.
write()tạo dirty page trong RAM; flusher bắt đầu ghi nền ở 10% RAM, còn ở 20% tiến trình bị chặn và phải tự ghi — nguồn của những khựng bí ẩn khi batch ghi lớn.O_DIRECTbypass cache nhưng man page cảnh báo nó thường giảm hiệu năng; hợp lý nhất khi ứng dụng tự quản cache riêng như database.
9. Tự kiểm tra
Q1Bạn chạy `free -h` trên máy production và thấy chỉ còn 200 MB free nhưng 14 GB buff/cache. Đây là dấu hiệu hết RAM hay không? Giải thích cơ chế.▸
Không phải hết RAM. Cột buff/cache là page cache — kernel chủ động lấp RAM rảnh bằng nội dung file để phục vụ lần đọc tiếp. Cột available mới là số đáng quan tâm: nó tính lượng RAM kernel có thể giải phóng ngay cho ứng dụng, bao gồm phần lớn buff/cache.
Chỉ đáng lo khi available gần về 0 — lúc đó kernel không còn chỗ xoay xở và phải dùng swap. Trong ví dụ này, 14 GB buff/cache là vốn dự phòng tốt, hoàn toàn bình thường theo thiết kế.
Q2Ứng dụng ghi batch 50 GB dữ liệu qua `write()` và thỉnh thoảng khựng đột ngột — không phải lỗi, chỉ dừng vài giây rồi tiếp tục. Cơ chế kernel nào có thể gây ra hiện tượng này?▸
Nguyên nhân nhiều khả năng là ngưỡng dirty_ratio 20% RAM. Khi ghi batch lớn, dirty page tích luỹ nhanh; đến lúc vượt 20% tổng RAM, kernel chặn tiến trình đang gọi write() và bắt tiến trình đó tự đồng bộ xuống đĩa trước khi tiếp tục. Khoảng thời gian chờ ghi đĩa đó chính là "khựng vài giây".
Các cú khựng không đều vì khoảng cách giữa chúng phụ thuộc tốc độ tích luỹ dirty page so với tốc độ flush ngầm của write-back daemon. Không có lỗi nào được báo cáo vì đây là hành vi có chủ đích, không phải exception.
Q3Vì sao read-ahead giúp ích cho truy cập tuần tự nhưng không giúp — thậm chí còn lãng phí tài nguyên — với truy cập ngẫu nhiên?▸
Read-ahead nhận ra mẫu: đọc page 0, 1, 2 theo thứ tự thì kernel đoán tiếp theo là page 3, 4, 5 và tải trước vào cache. Với truy cập tuần tự, dự đoán gần như luôn đúng — ứng dụng xin đến đâu, cache đã có sẵn đến đó, không phát sinh I/O chờ đợi.
Với truy cập ngẫu nhiên, kernel không đoán được page tiếp theo. Read-ahead tải nhầm — các trang nạp trước không được dùng, tốn băng thông đĩa và chiếm slot cache, rồi bị evict khi cần chỗ cho trang thật. Mỗi yêu cầu ngẫu nhiên vẫn phải chờ I/O từ đĩa.
Q4Đồng nghiệp muốn thêm `O_DIRECT` vào service ghi log để 'bỏ qua page cache cho nhanh'. Khi nào cách tiếp cận này thực sự cải thiện hiệu năng, và khi nào nó phản tác dụng?▸
O_DIRECT chỉ hợp lý khi ứng dụng tự quản cache riêng và muốn kiểm soát chính xác thời điểm I/O — điều mà database engine thường làm để tránh double-caching. Trong trường hợp đó, bỏ page cache còn cho phép ứng dụng tự quyết định flush khi nào.
Với service ghi log, O_DIRECT thường phản tác dụng: mỗi write() phải chờ đĩa đồng bộ, mất hết lợi ích batching của write-back. Man page open(2) ghi rõ đây thường là lựa chọn làm giảm hiệu năng. Ngoài ra còn phải đảm bảo buffer và offset căn chỉnh theo block size của đĩa.
Q5Ứng dụng ghi cấu hình quan trọng qua `write()` và nhận về thành công, không có lỗi. Dữ liệu có chắc chắn bền trên đĩa chưa? Cần làm gì thêm để đảm bảo?▸
write() thành công chỉ có nghĩa dữ liệu đã vào dirty page trong RAM — không cam kết gì về đĩa. Nếu mất điện hoặc kernel crash ngay sau đó, dữ liệu mất nếu write-back chưa kịp chạy. Kernel chọn tốc độ thay vì bền mặc định, để ứng dụng tự quyết định mức bảo đảm cần thiết.
Để đảm bảo dữ liệu xuống đĩa, ứng dụng phải gọi fsync(fd) sau khi ghi. fsync() chặn cho đến khi kernel flush toàn bộ dirty page của file descriptor đó xuống thiết bị lưu trữ. Bài tiếp theo mổ kỹ khi nào cần fsync và những cạm bẫy nếu làm không đúng cách.
Bài tiếp theo: fsync và độ bền — write() trả về chưa chắc đã ghi
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên