Khi bạn write() vào một tệp, lời gọi trả về gần như tức thì — nhưng dữ liệu chưa nằm an toàn trên đĩa. Nó mới ở trong page cache, và nếu mất điện ngay lúc đó, nó bay mất. Muốn dữ liệu thật sự bền vững (durable), bạn phải gọi fsync() để ép nó xuống đĩa. Bài này đo cái giá của lời gọi đó — và nó là một trong những con số quan trọng nhất mà mọi cơ sở dữ liệu, mọi hệ thống ghi log tin cậy phải trả. Trên đường đo, tôi lại vấp đúng cái ảo giác mà bài page cache vừa cảnh báo, lần này ở phía ghi.

fsync tốn gì

write nhanh, nhưng chưa bền

Đường đi của một lần ghi: write() chép dữ liệu của bạn vào page cache trong RAM rồi trả về ngay. Nhân sẽ tự đẩy các trang bẩn (dirty) đó xuống đĩa sau, theo lịch của nó (writeback). Nghĩa là khi write() trả về, dữ liệu đã "ghi" theo nghĩa chương trình thấy, nhưng chưa bền — nó còn nằm trong bộ nhớ dễ mất.

fsync(fd) phá vỡ sự trì hoãn đó: nó chặn cho tới khi mọi dữ liệu bẩn của tệp siêu dữ liệu (metadata: kích thước, mốc thời gian) đã thật sự nằm trên thiết bị lưu trữ bền. Có một biến thể nhẹ hơn, fdatasync(fd), chỉ ép dữ liệu xuống, bỏ qua đồng bộ siêu dữ liệu không thiết yếu — nên nhanh hơn một chút. Cả hai đều biến một thao tác nhanh-nhưng-mong-manh thành một thao tác chậm-nhưng-chắc chắn.

Đo: fsync chậm hơn 388 lần

Tôi ghi 2000 bản ghi 4 KB vào một tệp, theo bốn cách đảm bảo độ bền khác nhau, đo độ trễ mỗi lần ghi:

Cách µs/ghi ghi/giây Bền vững?
none (không fsync) 1,0 ~984.000 KHÔNG (chỉ ở page cache)
batch (một fsync ở cuối) 2,9 ~342.000 CÓ (sau khi fsync xong)
fdatasync (mỗi ghi) 367,9 ~2.718 CÓ (bỏ qua metadata)
fsync (mỗi ghi) 387,9 ~2.578 CÓ (dữ liệu + metadata)

Con số đập vào mắt: ghi không fsync đạt gần một triệu lần mỗi giây (1 µs/ghi), còn ghi fsync mỗi lần chỉ ~2.578 lần mỗi giây (388 µs/ghi) — chậm hơn ~388 lần. Đó chính là cái giá của độ bền: mỗi lần fsync phải chờ đĩa xác nhận, và độ trễ đĩa (~388 µs trên SSD ở đây; sẽ là hàng mili giây trên đĩa cứng quay) áp đảo hoàn toàn tốc độ RAM. fdatasync nhỉnh hơn fsync một chút (368 so với 388 µs) vì bỏ qua việc đồng bộ mốc thời gian.

Dòng batch là lối thoát: nếu tôi ghi cả 2000 bản ghi rồi chỉ gọi fsync một lần ở cuối, chi phí đồng bộ được chia đều — 2,9 µs mỗi ghi, gần bằng tốc độ cache, mà vẫn đảm bảo cả 2000 bản ghi đã bền sau lời fsync đó.

Một lần tôi đo hớ: write() đo cache, không đo đĩa

Khi thấy con số ghi-không-fsync ~984.000 lần/giây, phản xạ đầu tiên của tôi là reo lên: "ghi xuống đĩa nhanh khủng khiếp!". Rồi tôi kịp nhận ra đây đúng y cái bẫy page cache của bài trước, chỉ đổi từ đọc sang ghi.

Con số 1 µs/ghi đó không đo tốc độ đĩa — nó đo tốc độ page cache. write() chỉ chép dữ liệu vào RAM rồi về; dữ liệu chưa hề chạm đĩa, và nếu mất điện lúc đó thì mất sạch. Gọi nó là "tốc độ ghi" là sai — nó là "tốc độ chép vào bộ nhớ", một thao tác chưa bền. Chỉ khi tôi thêm fsync — ép dữ liệu thật sự xuống đĩa — thông lượng mới sụp 388 lần, và đó mới là tốc độ ghi bền vững thật sự.

Bài học đo lường: "nhanh" là vô nghĩa nếu chưa hỏi "bền chưa". Rất nhiều benchmark khoe "hệ thống ghi X triệu bản ghi mỗi giây" thực ra đang đo tốc độ đưa vào page cache, không phải tốc độ ghi bền — hai con số cách nhau hàng trăm lần. Đây cùng một họ ảo giác với bài page cache (đọc từ cache tưởng là đọc từ đĩa) và bài mmap (vòng đo là nút thắt): một tầng đệm vô hình khiến thao tác có vẻ nhanh, và chỉ khi buộc dữ liệu đi hết tới đích thật (đĩa) bạn mới thấy chi phí thật. write() đo cache; fsync đo cái giá của độ bền.

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

Hệ quả đầu tiên là hiểu rằng độ bền là một thao tác riêng, phải gọi tường minh. Nếu ứng dụng của bạn phải không mất dữ liệu khi cúp điện (một cơ sở dữ liệu, một hàng đợi tin cậy, một sổ cái giao dịch), thì write() là chưa đủ — bạn phải fsync (hoặc mở tệp với O_SYNC). Và bạn phải trả cái giá ~388 µs đó một cách có ý thức. Rất nhiều lỗi mất dữ liệu "không thể tin được" đến từ việc quên rằng write() thành công không có nghĩa là dữ liệu đã bền — nó chỉ nghĩa là nhân đã nhận dữ liệu, chứ chưa cất kỹ.

Hệ quả thứ hai là gộp (batch) là cách hòa giải tốc độ với độ bền. Vì cái đắt là số lần fsync, không phải lượng dữ liệu, nên gom nhiều thao tác rồi fsync một lần cho cả nhóm là kỹ thuật nền tảng. Đây chính xác là cách các cơ sở dữ liệu dùng nhật ký ghi trước (WAL): ghi tuần tự các thay đổi vào một tệp log rồi fsync một lần cho cả lô giao dịch, thay vì fsync sau mỗi bản ghi. Con số batch 2,9 µs/ghi so với fsync 388 µs/ghi cho thấy vì sao — gộp 100 ghi vào một fsync cắt chi phí đồng bộ đi gần 100 lần, mà vẫn bền.

Hệ quả thứ ba là một cảnh báo về môi trường: fsync chỉ bền nếu tầng lưu trữ trung thực. Một số đĩa có bộ nhớ đệm ghi riêng và "nói dối" — trả lời fsync xong trước khi dữ liệu thật sự nằm trên vật liệu bền; một số hệ tệp mạng hay lớp lưu trữ ảo hóa cũng vậy. Con số mang theo: write() không fsync đạt ~984.000 ghi/giây nhưng CHỈ nằm ở page cache (chưa bền); fsync ép xuống đĩa nên chậm hơn ~388 lần (~2.578 ghi/giây, 388 µs mỗi lần) — đó là cái giá thật của độ bền; và gộp một fsync cho nhiều ghi (2,9 µs/ghi) lấy lại gần hết tốc độ mà vẫn bền. Khi ai đó khoe tốc độ ghi, câu hỏi đầu tiên phải là: có fsync không?

Thử ba mươi giây

Nếu có một cơ sở dữ liệu hay ứng dụng ghi tệp, dùng strace -f -e fsync,fdatasync -c your-program để đếm nó gọi fsync bao nhiêu lần — nếu nó fsync sau mỗi bản ghi nhỏ, đó là điểm nghẽn tiềm tàng, và nhóm lại (batch) có thể tăng tốc hàng chục lần. Muốn thấy cái giá tận mắt, chạy dd if=/dev/zero of=/tmp/f bs=4k count=1000 (không fsync, nhanh) rồi so với dd if=/dev/zero of=/tmp/f bs=4k count=1000 oflag=dsync (fsync mỗi khối, chậm hơn hẳn) — hiệu số đó chính là thuế độ bền mà bài này đo. write() rẻ vì nó chỉ hứa; fsync đắt vì nó giữ lời.