Bài fsync cho thấy write() không đưa dữ liệu xuống đĩa ngay — nó chỉ đặt vào page cache. Vậy thì khi nào dữ liệu đó thật sự chạm đĩa, nếu bạn không gọi fsync? Câu trả lời là write-back: nhân âm thầm đẩy các trang "bẩn" xuống đĩa theo lịch riêng của nó. Bài này đo chính xác cái lịch đó — dữ liệu vừa ghi nằm chờ bao lâu trước khi được cất an toàn — và con số làm rõ vì sao khoảng chờ đó là một cửa sổ mất dữ liệu đáng sợ.

Ghi đệm và write-back

Trang bẩn và write-back

Khi bạn write() vào một tệp, trang tương ứng trong page cache được đánh dấu bẩn (dirty) — nghĩa là nội dung trong RAM đã mới hơn nội dung trên đĩa. Nhân không ghi xuống đĩa ngay; nó cố ý trì hoãn, vì hai lý do: gom nhiều lần ghi nhỏ thành một lần ghi đĩa lớn (hiệu quả hơn), và cho phép các lần ghi đè lên nhau bị "gộp" (nếu bạn ghi cùng một chỗ ba lần trong một giây, chỉ cần ghi xuống đĩa một lần). Việc đẩy các trang bẩn xuống đĩa gọi là write-back, do một luồng nhân (flusher) đảm nhiệm.

Write-back được kích hoạt bởi hai điều kiện, đọc được trong /proc/sys/vm/:

  • Thời gian: một trang bẩn "quá hạn" dirty_expire_centisecs (mặc định 30 giây) thì đủ điều kiện bị đẩy; luồng flusher thức dậy mỗi dirty_writeback_centisecs để quét.
  • Lượng: khi tổng trang bẩn vượt dirty_background_ratio (10%) của RAM, flusher bắt đầu đẩy nền; vượt dirty_ratio (20%), lần write() của chính bạn bị chặn để buộc dọn bớt.

Tôi đo điều kiện thời gian: ghi rồi ngồi nhìn, xem trang bẩn nằm chờ bao lâu.

Đo: Dirty nằm nguyên 30 giây

Tôi ghi 300 MB vào một tệp (không fsync), rồi theo dõi trường Dirty trong /proc/meminfo mỗi 3 giây:

Thời điểm Dirty
Trước khi ghi 0 MB
t = 0 → 30 s 300 MB (nằm nguyên)
t = 33 s 0 MB (đã đẩy)
Sau khi ép sync 0 MB

Con số rất dứt khoát: sau khi ghi, 300 MB trở thành trang bẩn ngay lập tức — và nằm nguyên đó suốt 30 giây, không hề giảm. Rồi giữa giây 30 và 33, nhân đẩy sạch xuống đĩa và Dirty về 0 — không phải giảm dần đều mà tụt một phát, vì cả lô trang cùng chạm mốc quá hạn một lúc. Cái mốc đó khớp chính xác với dirty_expire_centisecs = 30 giây: các trang trở nên "quá hạn" ở tuổi 30 giây, và luồng flusher (thức dậy theo chu kỳ) quét chúng đi ngay sau đó. Nếu tôi ép sync, Dirty về 0 tức thì — chứng tỏ dữ liệu vẫn ở đó chờ, chỉ là chưa tới lịch.

Một lần tôi đo hớ: cửa sổ quan sát quá ngắn

Tôi vào bài này với giả định rằng nhân đẩy trang bẩn xuống đĩa khá nhanh — nên ban đầu tôi định chỉ theo dõi Dirty trong một quãng ngắn, chừng 5 tới 10 giây. Nếu làm vậy, tôi đã thấy Dirty ghim nguyên 300 MB suốt cả cửa sổ đó và kết luận sai rằng "nhân chẳng đẩy gì cả, write-back không hoạt động".

May là tôi kéo dài phép đo tới hơn 33 giây. Và sự thật lộ ra: nhân đẩy, nhưng chỉ sau ~30 giây — đúng mốc dirty_expire. Đây chính xác là cùng một cái bẫy đã gặp ở bài load average: một hiện tượng vận hành theo một bộ đếm giờ dài (30 giây ở đây, một phút ở load average) thì một cửa sổ quan sát vài giây sẽ không bao giờ thấy nó, và bạn dễ tưởng nó "không xảy ra".

Bài học đo lường có hai tầng. Tầng phương pháp: đo một hiện tượng có mốc thời gian thì phải quan sát đủ dài — dài hơn hẳn cái mốc đó, kẻo bạn chụp một khoảnh khắc trước khi sự việc kịp diễn ra và kết luận ngược. Tầng nội dung, quan trọng hơn: write-back cố tình lười tới 30 giây. Nghĩa là dữ liệu bạn write() xong có thể nằm chỉ trong RAM tới nửa phút trước khi chạm đĩa. Nếu máy mất điện trong nửa phút đó, toàn bộ 300 MB "đã ghi" ấy bay sạch. Đây là lời giải thích trực quan nhất cho câu hỏi "vì sao cần fsync": vì nếu không ép, khoảng trống giữa "ghi xong" và "bền" rộng tới ~30 giây.

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

Hệ quả đầu tiên là hiểu độ rộng của cửa sổ mất dữ liệu. Mặc định, dữ liệu ghi mà không fsync có thể chưa bền trong tới ~30 giây. Với một ứng dụng bình thường (lưu tệp tạm, ghi log không tối quan trọng) thì chấp nhận được — đổi lại là hiệu năng ghi cao (tốc độ cache). Nhưng với dữ liệu không được phép mất (giao dịch, đơn hàng), bạn phải fsync để đóng cửa sổ đó lại. Biết con số 30 giây giúp bạn lượng hóa rủi ro thay vì mơ hồ.

Hệ quả thứ hai là cẩn thận với những đợt write-back đột ngột gây giật. Vì trang bẩn dồn lại rồi mới đẩy, một ứng dụng ghi liên tục có thể tích lũy hàng trăm MB–GB bẩn, và khi flusher đẩy (hoặc khi vượt dirty_ratio), một lượng lớn I/O đĩa bùng lên cùng lúc — gây ra những cú giật độ trễ khó hiểu, kể cả cho các tiến trình khác đang cố đọc đĩa. Đây là lý do các hệ thống nhạy độ trễ đôi khi hạ dirty_background_ratio xuống thấp, để nhân đẩy đều đặn từng chút thay vì dồn thành cơn bão. Các sysctl vm.dirty_* là nút chỉnh cho sự đánh đổi này.

Hệ quả thứ ba nối lại chuỗi bài về I/O: page cache làm cả đọc lẫn ghi có vẻ nhanh bằng cách hoãn công việc thật xuống đĩa. Đọc nhanh vì dữ liệu đã ở cache; ghi nhanh vì dữ liệu mới nằm ở cache và write-back hoãn việc xuống đĩa. Cả hai đều tuyệt cho hiệu năng, nhưng cả hai đều là lời hứa hoãn lại, và bạn phải biết khi nào lời hứa đó được thực hiện. Con số mang theo: dữ liệu ghi không fsync nằm ở trạng thái bẩn trong RAM tới ~30 giây (đúng mốc dirty_expire) trước khi nhân tự đẩy xuống đĩa — đó là cửa sổ mất dữ liệu, đo được bằng cách nhìn Dirty trong /proc/meminfo tan sau 30 giây. "Đã ghi" không phải "đã bền"; khoảng cách giữa hai điều đó là ~30 giây, và fsync là cách rút nó về 0.

Thử ba mươi giây

Trên máy Linux, mở hai cửa sổ. Cửa một, theo dõi trang bẩn: watch -n1 "grep -E 'Dirty|Writeback' /proc/meminfo". Cửa hai, ghi một tệp lớn không đồng bộ: dd if=/dev/zero of=/tmp/f bs=1M count=500. Quay lại cửa một: bạn sẽ thấy Dirty nhảy vọt lên ~500 MB ngay, rồi nằm đó vài chục giây, rồi tự tụt về 0 mà bạn không làm gì — đó là flusher của nhân đang lặng lẽ đẩy xuống đĩa theo lịch. Xem các nút chỉnh bằng cat /proc/sys/vm/dirty_expire_centisecs (thường 3000 = 30 giây). Cái khoảng thời gian Dirty nằm cao đó chính là cửa sổ mà dữ liệu của bạn "đã ghi" nhưng chưa an toàn — đúng thứ bài này đo.