phần đọc file ta thấy page cache giấu độ trễ đĩa cho việc đọc. Với việc ghi, cùng cơ chế đó tạo ra một cái bẫy tinh vi hơn nhiều — và là nguồn của vô số bug mất dữ liệu trong thực tế. Câu hỏi: khi write() trả về thành công, dữ liệu của bạn đã thật sự nằm trên đĩa chưa? Gần như ai mới học cũng trả lời "rồi". Đo cho thấy: chưa. Tôi đo trong container gcc:13 (ARM), so ghi buffered với ghi ép xuống đĩa bằng fsync, và con số vạch ra ranh giới giữa "ghi nhanh" và "ghi bền".

write và page cache

write() trả về trước khi lên đĩa

Khi bạn gọi write() theo cách thường (buffered), nhân không ghi thẳng dữ liệu xuống đĩa rồi mới trả về. Nó chỉ copy dữ liệu vào page cache (RAM), đánh dấu các trang đó là "bẩn" (dirty — đã sửa, chưa lên đĩa), rồi trả về ngay. Đĩa chưa hề bị chạm.

Việc ghi các trang bẩn xuống đĩa thật xảy ra sau đó, bất đồng bộ, do một cơ chế của nhân gọi là write-back: nhân gom nhiều trang bẩn, chọn thời điểm thuận lợi, rồi flush chúng xuống thiết bị. Nhờ trì hoãn này, write() chạy ở tốc độ RAM và hút được cả những cụm ghi dồn dập — bạn không phải chờ đĩa mỗi lần.

Cái giá của sự nhanh đó: sau khi write() trả về, dữ liệu nằm trong RAM, chưa bền. Nếu mất điện hay nhân sập trước khi write-back kịp flush, dữ liệu "đã ghi" đó biến mất. Muốn chắc chắn dữ liệu lên đĩa, bạn phải gọi fsync() — buộc write-back ngay lập tức và chờ thiết bị xác nhận đã ghi xong.

Đo: buffered nhanh 7 lần, nhưng chưa bền

Tôi ghi một file 64 MB theo hai cách — buffered thuần, và buffered rồi fsync một lần ở cuối để ép mọi thứ lên đĩa — cộng một phép đo ghi bền từng cái (fsync sau mỗi ghi 4 KB):

write BUFFERED (chỉ vào cache)     : 18,60 GB/s
write + fsync 1 lần (buộc lên đĩa) :  2,67 GB/s   -> chậm 7×
write + fsync MỖI 4KB (bền từng cái): 557 µs/lần

Ghi buffered đạt 18,6 GB/s — tốc độ bộ nhớ, vì write() chỉ copy vào page cache rồi trả về, không chờ đĩa. Ép cùng 64 MB đó thật sự lên đĩa bằng fsync chỉ còn 2,67 GB/s — chậm 7 lần. Bảy lần chênh lệch đó chính là phần việc mà buffered hoãn lại: ghi vật lý xuống thiết bị. Và nếu bạn cần mỗi ghi bền ngay (fsync sau mỗi 4 KB, như một cơ sở dữ liệu commit từng giao dịch), mỗi ghi tốn 557 µs — vì mỗi lần phải đợi đĩa. (Thành thật: đĩa nền container nhanh nên 557 µs còn lạc quan; trên đĩa quay thật, một fsync có thể ~10 ms, chậm hơn nữa.)

Một lần tôi đo hớ: "đã ghi" không bằng "đã bền"

Tôi vào đo với niềm tin gần như phổ quát ở người mới: "write() trả về thành công nghĩa là dữ liệu đã an toàn trên đĩa". Phép đo bác bỏ ngay — tốc độ 18,6 GB/s không thể là tốc độ ghi đĩa thật (đĩa nhanh nhất cũng chỉ vài GB/s bền vững, và ở đây là gấp 7 lần con số fsync). Con số cao đó là bằng chứng write() không chạm đĩa: nó chỉ copy vào RAM. Dữ liệu "đã ghi" của tôi lúc đó nằm trong page cache dưới dạng trang bẩn, chưa bền — một lần mất điện là mất sạch phần chưa write-back.

Bài học đo lường, và cũng là một bài học đúng đắn (correctness): "đã ghi" (write trả về) và "đã bền" (dữ liệu chắc chắn trên đĩa) là hai chuyện khác nhau — chỉ fsync mới bắc cầu giữa chúng. Đây là chỗ ẩn của vô số bug mất dữ liệu: một chương trình write() một file cấu hình / một bản ghi giao dịch / một checkpoint, thấy write() trả về thành công, rồi giả định nó đã bền — và mất dữ liệu khi máy sập trước write-back (thường vài giây sau). Tệ hơn, bug này không tái hiện trong test: write-back gần như luôn kịp chạy trong môi trường bình thường, chỉ hỏng đúng lúc mất điện thật — giống hệt các bug đồng thời chạy đúng trong test rồi hỏng ở production mà sê-ri trước đã gặp. Con số 7 lần cho thấy vì sao người ta muốn buffered (nhanh, hút cụm ghi), nhưng cũng cho thấy cái giá phải trả nếu bạn cần độ bền: phải fsync, và chấp nhận nó chậm.

Vì sao điều này quan trọng khi lập trình

Hệ quả đầu tiên: nếu dữ liệu phải sống sót qua sự cố, write() là chưa đủ — phải fsync (hoặc mở file với O_SYNC). Bản ghi giao dịch, file quan trọng, trạng thái phải bền: sau khi ghi, gọi fsync(fd) và kiểm mã trả về trước khi báo "đã lưu thành công". Bỏ qua bước này là mời một mất mát dữ liệu im lặng vào đúng lúc tệ nhất.

Hệ quả thứ hai: nhưng đừng fsync bừa — nó đắt, và phần lớn ghi không cần bền tức thì. Ghi log thường, file tạm, dữ liệu tái tạo được thì buffered là đúng: nhanh 7 lần, và write-back của nhân tự lo phần còn lại. Chỉ trả cái giá fsync cho những gì thật sự cần độ bền. Cơ sở dữ liệu tối ưu chính điểm này: gom nhiều giao dịch rồi fsync một lần (group commit) để amortize cái 557 µs — đúng tinh thần batching của sê-ri đồng thời.

Hệ quả thứ ba là tinh thần đo lường: một thao tác trả về nhanh chưa chắc đã hoàn tất việc bạn nghĩ — kiểm nó thật sự làm gì. Con số mang theo: write() thường chỉ copy dữ liệu vào page cache (RAM) rồi trả về ngay, đánh dấu trang bẩn; đĩa được ghi SAU bằng write-back bất đồng bộ — nên write buffered đạt 18,6 GB/s (tốc độ RAM), nhanh 7× write+fsync (2,67 GB/s, buộc lên đĩa); và một ghi bền từng cái (fsync mỗi 4KB) tốn 557µs. Sau khi write() trả về, dữ liệu CHƯA bền — mất điện trước write-back là mất; muốn bền phải fsync và chịu chậm. "Đã ghi" không bằng "đã bền".

Thử ba mươi giây

Nhìn đoạn code gần nhất của bạn ghi một thứ quan trọng xuống file — một bản ghi, một checkpoint, một file cấu hình người dùng vừa lưu. Tìm xem sau write() (hay fwrite/f.write()) có một fsync (hay flush rồi os.fsync, hay O_SYNC) không. Nếu không, hỏi: chuyện gì xảy ra nếu máy mất điện ngay sau khi bạn báo người dùng "đã lưu"? Với buffered thuần, dữ liệu có thể vẫn nằm trong page cache và biến mất — người dùng thấy "đã lưu" nhưng dữ liệu không còn. Ba mươi giây kiểm sự vắng mặt của fsync đó bắt được một lớp bug mất dữ liệu mà không lần test bình thường nào lộ ra — vì nó chỉ hỏng đúng vào khoảnh khắc mà bạn cần dữ liệu còn đó nhất.