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ợ.
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ỗidirty_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ượtdirty_ratio(20%), lầnwrite()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.
Cửa sổ quan sát quá ngắn suýt cho tôi kết luận ngược
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 có đẩ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".
Điều rút ra 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ề đến code hằng ngày
Điều đầ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ồ.
Điều 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.
Điều cuối 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. Gói lại bằng số: 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 ghim 300 MB rồi 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.
Cái ranh giới "ghi xong ≠ đã bền, có một khoảng hoãn trước khi dữ liệu thật sự an toàn" này là bài học nền của mọi hệ lưu trữ, và mỗi tầng đều dựng cơ chế quanh nó. Trong Java, BufferedWriter.write hay FileOutputStream.write chỉ đưa dữ liệu vào buffer ứng dụng rồi page cache của OS — muốn bền phải gọi flush() (đẩy buffer app) rồi FileChannel.force(true)/FileDescriptor.sync() (ép OS xuống đĩa), hai bước hoàn toàn khác nhau mà người ta hay nhầm là một; đó cũng là lý do một System.out.println "đã in" có thể mất khi JVM crash trước khi flush. Go có đúng cặp: bufio.Writer.Flush() (buffer app) và os.File.Sync() (fsync OS). Cơ sở dữ liệu sống chết với cửa sổ này: đây chính là lý do WAL/redo log tồn tại và phải fsync trước khi báo "commit thành công" — PostgreSQL có synchronous_commit, MySQL có innodb_flush_log_at_trx_commit, và hạ các tham số đó xuống để nhanh hơn nghĩa là chấp nhận đúng cửa sổ mất dữ liệu 30 giây của bài này, đổi độ bền lấy tốc độ. Mẫu số chung: ở mọi tầng, "ghi" thường chỉ là đặt dữ liệu vào một bộ đệm hoãn-lại (buffer app → page cache → đĩa), và mỗi tầng đệm là một cửa sổ có thể mất; khi dữ liệu không được phép mất, phải chủ động ép qua mọi tầng đệm (flush + fsync), còn khi tốc độ quan trọng hơn thì tận dụng sự hoãn đó — nhưng luôn biết chính xác cửa sổ rủi ro rộng bao nhiêu, chứ đừng tưởng "ghi xong là an toàn".
Muốn tự thấy thì 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.