Khi write trả về "xong", nó không có nghĩa dữ liệu đã nằm trên đĩa — nó chỉ có nghĩa nhân đã nhận dữ liệu và hứa sẽ ghi xuống lúc rảnh. Giống như bạn đưa lá thư cho người đưa thư: họ gật đầu nhận, nhưng thư chưa tới tay ai cả. fsync là lúc bạn đứng đó nhìn cho tới khi thư thật sự được bỏ vào hòm nhà người nhận — và cái sự chờ đó tốn thời gian, tốn rất nhiều tuỳ vào hòm thư ở đâu. Phần trước cho thấy write chỉ là lời hứa; bài này tôi đo cái giá của việc bắt lời hứa đó thành sự thật.

(Các con số dưới đây là IOPS và độ trễ đo bằng fio trên VM, không phải perf counter phần cứng.)

Bốn đích đến, cùng một máy

fio, ghi 4 KB rồi fsync, lặp 3.000 lần, một luồng:

Đích đến IOPS fsync trung bình fsync p99
tmpfs (nằm trong RAM) 750.000 0,2 µs 0,2 µs
ext4 tắt rào chắn 8.523 114,5 µs 216,1 µs
overlay (lớp ghi container) 1.653 600,8 µs 2.113,5 µs
ext4 đủ rào chắn 686 1.453,9 µs 3.883,0 µs

Từ 750.000 xuống 686 IOPS — hơn một nghìn lần, cùng một lời gọi hệ thống, cùng một ổ đĩa vật lý. Chỉ khác nhau ở chỗ dữ liệu thật sự phải đi tới đâu trước khi fsync chịu trả về.

Trên tmpfs, fsync là lệnh rỗng: không có đĩa nào để đồng bộ tới. Đó là lý do mọi phép đo hiệu năng ghi chạy trong /dev/shm hay tmpfs đều vô nghĩa nếu bạn quan tâm tới độ bền — bạn đang đo tốc độ của một lời hứa không ai định giữ.

nobarrier nhanh hơn 12 lần vì nó bỏ hẳn một bước

Khác biệt giữa 686 và 8.523 IOPS chỉ nằm ở một tuỳ chọn gắn hệ thống tệp:

mount -o loop            /loop-ext4.img /mnt/ext4      #   686 IOPS
mount -o loop,nobarrier  /loop-nb.img   /mnt/ext4nb    # 8.523 IOPS

Rào chắn là lệnh bảo ổ đĩa xả bộ đệm của chính nó xuống mặt lưu trữ. Ổ đĩa hiện đại có RAM bên trong và trả lời "xong" ngay khi dữ liệu vào RAM đó — nhanh, nhưng mất điện là mất.

nobarrier bỏ lệnh xả. Nó nhanh hơn vì làm ít việc hơn, và cái việc bị bỏ chính là thứ làm nên đảm bảo bền vững. Sau một fsync thành công với nobarrier, dữ liệu vẫn có thể chỉ đang nằm trong RAM của ổ đĩa. Tôi nói lại lần thứ hai vì đây là chỗ hay bị hiểu nhầm nhất: nobarrier không phải một tối ưu. Nó là một sự đánh đổi, và bên bị mất chính là thứ duy nhất fsync tồn tại để bảo đảm.

Nó chỉ hợp lý ở đúng một chỗ: khi ổ đĩa có pin dự phòng hoặc siêu tụ, nghĩa là RAM bên trong ổ vẫn được ghi xuống sau khi mất điện. Bộ điều khiển RAID doanh nghiệp thường có; ổ tiêu dùng thì không.

Dồn lại rồi hãy fsync — đòn bẩy nằm trong tay bạn

Đây là chỗ tôi thấy đáng giá nhất, vì nó không phụ thuộc hệ điều hành mà nằm trong tay ứng dụng. Cùng ext4, chỉ đổi số lần ghi giữa hai lần fsync:

fsync mỗi IOPS Thông lượng
1 lần ghi 1.401 5,5 MB/s
8 lần ghi 2.035 8,0 MB/s
64 lần ghi 20.270 79,2 MB/s
512 lần ghi 111.111 434,0 MB/s

79 lần giữa hai đầu bảng. Lý do rõ ngay khi nhìn lại bảng đầu: mỗi fsync tốn khoảng 1,4 ms bất kể có bao nhiêu dữ liệu đang chờ. Dồn 512 lần ghi vào một fsync chính là chia cái giá cố định đó cho 512.

Đây đúng là cơ chế commit theo nhóm mà mọi cơ sở dữ liệu đều làm. PostgreSQL có commit_delay và commit_siblings; MySQL có innodb_flush_log_at_trx_commit; Kafka có flush.messages. Và cái giá phải trả cũng rõ ràng: một giao dịch đến sớm phải chờ các giao dịch sau nó gom đủ nhóm. Thông lượng tăng, độ trễ của từng giao dịch tăng theo. Đó là đánh đổi có ý thức, không phải bữa trưa miễn phí.

Mẫu số chung

Cái trần "một fsync tốn 1,4 ms nên đừng gọi nó mỗi lần ghi" không phải chuyện của C hay Linux — nó đội mọi ngôn ngữ và mọi hệ lưu trữ, chỉ đổi tên hàm.

Trong thế giới Java, fsync chính là FileChannel.force(true) hay FileDescriptor.sync(), và cái bẫy y hệt: nếu bạn force() sau mỗi bản ghi vào một file log, bạn tự khoá mình ở ~686 thao tác/giây bất kể ổ nhanh cỡ nào. Đó là lý do các log ghi-trước như của Kafka hay Lucene gom nhiều bản ghi rồi mới force một lần — commit theo nhóm dựng lại bằng bytecode.

Trong Go, đó là file.Sync() gọi xuống đúng lời gọi hệ thống fsync. Cùng bài học: một vòng lặp f.Write(); f.Sync() mỗi bản ghi nghe thì "an toàn" nhưng thật ra biến ổ NVMe triệu-IOPS thành một ổ vài trăm-IOPS. Muốn cả bền lẫn nhanh thì buffer rồi Sync theo lô.

Còn ở cơ sở dữ liệu, đây là nơi câu chuyện được nghiên cứu kỹ nhất: wal_sync_method của PostgreSQL chọn cách gọi đồng bộ (fdatasync, open_datasync, fsync...), còn commit_delay/nhóm commit chọn tần suất; innodb_flush_log_at_trx_commit=1 là bền tuyệt đối (fsync mỗi commit) còn =2 là "đủ tốt" (ghi mỗi commit, fsync mỗi giây) — đúng cùng trục đánh đổi độ bền lấy thông lượng mà nobarrier bày ra ở tầng hệ điều hành.

Mẫu số chung: độ bền không miễn phí, và cái giá của nó là một khoảng chờ cố định trên mỗi lần đồng bộ chứ không phải trên mỗi byte — nên mọi tầng, từ mount option tới log của DB, đều xoay quanh đúng hai câu hỏi: có thật sự xả xuống mặt lưu trữ không, và bao lâu xả một lần. Vì thế câu hỏi đúng khi hệ ghi chậm không phải "ổ của tôi có nhanh không" mà là "tôi đang gọi fsync mấy lần mỗi giây, và mỗi lần đó có thật sự cần thiết không" — gộp lại được bao nhiêu lời gọi thì gần như luôn là đòn bẩy lớn hơn việc đổi phần cứng.

Muốn tự thấy hai thế giới ấy, đo fsync không cần cài gì:

cat > /tmp/fs.c <<'EOF'
#include <stdio.h>
#include <fcntl.h>
#include <unistd.h>
#include <time.h>
static double now(void){struct timespec t;clock_gettime(CLOCK_MONOTONIC,&t);
  return t.tv_sec+t.tv_nsec/1e9;}
int main(int c,char**v){
  int fd=open(v[1],O_WRONLY|O_CREAT|O_TRUNC,0600);
  if(fd<0){perror("open");return 1;}
  char b[4096]={0}; int N=300;
  double t0=now();
  for(int i=0;i<N;i++){ if(write(fd,b,4096)<0) return 2; if(fsync(fd)<0) return 3; }
  double d=now()-t0;
  printf("%s: %.0f fsync/giay, %.0f us moi lan\n",v[1],N/d,d/N*1e6);
  close(fd); return 0;
}
EOF
cc -O2 -o /tmp/fs /tmp/fs.c
/tmp/fs /dev/shm/thu       # tmpfs, khong co dia
/tmp/fs /tmp/thu           # dia that
rm -f /tmp/thu /dev/shm/thu

Nếu có sẵn fio hoặc PostgreSQL thì còn nhanh hơn:

fio --name=s --filename=/duong/dan/thu --rw=write --bs=4k \
    --size=64M --number_ios=3000 --ioengine=psync --fsync=1 \
    --output-format=normal | grep -E 'iops|fsync'
pg_test_fsync -f /duong/dan/thu       # kem theo PostgreSQL

pg_test_fsync in ra thời gian của từng phương pháp đồng bộ mà PostgreSQL biết dùng, và đó thường là câu trả lời trực tiếp cho "nên đặt wal_sync_method là gì". Con số để đọc kết quả: một fsync dưới 100 µs nghĩa là có bộ đệm được bảo vệ bằng pin, hoặc rào chắn đang tắt; vài trăm µs là NVMe bình thường; vài mili giây là SATA SSD hoặc lớp ảo hoá; trên 10 ms là đĩa quay. Nếu ứng dụng của bạn đang ghi log hoặc ghi trạng thái vào một thư mục mà bạn chưa từng đo, chạy đúng đường dẫn đó — con số ấy là trần thông lượng ghi bền vững của bạn, và không cách nào vượt qua ngoài việc dồn nhiều lần ghi vào một fsync.

Phần sau: lịch trình I/O — bốn bộ lập lịch của nhân và lúc nào đổi có ích.