Phần 13 cho thấy write chỉ là lời hứa. fsync là chỗ trả tiền cho lời hứa đó. Bài này đo giá.
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ý.
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.
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à việc bị bỏ chính là thứ làm nên đảm bảo bền vững. Sau fsync thành công với nobarrier, dữ liệu vẫn có thể chỉ nằm trong RAM của ổ đĩa.
Nói lại lần thứ hai vì hay bị hiểu nhầm: nobarrier không phải một tối ưu. Nó là một sự đánh đổi, và bên bị mất 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
Đây là đòn bẩy lớn nhất, và nó nằm trong tay ứng dụng chứ không phải hệ điều hành. Cùng ext4, đổ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õ ràng khi nhìn vào bảng đầu tiên: mỗi fsync tốn khoảng 1,4 ms bất kể có bao nhiêu dữ liệu chờ. Dồn 512 lần ghi vào một fsync là chia cái giá cố định đó cho 512.
Đây chính 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õ: 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í.
Chỗ tôi không kết luận được: fsync so với fdatasync
Lý thuyết nói fdatasync rẻ hơn vì bỏ qua phần cập nhật siêu dữ liệu không cần cho việc đọc lại. Tôi đo năm lần mỗi bên:
fsync 1.621 718 864 1.852 1.149 IOPS
fdatasync 1.140 748 768 746 792 IOPS
Chênh lệch giữa các lần chạy của cùng một lệnh lên tới 2,6 lần (718 so với 1.852). Khoảng đó lớn hơn mọi khác biệt giữa hai lệnh. Với số liệu này, tôi không kết luận được cái nào nhanh hơn.
Nhiễu đến từ việc thiết bị là tệp loop nằm trên overlay nằm trong máy ảo — ba tầng, mỗi tầng có bộ đệm và lịch trình riêng. Muốn đo đúng phải chạy thẳng trên phần cứng thật.
Tôi ghi lại chuyện này thay vì chọn cặp số có lợi, vì cách chọn số ấy là cách người ta tạo ra những "kết quả benchmark" không lặp lại được ở đâu.
Đo trên máy chủ của bạn
# do dung mot thu: do tre cua fsync
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'
# hoac khong can fio
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 câu hỏi "nên đặt wal_sync_method là gì".
Con số cần nhớ để đọ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.
Thử ba mươi giâ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
Hai dòng ra là hai thế giới. 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 dòng thứ hai với đú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 nó 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.