Ở bài về page cache, ta thấy đọc file đi qua một lớp đệm RAM. Ghi cũng vậy — và điều đó dẫn tới một trong những hiểu lầm nguy hiểm nhất trong lập trình: write() trả về thành công không có nghĩa dữ liệu đã nằm an toàn trên đĩa. write() chỉ đặt dữ liệu vào page cache rồi trả về ngay; nếu mất điện lúc đó, dữ liệu bay mất. Muốn chắc chắn dữ liệu bền vững, phải gọi fsync(). Bài này đo cái giá của độ bền đó — và một lần tôi suýt báo cáo sai vì so nhầm hai con số.
write() nhanh vì nó chưa làm việc bền vững
Khi bạn gọi write(), kernel copy dữ liệu vào page cache (bộ đệm trong RAM) và trả về ngay — nó chưa ghi xuống đĩa. Việc ghi thật xuống thiết bị lưu trữ được kernel làm sau, theo nhóm, vào một thời điểm thuận tiện (gọi là writeback). Nhờ vậy write() cực nhanh, nhưng cũng cực nguy hiểm nếu bạn hiểu nhầm: dữ liệu bạn vừa "ghi" đang nằm trong RAM dễ bay hơi, chưa bền.
Để buộc dữ liệu xuống lưu trữ bền vững, có fsync(fd) — đẩy cả dữ liệu và metadata (kích thước, thời gian sửa...) của file xuống đĩa và chặn cho tới khi đĩa xác nhận — và fdatasync(fd), phiên bản gọn hơn chỉ đẩy dữ liệu (bỏ qua metadata không cần thiết). Đây là chi phí thật của độ bền (durability): mỗi lần một cơ sở dữ liệu commit một giao dịch, mỗi dòng một write-ahead log muốn sống sót qua sự cố, đều phải trả một lần fsync.
Đo: 0,39 micro giây so với 518
Tôi ghi 2000 khối 512 byte vào một file, ghim lõi 0, đo hai cách: không fsync gì (để dữ liệu nằm trong page cache), và fdatasync sau mỗi lần ghi (ép bền vững từng khối). Trên hệ thống file của container (overlayfs):
| overlayfs (/tmp) | Thời gian mỗi lần ghi |
|---|---|
write không fsync |
0,39 µs |
write + fdatasync |
517,90 µs |
Chênh nhau 1312 lần. strace xác nhận fdatasync thật sự được gọi và trả về 0 sau mỗi write — không phải tôi tưởng tượng. Ghi vào page cache tốn chưa tới một micro giây; ép nó xuống lưu trữ bền vững tốn hơn nửa mili giây — chậm hơn cả nghìn lần. Đó là cái giá của độ bền, và nó là chi phí chi phối trong bất kỳ hệ thống nào cần dữ liệu sống sót qua sự cố.
Một lần tôi đo hớ: tỉ lệ nói dối, con số tuyệt đối mới thật
Nhìn con số 1312 lần, tôi định gõ ngay một câu giật gân: "fsync chậm gấp 1312 lần một lần ghi". Nhưng khi đo thêm trên một filesystem khác — bind mount FUSE — tỉ lệ đổ sụp:
fuseblk (/m): write không fsync 29,15 µs | write+fdatasync 122,66 µs | tỉ lệ 4,2x
Chỉ 4,2 lần, không phải 1312. Nếu tôi chỉ đo trên FUSE, tôi sẽ kết luận "fsync chỉ đắt gấp 4 lần, cũng không sao lắm"; nếu chỉ đo trên overlayfs, tôi kết luận "fsync đắt kinh khủng, 1312 lần". Hai con số mâu thuẫn nhau — dấu hiệu quen thuộc của việc đo nhầm đại lượng.
Sự thật: tỉ lệ đó trộn hai thứ khác nhau. Trên overlayfs, một write không fsync gần như miễn phí (0,39 µs — chỉ bỏ dữ liệu vào page cache), nên bất cứ thứ gì có chi phí thật (như fsync 518 µs) trông đắt gấp hàng nghìn lần. Trên FUSE, một write thường đã chậm sẵn (29 µs — vì FUSE chuyển mỗi lần ghi qua kênh tới máy chủ), nên fsync thêm vào chỉ trông gấp 4 lần. Tỉ lệ 1312x không phải fsync bất thường chậm — mà là write() bất thường nhanh (vì nó chưa làm việc bền vững). Con số trung thực là chi phí tuyệt đối của độ bền: ~518 µs trên overlayfs, ~94 µs trên FUSE — chứ không phải cái tỉ lệ phụ thuộc hoàn toàn vào việc write baseline nhanh hay chậm.
Bài học đo lường: một tỉ lệ (X nhanh/chậm gấp mấy lần Y) chỉ có nghĩa khi bạn biết cả tử số lẫn mẫu số đang đo gì. Cái tỉ lệ 1312x của tôi nói về write nhiều như nói về fsync; báo cáo nó như "fsync chậm 1312 lần" là gán toàn bộ cho fsync một con số mà một nửa thuộc về việc write gần như không làm gì. Và bài học sâu hơn, đúng khẩu hiệu của sê-ri: write() nhanh là hiểm họa, không phải tính năng — nó nhanh chính vì nó chưa bền vững hóa dữ liệu, đang "nói dối" rằng đã lưu xong. write() trả về thành công không phải câu trả lời cho "dữ liệu đã an toàn chưa"; chỉ fsync/fdatasync mới là.
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ố, bạn PHẢI gọi fsync — và trả cái giá của nó. Không có đường tắt: write() một mình không bền, dù nó trả về thành công. Đây là lý do các cơ sở dữ liệu, hệ thống hàng đợi, và bất kỳ store nào hứa "durable" đều fsync (hoặc dùng O_SYNC) tại đúng điểm commit — và vì sao thông lượng commit của chúng bị giới hạn bởi tốc độ fsync của đĩa, không phải tốc độ write. Một SSD tốt fsync trong ~vài trăm micro giây; một HDD trong ~vài mili giây; đó là trần thông lượng giao dịch của bạn.
Hệ quả thứ hai: gộp nhiều ghi vào một fsync, đừng fsync sau mỗi ghi nhỏ. Vì cái đắt là lần fsync, không phải lượng dữ liệu, ghi 100 bản ghi rồi fsync một lần rẻ hơn nhiều so với fsync sau mỗi bản ghi. Đây chính là ý tưởng của group commit trong cơ sở dữ liệu: gom các giao dịch của nhiều client vào một lần fsync chung. Nếu code của bạn fsync trong một vòng lặp ghi từng dòng, bạn đang trả cái giá 518 µs đó cho mỗi dòng — thường là sai lầm hiệu năng lớn nhất trong một đường ghi.
Hệ quả thứ ba là bài học đo lường mang theo: so sánh bằng tỉ lệ phải kèm con số tuyệt đối và hiểu cả hai vế. Con số mang theo: write() chỉ đặt dữ liệu vào page cache và trả về (0,39 µs, chưa bền — mất điện là mất), còn fdatasync ép xuống lưu trữ bền vững tốn ~518 µs trên overlayfs (gấp 1312 lần) hay ~94 µs trên FUSE (gấp 4,2 lần) — cái tỉ lệ khác nhau là vì write baseline khác nhau, không phải fsync; và write() trả về thành công KHÔNG bảo đảm độ bền, chỉ fsync/fdatasync mới. Khi hỏi "cái này đắt cỡ nào", đừng dừng ở tỉ lệ — hỏi tiếp "so với cái gì, và cái gì đó đang thật sự làm gì".
Thử ba mươi giây
Viết một chương trình ghi 1000 dòng vào một file hai cách: một lần không fsync, một lần gọi fdatasync(fd) sau mỗi dòng, rồi so thời gian. Bạn sẽ thấy bản có fsync chậm hơn hàng chục tới hàng nghìn lần — và đó là toàn bộ chi phí của độ bền. Rồi chạy strace -e trace=write,fdatasync ./chuong-trinh để thấy fdatasync thật sự được gọi (nếu không thấy, dữ liệu của bạn không bền, dù chương trình chạy xong êm ru). Cuối cùng, thử fsync một lần sau cả 1000 dòng thay vì sau mỗi dòng: nhanh hơn hẳn, mà vẫn bền — đó là group commit, và là cách đúng để trả giá độ bền chỉ một lần thay vì một nghìn lần.