Hình dung một trợ lý cực nhanh: bạn đọc một thông tin, anh ta đáp "xong!" ngay lập tức. Nhưng "xong" đó chỉ có nghĩa anh ta ghi vội lên một tờ giấy nhớ dán trên bàn — chưa phải đã cất vào tủ hồ sơ lưu trữ. Việc xếp giấy nhớ vào tủ diễn ra sau, theo từng đợt, do một nhân viên lưu trữ làm khi rảnh. Nếu văn phòng bị dọn sạch trước lúc xếp tủ, tờ giấy nhớ bay theo — dù bạn đã nghe "xong". write() của hệ điều hành hành xử đúng anh trợ lý đó, và cái khoảng cách giữa "đã nghe xong" với "đã vào tủ" là nguồn của vô số vụ mất dữ liệu.
Ở 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. 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() 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. Đúng tờ giấy nhớ dán trên bàn.
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ị — đúng anh nhân viên xếp giấy nhớ vào tủ theo đợt. 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 về phép đo: đĩ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. Con số tuyệt đối đổi theo phần cứng; cái bền vững là: buffered chạy tốc độ RAM vì không chạm đĩa, còn ép-bền chậm hơn hẳn.)
Hai niềm tin đối nghịch, cùng sai
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 (nó 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 đó là trang bẩn trong page cache, chưa bền — một lần mất điện là mất sạch phần chưa write-back. "Đã ghi" (write trả về) và "đã bền" (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.
Nhưng có một niềm tin ngược, dễ mắc ngay sau khi biết chuyện này, cũng sai: "vậy phải fsync sau mọi write() cho chắc". Không — nhìn cái giá: fsync mỗi ghi tốn 557 µs, chậm hàng trăm lần, và phần lớn dữ liệu không cần bền tức thì (log thường, file tạm, cache, dữ liệu tái tạo được). Rải fsync bừa là tự bóp nghẹt throughput để mua một sự an toàn bạn không cần. Sự thật nằm ở chỗ biết ranh giới bền của mình: cái gì phải sống sót qua sự cố thì fsync (và chấp nhận chậm), cái gì không thì để buffered bay ở tốc độ RAM. Cái bẫy nguy hiểm nhất là giả định — tin write() đã bền rồi 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 ở 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.
Về đến code hằng ngày
Điều đầ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.
Điều 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. Chỉ trả cái giá fsync cho những gì thật sự cần, và gom nhiều ghi rồi fsync một lần (group commit) để amortize cái 557 µs — đúng tinh thần batching.
Chỗ này có một cái bẫy tầng-cao cực phổ biến đáng gọi tên: flush không phải fsync. BufferedWriter.flush() của Java, f.flush() của Python, fflush() của C — tất cả chỉ đẩy dữ liệu từ buffer của thư viện xuống page cache của OS; nó vẫn chưa bền, y hệt write() thô. Muốn bền thật phải đi thêm một bước: Java FileChannel.force(true) hoặc FileDescriptor.sync(), Python os.fsync(f.fileno()) (sau f.flush()). Vô số người tưởng "flush xong là an toàn" và mất dữ liệu vì đúng nhầm lẫn này. Đây cũng là lý do trình soạn thảo và công cụ lưu file cẩn thận theo mẫu ghi-file-tạm → fsync → rename nguyên tử, và là gốc của cả cuộc tranh cãi ext4 "mất file sau khi rename mà không fsync" (vụ O_PONIES) nhiều năm trước. Còn database thì WAL + fsync là xương sống của độ bền. Mẫu số chung: "đã ghi" ở tầng nào cũng không tự động nghĩa "đã bền" — phải xuống tới đĩa và chờ xác nhận.
Muốn tự thấy thì 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. Vài phút 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.