phần trước ta thấy write() chưa đủ để dữ liệu bền — phải fsync mới ép nó lên đĩa. Nhưng fsync chậm: mỗi lần đo được ~557 µs. Điều đó dựng lên một thế lưỡng nan tưởng như không thoát: fsync mỗi ghi thì an toàn nhưng chậm, còn không fsync thì nhanh nhưng mất dữ liệu khi sự cố. Phải chọn một? Tôi đo trong container gcc:13 (ARM), và con số cho thấy có một lối ra — cùng lối ra mà mọi cơ sở dữ liệu đều dùng.

fsync: chi phí bền vững

Vì sao fsync đắt, và cái đắt nằm ở đâu

fsync(fd) buộc nhân flush mọi trang bẩn của file xuống thiết bị ngay, và chờ thiết bị xác nhận đã ghi xong mới trả về. Cái chờ đó là chi phí durability (độ bền) — và nó không né được, vì bản chất là phải đợi phần cứng thật: đĩa quay định vị đầu từ, hay SSD flush bộ đệm nội bộ và cập nhật bảng ánh xạ. Không có mẹo phần mềm nào làm đĩa xác nhận nhanh hơn khả năng vật lý của nó.

Điểm mấu chốt để tối ưu: chi phí này tính theo mỗi lần fsync, gần như không theo lượng dữ liệu. Một fsync sau khi ghi 1 bản ghi và một fsync sau khi ghi 256 bản ghi tốn xấp xỉ nhau — cả hai đều là "một lần chờ đĩa". Điều đó gợi ngay cách rẻ hóa: gom nhiều ghi rồi fsync một lần — cả lô cùng trở nên bền sau một lần chờ đĩa. Đây gọi là group commit.

Đo: gom 256 ghi, nhanh 125 lần

Tôi ghi 4.000 bản ghi 4 KB, thay đổi tần suất fsync — fsync sau mỗi bao nhiêu bản ghi — và đo throughput ghi bền (bản ghi/giây):

fsync mỗi   1 ghi :   1.551 ghi/giây   (645 µs mỗi fsync)
fsync mỗi   4 ghi :   7.509 ghi/giây
fsync mỗi  16 ghi :  23.869 ghi/giây
fsync mỗi  64 ghi :  54.583 ghi/giây
fsync mỗi 256 ghi : 194.211 ghi/giây   -> gom 256 = 125× so mỗi-ghi

fsync sau mỗi bản ghi cho throughput thảm hại 1.551 ghi/giây — vì mỗi ghi phải đợi một vòng chờ đĩa ~645 µs. Nhưng gom 256 bản ghi rồi fsync một lần cho 194.211 ghi/giây — nhanh hơn 125 lần — mà vẫn bền: sau lần fsync đó, cả 256 bản ghi đều chắc chắn trên đĩa. Chi phí fsync cố định được chia đều cho cả lô. Đây chính là batching của sê-ri đồng thời, áp cho độ bền.

Tôi cũng so fsync với fdatasync:

fsync     : 1.601 ghi/giây
fdatasync : 1.737 ghi/giây   (rẻ hơn ~8%)

fdatasync rẻ hơn ~8% vì nó chỉ đảm bảo dữ liệu bền, bỏ qua việc cập nhật metadata không thiết yếu (như thời gian sửa file). Một cải thiện nhỏ, nhưng miễn phí nếu bạn không cần metadata thời gian chính xác. (Thành thật: đĩa nền container này nhanh, nên 645 µs/fsync còn lạc quan — trên đĩa quay thật một fsync có thể ~10 ms, và khi đó lợi ích của group commit còn lớn hơn nhiều.)

Một lần tôi đo hớ: không phải chọn giữa bền và nhanh

Tôi vào đo với một thế lưỡng nan tưởng như hiển nhiên: "muốn an toàn thì fsync mỗi ghi và chịu chậm; muốn nhanh thì bỏ fsync và chịu rủi ro mất dữ liệu — phải đánh đổi". Phép đo phá vỡ chính khung tư duy đó. Nhìn dòng gom 256: 194.211 ghi/giây, vừa bền vừa nhanh gấp 125 lần fsync-mỗi-ghi. Không phải chọn giữa an toàn và tốc độ — mà chọn đơn vị để trả cái giá bền: trả cho mỗi ghi (chậm) hay cho mỗi lô (nhanh, mà cả lô vẫn bền).

Bài học đo lường: khi một chi phí cố định tính theo sự kiện, câu hỏi đúng không phải "trả hay không trả" mà "gom bao nhiêu việc vào mỗi lần trả". Cái đắt của fsync là một lần chờ đĩa, không phải lượng byte; nên gom nhiều ghi vào một fsync amortize nó xuống gần không cho mỗi ghi. Đây là vì sao mọi cơ sở dữ liệu nghiêm túc dùng group commit: gom các giao dịch đang chờ commit trong một cửa sổ nhỏ, ghi hết vào write-ahead log, rồi fsync một lần cho cả nhóm — mọi giao dịch trong nhóm cùng bền sau một vòng chờ đĩa. Nếu tôi tin thế lưỡng nan "bền xOR nhanh" mà không đo, tôi đã hoặc hy sinh độ bền để nhanh, hoặc chấp nhận chậm 125 lần một cách không cần thiết — trong khi group commit cho cả hai.

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

Hệ quả đầu tiên: cần độ bền cho nhiều ghi thì gom rồi fsync một lần, đừng fsync từng cái. Nếu bạn ghi một loạt bản ghi (log giao dịch, sự kiện, dòng dữ liệu) và mỗi cái cần bền, gom chúng trong một buffer, ghi hết, rồi fsync một lần — throughput tăng hàng trăm lần mà độ bền không đổi. Đây là đòn tăng tốc lớn nhất và rẻ nhất cho I/O bền.

Hệ quả thứ hai: fsync không bao giờ "miễn phí", kể cả trên SSD — đừng gọi bừa. Đo cho thấy mỗi fsync tốn hàng trăm µs tới hàng ms ngay trên lưu trữ nhanh, vì nó phải đợi phần cứng xác nhận. Đừng rải fsync sau mỗi thao tác nhỏ vì "cho chắc"; xác định ranh giới bền thật sự của bạn (mỗi giao dịch? mỗi giây? mỗi lô?) và fsync đúng ở đó. Với dữ liệu tái tạo được thì không cần fsync chút nào — để write-back của nhân lo.

Hệ quả thứ ba là tinh thần đo lường: một chi phí cố định theo sự kiện luôn có thể amortize bằng gom — đo để tìm đúng cỡ lô. Con số mang theo: fsync là chi phí độ bền (buộc write-back + CHỜ đĩa xác nhận), tính theo MỖI LẦN fsync (~645µs-1,3ms) chứ không theo lượng byte — nên fsync mỗi ghi cho throughput thảm (1.551 ghi/s) còn gom 256 ghi rồi fsync một lần cho 194.211 ghi/s (125×) mà VẪN bền (cả lô bền sau một lần chờ đĩa); fdatasync rẻ hơn ~8% vì bỏ metadata thời gian. Bền và nhanh không mâu thuẫn — group commit cho cả hai. Câu hỏi không phải "fsync hay không" mà "gom bao nhiêu mỗi fsync".

Thử ba mươi giây

Nếu code của bạn có chỗ ghi dữ liệu bền — một log, một hàng đợi bền, một file trạng thái cập nhật thường — tìm chỗ gọi fsync (hay fdatasync, f.flush()+os.fsync, FileChannel.force). Hỏi: nó fsync sau mỗi bản ghi, hay gom nhiều bản rồi fsync một lần? Nếu là mỗi bản, bạn đang trả một vòng chờ đĩa cho từng cái — và có thể tăng throughput hàng trăm lần bằng cách gom vài chục tới vài trăm bản ghi rồi fsync chung, mà không mất chút độ bền nào (cả lô vẫn bền sau lần fsync đó). Ba mươi giây tìm "fsync trong vòng lặp" đó thường lộ ra nút thắt I/O bền lớn nhất của một chương trình — và cách sửa chỉ là dời fsync ra ngoài vòng lặp, gom việc lại.