Hình dung bạn bỏ một lá thư vào hộp thư trước cửa nhà và tích vào đầu "đã gửi". Về mặt cảm giác thì xong rồi — thư đã rời tay bạn. Nhưng lá thư thật ra vẫn nằm trong cái hộp ấy cho tới khi bưu tá ghé qua gom đi, và nếu nhà cháy trước lúc đó, lá thư cháy theo dù bạn đã yên tâm rằng nó "đã gửi". Chiều ghi của hệ điều hành hành xử đúng như vậy: write trả về ngay khi dữ liệu vào cái hộp thư trước cửa (RAM), còn việc nó thật sự xuống đĩa là chuyện của một luồng nền ghé qua sau. Bài này đo cái khoảng cách giữa "đã gửi" và "thật sự đã đi".

Phần 12 đo bộ đệm trang ở chiều đọc. Chiều ghi có một tính chất mà chiều đọc không có: lời hứa của nhân là lời hứa suông cho tới khi dữ liệu thật sự xuống đĩa.

dd nói dối, và nó có lý do

Ghi 512 MB rồi theo dõi Dirty trong /proc/meminfo:

dd: 536870912 bytes (537 MB) copied, 0.148729 s, 3.6 GB/s
dd trả về sau 0.150 s

giây  1: Dirty  524440 KB
giây  5: Dirty  524612 KB
giây 10: Dirty     264 KB
giây 15: Dirty     684 KB

dd báo xong sau 0,15 giây. Ở giây thứ năm, toàn bộ 512 MB vẫn còn nguyên trong RAM và chưa có byte nào xuống đĩa. Tới đâu đó giữa giây 5 và giây 10 thì luồng nền mới ghi. Trong khoảng đó, cúp điện là mất sạch — và ứng dụng đã được báo là ghi thành công. Lá thư vẫn nằm trong hộp, mà bạn đã tích "đã gửi".

Nhân làm vậy vì hai lý do đúng đắn: gom nhiều lần ghi nhỏ thành lần ghi lớn, và bỏ qua hẳn dữ liệu bị ghi đè trước khi kịp xuống đĩa. Với tệp tạm thời, đó là món hời. Với sổ giao dịch của cơ sở dữ liệu, đó là thảm hoạ — nên mọi CSDL nghiêm túc đều gọi fsync, và phần 14 sẽ đo cái giá của nó.

Ba con số cho cùng một việc

Đo ba lần mỗi cách, cùng 512 MB:

Cách ghi Lần 1 Lần 2 Lần 3
Không đồng bộ 3,9 GB/s 4,0 GB/s 4,2 GB/s
conv=fsync 2,0 GB/s 1,6 GB/s 2,0 GB/s

Ghi liền 6 GB — vượt xa ngưỡng chặn — cho 1,4 GB/s. Ba con số đo ba thứ khác nhau:

Con số Thực chất là tốc độ của
3,9–4,2 GB/s chép dữ liệu vào RAM
1,6–2,0 GB/s ghi thật, có chờ xuống đĩa
1,4 GB/s ghi bền vững, khi bộ đệm đã hết chỗ

Đo tốc độ ghi bằng một lần ghi ngắn và không fsync là đo tốc độ của RAM. Đây là lỗi phổ biến nhất khi so sánh ổ đĩa hoặc so sánh hệ thống tệp, và nó luôn cho kết quả đẹp đến khó tin. Muốn con số đúng: ghi lượng dữ liệu lớn hơn RAM, hoặc thêm fsync, hoặc dùng O_DIRECT.

Bốn nút điều khiển

grep . /proc/sys/vm/dirty_{ratio,background_ratio,expire_centisecs,writeback_centisecs}

Trên máy đo, 15.971 MB RAM:

Nút Giá trị Nghĩa
dirty_background_ratio 10 Vượt 1.597 MB bẩn thì luồng nền bắt đầu ghi. Ứng dụng không bị chậm.
dirty_ratio 20 Vượt 3.194 MB thì tiến trình đang ghi bị chặn lại cho tới khi ghi kịp.
dirty_expire_centisecs 3000 Trang bẩn quá 30 giây thì phải ghi.
dirty_writeback_centisecs 1500 Cứ 15 giây luồng nền thức dậy kiểm.

Hai ngưỡng đầu giải thích một triệu chứng quen thuộc: hệ thống chạy mượt rồi đột ngột đứng vài giây khi đang ghi nhiều. Đó là lúc Dirty chạm dirty_ratio và nhân chặn mọi lời gọi write lại — hộp thư đầy tới mức bạn phải đứng đợi bưu tá gom bớt mới bỏ thêm được.

Chữa bằng cách hạ ngưỡng xuống — nghe ngược đời nhưng đúng:

sysctl -w vm.dirty_background_ratio=5
sysctl -w vm.dirty_ratio=10

Ngưỡng thấp nghĩa là ghi xuống đĩa sớm và đều hơn, nên không bao giờ tích tụ đủ để phải chặn. Đánh đổi là thông lượng đỉnh giảm.

Với máy nhiều RAM, dùng dạng byte thay vì phần trăm — 10% của 512 GB là 51 GB trang bẩn, một con số vô nghĩa:

sysctl -w vm.dirty_background_bytes=$((256*1024*1024))
sysctl -w vm.dirty_bytes=$((1024*1024*1024))

Đặt dạng _bytes sẽ tự làm _ratio thành 0 và ngược lại — chúng loại trừ nhau, không cộng dồn.

Theo dõi

Hai dòng cần nhìn trong /proc/meminfo:

watch -n1 "grep -E '^(Dirty|Writeback):' /proc/meminfo"

Dirty là dữ liệu chờ ghi. Writeback là dữ liệu đang được ghi.

Trong phép đo của tôi, Writeback luôn hiện 0 KB ở mọi lần lấy mẫu cách nhau 200 ms. Không phải vì không có gì được ghi — mà vì trên NVMe, 512 MB đi qua trạng thái đó nhanh hơn khoảng lấy mẫu. Trên hệ thống chậm hoặc quá tải, Writeback sẽ đứng ở con số lớn hàng chục giây, và đó chính là lúc sync treo.

Dirty lớn và không giảm nghĩa là đang ghi nhanh hơn ổ đĩa chịu được. Lúc đó mọi thứ vẫn có vẻ nhanh — cho tới khi chạm dirty_ratio.

Hai lời gọi ép ghi

fsync(fd);       /* du lieu VA sieu du lieu cua tep nay */
fdatasync(fd);   /* chi du lieu, bo qua thoi gian sua doi... */
sync();          /* toan he thong, khong doi lay ai */

fdatasync rẻ hơn fsync vì bỏ qua phần cập nhật siêu dữ liệu không ảnh hưởng tới việc đọc lại. Phần 14 sẽ đo khoảng cách đó. Còn sync() trên dòng lệnh trả về khi mọi thứ đã xuống — nên time sync sau một lần ghi lớn là cách nhanh nhất để thấy bạn đang nợ đĩa bao nhiêu.

Mẫu số chung: "đã xong" của lớp đệm không phải "đã bền vững"

Cái lõi ở đây không riêng gì ghi tệp: gần như mọi lớp đệm tăng tốc bằng cách báo "xong" ngay khi nhận, rồi đẩy việc thật sự bền vững hoá xuống sau — và khoảng trống giữa hai thời điểm ấy là chỗ dữ liệu có thể bốc hơi nếu có sự cố. Cùng cái bẫy ở mọi tầng.

Trong thế giới Java, đây đúng là BufferedWriter/BufferedOutputStream: write() trả về tức thì vì dữ liệu vào bộ đệm trong bộ nhớ, và nó chỉ chạm hệ điều hành khi flush() — quên flush trước khi thoát là mất phần đuôi mà không lỗi nào báo; và ngay cả flush cũng chỉ đẩy tới page cache, muốn bền thật vẫn cần FileChannel.force(true) (tức fsync). Ba tầng "đã xong" chồng lên nhau, mỗi tầng hứa ít hơn tầng dưới.

Trong Go, cùng chuyện với bufio.Writer và log: một dòng log tưởng đã ghi có thể còn trong buffer khi tiến trình panic; và os.File.Write trả về không có nghĩa đã bền — phải f.Sync(). Người ta mất log sự cố đúng vào lúc cần nó nhất vì buffer chưa flush khi crash.

Còn ở cơ sở dữ liệu, đây là toàn bộ lý do có mức cô lập độ bền: fsync=off hay synchronous_commit=off của Postgres cho commit trả về trước khi WAL chạm đĩa — nhanh hơn nhiều, nhưng một cú sập mất các giao dịch vừa "commit thành công"; và replication bất đồng bộ báo "đã ghi" khi bản sao chưa nhận. Cùng đánh đổi "đã xong nhanh" đổi lấy "có thể mất khi sự cố".

Mẫu số chung: một lớp đệm báo 'đã xong' ngay khi nhận dữ liệu vào bộ nhớ, không phải khi dữ liệu đã bền vững — nên luôn tồn tại một cửa sổ mà 'thành công' đã được báo mà dữ liệu vẫn có thể mất, và cửa sổ đó vô hình cho tới khi có sự cố đúng lúc. Nên câu hỏi đúng khi một thao tác ghi báo thành công không phải "nó xong chưa" mà là "cái 'xong' này nghĩa là đã vào bộ đệm hay đã bền vững, và tôi có chịu được mất khoảng dữ liệu giữa hai mốc đó khi sập không" — vì với tệp tạm thì chịu được, còn với sổ giao dịch thì phải ép xuống đĩa và trả cái giá của fsync.

Muốn tự bắt quả tang lời hứa suông:

sync; sleep 1
grep '^Dirty:' /proc/meminfo

dd if=/dev/zero of=/tmp/thu.bin bs=1M count=512 2>&1 | tail -1
grep '^Dirty:' /proc/meminfo          # ngay sau khi dd bao xong

echo "cho 2 giay..."; sleep 2
grep '^Dirty:' /proc/meminfo

echo -n "time sync: "; time sync
grep '^Dirty:' /proc/meminfo          # gio moi that su bang 0

rm -f /tmp/thu.bin

Dòng Dirty ngay sau dd sẽ hiện vài trăm nghìn KB. Đó là lượng dữ liệu mà ứng dụng của bạn tin là đã an toàn nhưng chưa hề rời khỏi RAM. Nếu time sync mất hơn một giây, hệ thống của bạn đang tích tụ nợ ghi — và ở đâu đó có một tiến trình sắp bị chặn mà không hiểu vì sao.

Phần sau: fsync tốn bao nhiêu — đo trên bốn loại thiết bị.