fsync và độ bền — write() trả về chưa chắc đã ghi
Từ page cache xuống mặt đĩa: dirty page, write-back, fsync và fdatasync, write barrier, cache của chính ổ đĩa. Trả bao nhiêu độ trễ để đổi lấy độ bền.
TL;DR: Khi write() trả về thành công, thứ duy nhất bạn biết chắc là kernel đã nhận dữ liệu — nó đang nằm trong page cache dưới dạng trang bẩn, chưa chạm mặt đĩa. Mất điện lúc này là mất trắng. fsync là lệnh buộc kernel đẩy trang bẩn xuống thiết bị và bảo thiết bị xả bộ đệm riêng của nó, nên nó trả tiền bằng độ trễ để đổi lấy độ bền. Cái bẫy đắt nhất: ghi file tạm rồi rename mà quên fsync thư mục cha — crash có thể làm mất cả hai file.
Bạn viết một service nhận đơn hàng. Ghi đơn xuống file, write() trả về không lỗi, service trả 200 OK cho khách. Chạy ngon suốt nhiều tháng. Rồi trung tâm dữ liệu mất điện đột ngột, máy khởi động lại, và bạn phát hiện hơn ba mươi giây đơn hàng cuối cùng đã bốc hơi — dù log ứng dụng ghi rõ chúng đã được ghi thành công.
Không có bug nào trong code cả. Vấn đề nằm ở chỗ write() thành công và "dữ liệu đã nằm an toàn trên đĩa" là hai chuyện hoàn toàn khác nhau, và khoảng cách giữa chúng rộng đúng bằng con số 30 giây bạn vừa gặp.
1. Analogy — nhờ đồng nghiệp chuyển lời nhắn
Bạn nhờ một đồng nghiệp gửi thông tin quan trọng cho phòng kế toán. Hắn gật đầu: "Ừ, tôi nhận rồi." Câu gật đầu đó là write() trả về.
Nhưng thực tế thì lời nhắn mới nằm trên tờ giấy nhớ dán ở màn hình của hắn. Cuối buổi hắn mới gom hết giấy nhớ đi nhập vào hệ thống — đó là write-back. Mà ngay cả khi đã nhập, hệ thống cũng chưa chắc ghi thẳng xuống máy chủ: nó còn một bộ đệm riêng, gom vài phút mới đồng bộ một lần.
Vậy giữa lúc hắn gật đầu và lúc kế toán thật sự thấy dữ liệu, có ba chỗ thông tin có thể biến mất: tờ giấy nhớ rơi, phiên nhập liệu bị hỏng, bộ đệm hệ thống mất điện. Câu "tôi nhận rồi" chỉ bảo đảm được chặng đầu tiên.
| Trong văn phòng | Trong máy tính |
|---|---|
| Đồng nghiệp gật đầu "nhận rồi" | write() trả về thành công |
| Tờ giấy nhớ trên màn hình | Trang bẩn trong page cache |
| Cuối buổi gom giấy đi nhập | write-back của kernel |
| Bộ đệm riêng của hệ thống kế toán | Bộ đệm volatile bên trong ổ đĩa |
| Bắt hắn đi nhập ngay bây giờ và chờ xác nhận | fsync |
write() trả lời câu hỏi "bạn đã nhận chưa". fsync trả lời câu hỏi "bạn đã cất vào chỗ mất điện không bay chưa". Hai câu hỏi khác nhau, và chỉ câu thứ hai mới là độ bền.
2. Dữ liệu phải xuyên qua mấy tầng đệm?
Từ lúc chương trình gọi ghi cho tới lúc bit nằm yên trên vật liệu, dữ liệu đi qua một chuỗi tầng, mỗi tầng là một chỗ nó có thể bốc hơi:

Tầng đầu tiên là bẫy dễ nhất: nếu bạn dùng fwrite hay hàm in của ngôn ngữ bậc cao, dữ liệu còn chưa rời khỏi tiến trình của bạn — nó nằm trong buffer thư viện. Phải fflush trước thì nó mới thành một write() thật.
Nhưng write() thành công cũng mới chỉ đưa dữ liệu tới tầng thứ tư. Bài trước đã dựng bức tranh này: trang vừa sửa trở thành trang bẩn, và kernel đẩy chúng xuống đĩa theo lịch riêng của nó — mặc định trang bẩn được phép nằm lại tối đa 30 giây trước khi flusher đụng tới. Ba mươi giây đó chính là lượng đơn hàng bạn mất ở đầu bài.
Bạn đã gặp khái niệm trang bẩn ở đó, trong ngữ cảnh bộ nhớ ảo: trang bị sửa thì phải ghi ra trước khi thu hồi. Ở đây vẫn đúng cơ chế ấy, nhưng đổi câu hỏi — không phải "khi nào kernel buộc phải ghi ra" mà là "làm sao tôi bắt nó ghi ra ngay bây giờ".
3. Vậy fsync đảm bảo gì, và fdatasync khác chỗ nào?
fsync(fd) yêu cầu kernel đẩy mọi thay đổi của file xuống thiết bị lưu trữ và chỉ trả về sau khi thiết bị báo đã nhận xong. Nó đồng bộ hai thứ: nội dung file, và metadata của file — kích thước, thời gian sửa, bản đồ block.
fdatasync(fd) làm y hệt nhưng bỏ qua phần metadata không cần thiết cho việc đọc lại đúng dữ liệu. Thời gian sửa đổi đổi hay không thì nội dung đọc ra vẫn thế, nên bỏ qua được. Nhưng nếu file vừa dài ra, kích thước mới là thứ bắt buộc phải bền — thiếu nó thì đọc lại sẽ không thấy phần vừa ghi. Trong trường hợp đó fdatasync vẫn đồng bộ metadata.
Chênh lệch giữa hai lệnh là một lượt cập nhật metadata, mà metadata thường nằm ở vùng khác trên đĩa. Với ổ quay, khác vùng nghĩa là thêm một lần nhảy chỗ — đúng khoản phạt cơ học bài 01 đã tính. Đó là lý do các hệ ghi log cường độ cao thường chọn fdatasync và cấp phát trước dung lượng file để kích thước không đổi.
Còn một tầng nữa nằm dưới cả hai lệnh này. Bản thân ổ đĩa cũng có bộ đệm riêng, và nó volatile — mất điện là mất. Nếu kernel chỉ đẩy dữ liệu tới đó rồi coi là xong thì fsync sẽ nói dối. Vì vậy tầng block của Linux gửi kèm hai loại yêu cầu đặc biệt: REQ_PREFLUSH buộc thiết bị xả sạch bộ đệm đang có trước khi thực hiện, và REQ_FUA yêu cầu ghi thẳng xuống vật liệu bền chứ không dừng ở bộ đệm. Filesystem phát ra hai yêu cầu này mà không cần biết thiết bị bên dưới có bộ đệm hay không — nếu không có, tầng dưới bỏ qua.
Đây cũng là chỗ ổ doanh nghiệp ăn tiền. Ổ có tụ chống mất điện đủ sức xả bộ đệm khi điện tắt, nên bộ đệm của nó không còn volatile theo nghĩa cũ; nó xác nhận bền ngay mà không phải chờ ghi xuống vật liệu. Cùng một lệnh fsync, cùng một dòng code, nhưng cái giá chênh nhau hàng bậc.
4. Pattern ghi an toàn — và chỗ nó thường vỡ
Cách chuẩn để thay nội dung một file mà không bao giờ để lại file hỏng giữa chừng là ghi ra file tạm rồi đổi tên đè lên:
# Y tuong: rename la thao tac nguyen tu trong cung mot filesystem.
# Doc gia hoac thay file cu nguyen ven, hoac thay file moi nguyen ven.
ghi du lieu -> data.db.tmp
rename(data.db.tmp, data.db)
Giả sử bạn làm đúng như trên, có gọi fsync cho data.db.tmp trước khi rename. Ngay sau lệnh rename thì máy mất điện. Khởi động lại, bạn có thể thấy những trạng thái nào của thư mục? Viết ra tất cả các khả năng bạn nghĩ tới trước khi đọc tiếp.
Câu trả lời làm nhiều người ngạc nhiên: ngoài hai trạng thái lành lặn mà bạn mong đợi, còn một khả năng thứ ba — không thấy file nào cả.
Lý do là rename sửa thư mục, mà thư mục cũng chỉ là dữ liệu trên đĩa. Bạn đã fsync nội dung file tạm, nhưng cái directory entry mới trỏ tên data.db sang inode mới thì vẫn đang là trang bẩn trong page cache, chưa xuống đĩa. Mất điện đúng lúc đó thì thao tác đổi tên chưa từng xảy ra trên đĩa — trong khi entry cũ có thể đã bị gỡ. Chính man page của fsync nói thẳng điều này: muốn thao tác tạo hoặc đổi tên bền, bạn phải fsync luôn file descriptor của thư mục cha.
# SAI - chi fsync noi dung file
fsync(fd_tmp)
rename("data.db.tmp", "data.db")
# mat dien o day -> co the mat CA HAI
# DUNG - fsync them thu muc cha sau khi rename
fsync(fd_tmp)
rename("data.db.tmp", "data.db")
fd_dir = open("/duong/dan/thu/muc", O_RDONLY)
fsync(fd_dir)
close(fd_dir)
Đây là loại lỗi sống sót rất lâu trong production, vì nó chỉ lộ ra khi mất điện đột ngột — mà máy chủ thì hiếm khi mất điện đột ngột, cho tới ngày nó mất.
5. Pitfall của riêng concept này
❌ Nhầm 1 — tưởng đóng file là đã đồng bộ.
✅ close() không có nghĩa là dữ liệu đã bền; nó chỉ giải phóng file descriptor. Trang bẩn vẫn nằm đó chờ write-back. Muốn bền thì fsync trước khi đóng, và kiểm tra giá trị trả về của nó.
❌ Nhầm 2 — bỏ qua giá trị trả về của fsync.
✅ fsync có thể thất bại, và trên một số filesystem thì lần gọi thất bại đã xoá dấu vết lỗi, nên gọi lại lần nữa có thể trả về thành công dù dữ liệu chưa hề bền. Xử lý lỗi ở lần gọi đầu là bắt buộc, không phải tuỳ chọn.
❌ Nhầm 3 — rắc fsync sau mỗi lần ghi cho chắc.
✅ Mỗi fsync là một lần chờ thiết bị xác nhận, và nó chặn theo độ trễ chứ không theo băng thông — nên throughput có thể tụt cả bậc. Cách làm đúng là gom nhiều thay đổi rồi đồng bộ một lần, đúng như database làm với write-ahead log: ghi tuần tự vào log, fsync theo nhóm giao dịch, rồi mới thong thả cập nhật dữ liệu chính.
6. 📚 Đào sâu (tuỳ chọn)
- fsync(2) — man7.org — khác biệt chính xác giữa
fsyncvàfdatasync, và đoạn nói rõ rằng thao tác tạo hoặc đổi tên cầnfsynccả thư mục chứa. - Linux kernel — writeback cache control — cơ chế
REQ_PREFLUSHvàREQ_FUAở tầng block, và cách filesystem yêu cầu độ bền mà không cần biết thiết bị bên dưới.
7. Liên hệ các bài khác
- Page cache — vì sao lần đọc thứ hai nhanh gấp trăm lần — bài trước dựng khái niệm trang bẩn và bốn ngưỡng điều khiển write-back; bài này là cách bạn giành lại quyền quyết định thời điểm.
- HDD, SSD, NVMe — vì sao cùng một lệnh
fsynclại đắt rẻ khác nhau hàng bậc tuỳ thiết bị, và tụ chống mất điện đổi cục diện thế nào. - Lưu trữ mạng — block, file hay object? — khi đĩa nằm sau sợi cáp mạng, khoản chờ xác nhận của
fsynccòn cộng thêm cả round-trip. - Mini-challenge — đo ổ đĩa của chính bạn — bạn sẽ tự đo chênh lệch giữa ghi có
fsyncvà không, trên chính máy mình.
8. Tóm tắt
write()thành công chỉ chứng minh kernel đã nhận dữ liệu vào page cache; mặc định trang bẩn được phép nằm lại tới 30 giây trước khi bị đẩy xuống đĩa.- Dữ liệu xuyên bốn tầng có thể mất: buffer thư viện, page cache, write-back, và bộ đệm volatile bên trong chính ổ đĩa.
fdatasyncrẻ hơnfsyncvì bỏ qua metadata không cần cho việc đọc lại — nhưng vẫn phải đồng bộ kích thước khi file dài ra.- Pattern ghi file tạm rồi
renamechỉ bền khifsynccả thư mục cha; thiếu bước đó, mất điện có thể xoá sạch cả hai file.
9. Tự kiểm tra
Q1Service của bạn ghi log bằng hàm in của ngôn ngữ bậc cao rồi thoát bằng cách bị kill -9. Log mất một phần. Nêu ĐẦY ĐỦ các tầng mà dữ liệu có thể đang kẹt lại, và tầng nào bị kill -9 làm mất còn tầng nào thì không.▸
Có bốn tầng: buffer của thư viện trong tiến trình, page cache của kernel, hàng chờ write-back, và bộ đệm volatile của ổ đĩa. kill -9 giết tiến trình nên chỉ tầng đầu tiên bị mất — buffer thư viện nằm trong không gian địa chỉ của chính tiến trình, chết theo tiến trình.
Ba tầng còn lại thuộc kernel và thiết bị nên sống sót qua việc tiến trình chết: kernel vẫn write-back bình thường. Nói cách khác, kill -9 làm lộ lỗi thiếu fflush, còn mất điện mới làm lộ lỗi thiếu fsync.
Q2Vì sao fdatasync rẻ hơn fsync, và trong tình huống nào chênh lệch đó biến mất?▸
fdatasync bỏ qua việc đồng bộ metadata không ảnh hưởng tới việc đọc lại đúng dữ liệu, điển hình là thời gian sửa đổi. Metadata thường nằm ở vùng khác trên đĩa với nội dung file, nên bỏ được nó là bớt một lần nhảy chỗ — trên ổ quay thì đó đúng là khoản seek cộng chờ quay.
Chênh lệch biến mất khi file dài ra: lúc đó kích thước mới là metadata bắt buộc phải bền, vì thiếu nó thì đọc lại sẽ không thấy phần vừa ghi. Đây là lý do các hệ ghi log cường độ cao hay cấp phát trước dung lượng file — giữ kích thước không đổi để fdatasync thật sự rẻ.
Q3Giải thích vì sao 'fsync file rồi rename' vẫn có thể mất cả hai file khi mất điện, và cách sửa.▸
rename không sửa nội dung file mà sửa thư mục — nó gỡ một directory entry và đặt một entry khác. Thư mục cũng là dữ liệu trên đĩa, nên thay đổi đó ban đầu chỉ nằm trong page cache dưới dạng trang bẩn. Bạn đã fsync nội dung file tạm, nhưng chưa hề bảo kernel đẩy thay đổi của thư mục xuống.
Mất điện đúng khoảng đó thì trên đĩa, thao tác đổi tên có thể chưa xảy ra trong khi entry cũ đã bị gỡ — kết quả là không còn file nào. Cách sửa là sau rename, mở thư mục cha bằng O_RDONLY rồi fsync chính file descriptor đó. Man page của fsync nêu tường minh yêu cầu này.
Q4Đồng nghiệp đề xuất gọi fsync sau mỗi bản ghi để 'chắc chắn không mất dữ liệu'. Phân tích cái giá, và nêu cách các database làm thay vì thế.▸
Mỗi fsync là một lần chờ thiết bị xác nhận, tức là chặn theo độ trễ chứ không theo băng thông. Ghi một nghìn bản ghi kèm một nghìn lần chờ thì tổng thời gian bằng một nghìn lần độ trễ một vòng, bất kể mỗi bản ghi nhỏ đến đâu. Thông lượng vì thế có thể tụt cả bậc.
Database giải bài này bằng write-ahead log: ghi tuần tự vào một file log, rồi fsync một lần cho cả nhóm giao dịch đang chờ, sau đó mới thong thả cập nhật dữ liệu chính. Nhiều giao dịch chia nhau chung một lần chờ, nên độ bền vẫn giữ mà cái giá được chia nhỏ.
Q5Cùng một dòng lệnh fsync, vì sao trên ổ doanh nghiệp lại rẻ hơn ổ phổ thông hàng bậc?▸
Vì fsync không chỉ đẩy dữ liệu từ page cache xuống thiết bị — nó còn buộc thiết bị xả bộ đệm volatile của chính nó xuống vật liệu bền, qua các yêu cầu REQ_PREFLUSH và REQ_FUA ở tầng block. Trên ổ phổ thông, khoản chờ đó là chờ ghi thật xuống vật liệu.
Ổ doanh nghiệp có tụ chống mất điện, đủ sức tự xả bộ đệm khi điện tắt, nên bộ đệm của nó không còn volatile theo nghĩa cũ và nó xác nhận bền ngay. Cùng một lời gọi, nhưng chỗ chốt lời hứa "đã bền" nằm ở hai tầng khác nhau.
Bài tiếp theo: Lưu trữ mạng — block, file hay object?
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên