Tối ưu Linux và NVMe cho workload I/O cao trên VPS
Có một luật bất thành văn khi làm việc với storage trên VPS: mọi con số bạn nghe được về IOPS hay throughput đều vô nghĩa cho tới khi bạn tự đo trên đúng máy, đúng workload của mình. NVMe nhanh hơn SATA SSD nhiều lần, nhưng phần lớn "mẹo tối ưu" lan truyền trên mạng được viết cho ổ cơ hoặc SSD đời cũ, và áp lên một VPS chạy NVMe ảo hoá thì hoặc không có tác dụng, hoặc làm chậm đi. Tệ hơn, nhiều mẹo trong số đó được chép qua chép lại từ những bài viết cũ hàng chục năm, khi mà single-queue scheduler và ổ cơ 7200rpm còn là mặc định. Bài này đi theo trình tự thực dụng: đo bằng fio và iostat trước, chỉ chỉnh những gì đo được là có lợi, và thành thật về những trường hợp tuning không cứu được gì vì nút thắt nằm ở tầng hạ tầng dùng chung mà bạn không sở hữu. Nguyên tắc xuyên suốt: mỗi thay đổi phải kèm một phép đo trước và sau trên cùng điều kiện, nếu không bạn sẽ không biết mình vừa cải thiện hay vừa tự huyễn hoặc.
Đo trước đã: lập baseline bằng fio và iostat
Không có baseline thì không có cách nào biết một thay đổi là cải thiện hay ảo giác. Bắt đầu bằng fio với direct=1 để bỏ qua page cache và đo thẳng vào storage. Với NVMe, cấu hình phổ biến để đo IOPS là block size 4k, iodepth cao (32 tới 256) để lấp đầy hàng đợi sâu của thiết bị.
# Random read 4k, đo IOPS và latency - direct I/O bỏ qua cache
fio --name=randread --ioengine=libaio --direct=1 --rw=randread \
--bs=4k --iodepth=64 --numjobs=4 --runtime=60 --time_based \
--group_reporting --filename=/var/lib/fio-test.bin --size=4G
# Random write 4k
fio --name=randwrite --ioengine=libaio --direct=1 --rw=randwrite \
--bs=4k --iodepth=64 --numjobs=4 --runtime=60 --time_based \
--group_reporting --filename=/var/lib/fio-test.bin --size=4G
Nguyên tắc quan trọng nhất khi benchmark: đo đúng cái workload thật của bạn chạy. Đo với iodepth khổng lồ trong khi ứng dụng thực tế chạy queue depth bằng 1 là tự lừa mình, vì bạn sẽ thấy IOPS cực cao nhưng nó không phản ánh gì cảnh production. Chỉnh iodepth, numjobs, bs, rw khớp với hồ sơ I/O thật: database OLTP thường là random read/write 4k tới 16k, còn backup hay log ingest là ghi tuần tự block lớn. Hai hồ sơ này cho kết quả khác nhau hoàn toàn nên đừng đo một cái rồi kết luận cho cái kia.
Một chi tiết dễ bỏ sót: data set phải lớn hơn RAM. Nếu file test nhỏ hơn bộ nhớ, một phần lớn I/O sẽ được page cache hoặc cache tầng host hấp thụ và bạn đo trúng cache chứ không trúng NVMe. Dùng --size ít nhất gấp đôi RAM khả dụng, và luôn giữ direct=1. Khi đọc kết quả, đừng chỉ nhìn latency trung bình mà phải xem cả đuôi trễ p99 và p99.99, vì đó là nơi người dùng cảm nhận được độ giật.
Khi hệ thống đang chạy tải thật, dùng iostat để nhìn hành vi live. Cột đáng quan tâm nhất là %util, await (thời gian chờ trung bình mỗi request) và aqu-sz (độ sâu hàng đợi trung bình).
# Cập nhật mỗi 2 giây, kèm cột mở rộng
iostat -xz 2
# Chỉ theo dõi thiết bị NVMe
iostat -xz -p nvme0n1 2
Tham chiếu tuỳ chọn của fio ở tài liệu chính thức fio. Đây là công cụ đo, không phải công cụ đoán.
I/O scheduler cho NVMe: mặc định none thường là đúng
Đây là chỗ nhiều hướng dẫn cũ dẫn người đọc đi sai. Với NVMe, kernel Linux hiện đại đã chọn scheduler none làm mặc định, và trong đa số trường hợp đó là lựa chọn đúng. NVMe hỗ trợ hàng nghìn hàng đợi với độ sâu lớn, bản thân firmware ổ đã sắp xếp lại request; thêm một lớp lập lịch phần mềm chỉ tốn CPU mà không thêm được gì.
Kiểm tra và đặt scheduler:
# Xem scheduler hiện tại, giá trị trong [ngoặc] là đang dùng
cat /sys/block/nvme0n1/queue/scheduler
# Đặt tạm thời (mất khi reboot)
echo none > /sys/block/nvme0n1/queue/scheduler
# Cố định bằng udev rule
cat > /etc/udev/rules.d/60-nvme-scheduler.rules
Có ngoại lệ đáng biết, và đây là lý do phải đo chứ không phát biểu tuyệt đối. Nếu bạn tối ưu cho đuôi trễ p99/p999 (ví dụ hệ thống giao dịch nhạy latency), mq-deadline có thể thắng none nhờ ép deadline cứng, chặn được request lẻ bị trễ bất thường khi thiết bị bận. Còn với SATA SSD hay ổ cơ chạy tải hỗn hợp thì mq-deadline vẫn hợp lý, và bfq dùng khi cần chống một tiến trình bỏ đói các tiến trình khác, ví dụ máy desktop hay server chia sẻ nhiều user. Thực tế benchmark nhiều năm cho thấy chênh lệch throughput giữa các scheduler trên NVMe nhanh là rất nhỏ, nên phần lớn thời gian none thắng đơn giản vì nó tốn ít CPU nhất. Kết luận: mặc định cho NVMe cứ để none, rồi đo lại bằng fio với đúng workload nếu nghi ngờ có ngoại lệ. Xem thêm tài liệu blk-mq trên kernel.org.
Filesystem và mount option: noatime là món dễ ăn
Chọn filesystem thì ext4 và XFS đều là lựa chọn chín muồi; XFS thường nhỉnh hơn ở tải song song lớn và file lớn, ext4 an toàn và phổ dụng. Đừng đổi filesystem chỉ vì một bài blog, hãy đo.
Mount option có lợi rõ và gần như miễn phí là noatime. Mặc định mỗi lần đọc file, kernel ghi lại access time, tạo ra I/O ghi thừa cho một workload đọc nhiều. noatime tắt hẳn cập nhật atime (bao trùm cả nodiratime).
# /etc/fstab
/dev/nvme0n1p1 /var/lib/mysql ext4 defaults,noatime 0 2
# Áp dụng không cần reboot
mount -o remount,noatime /var/lib/mysql
Nếu ứng dụng cần biết atime nhưng không cần chính xác tuyệt đối, relatime là dung hoà và thực ra đã là mặc định trên phần lớn distro hiện đại. Với workload đọc file rất nhiều như mail server hay static file server, chênh lệch giữa mặc định và noatime có thể đo được rõ ràng, nên đây là một trong số ít chỉnh gần như luôn đáng làm.
Với NVMe hỗ trợ discard, cân nhắc chạy fstrim định kỳ qua timer thay vì mount discard đồng bộ, để tránh chi phí trim chèn vào đường I/O nóng. Trim đồng bộ khiến mỗi lần xoá file phát sinh lệnh trim ngay lập tức, gom lại chạy hàng tuần thường là đủ để giữ ổ khoẻ mà không ảnh hưởng latency lúc cao điểm.
# Bật timer fstrim hàng tuần thay cho discard đồng bộ
systemctl enable --now fstrim.timer
Tinh chỉnh vm: dirty ratio và readahead
Mặc định vm.dirty_ratio thường là 20 tới 40 phần trăm RAM, khá cao với máy nhiều RAM. Khi lượng dirty page chạm ngưỡng vm.dirty_ratio, mọi I/O bị khoá lại cho tới khi flush xong, tạo ra khựng (stall) chu kỳ. Với storage nhanh và workload ghi đều, giữ hai tham số này thấp cho ra latency mượt hơn.
# Kiểm tra giá trị hiện tại
sysctl vm.dirty_ratio vm.dirty_background_ratio
# Hồ sơ hợp lý cho database trên storage nhanh
# (theo tài liệu tuning phổ biến: dirty_ratio ~15, background ~3)
sysctl -w vm.dirty_ratio=15
sysctl -w vm.dirty_background_ratio=3
Trên máy RAM rất lớn, dùng ngưỡng theo byte thay vì phần trăm để kiểm soát chính xác hơn, ví dụ vm.dirty_bytes và vm.dirty_background_bytes. Lý do là phần trăm trên máy 128GB RAM cho ra ngưỡng dirty tính bằng chục GB, quá lớn để flush trơn tru khi chạm trần. Một cách tính thực dụng là lấy lượng dữ liệu ghi được trong khoảng 5 tới 15 giây theo throughput thật của storage rồi đặt ngưỡng quanh đó. Con số cụ thể phụ thuộc throughput ghi thật, nên tính từ chính baseline fio của bạn chứ đừng chép nguyên từ bài viết nào. Đọc ý nghĩa từng tham số ở tài liệu vm sysctl của kernel.
Trên nhiều distro, cách gọn nhất để quản lý các profile này là tuned. Profile throughput-performance đã gộm sẵn các chỉnh hợp lý cho server.
tuned-adm active
tuned-adm profile throughput-performance
Readahead (blockdev --getra) đáng chỉnh cho quét tuần tự lớn, nhưng với database truy cập ngẫu nhiên thì readahead cao chỉ phí băng thông. Lại là chuyện đo trước.
Tip cho database
Với PostgreSQL và MySQL/InnoDB, phần lớn hiệu năng I/O được quyết định trong chính cấu hình engine, không phải ở kernel. Trước khi đụng tới scheduler hay dirty ratio, hãy chắc rằng buffer pool hoặc shared_buffers đã đủ lớn để giữ working set trong RAM, vì cách rẻ nhất để giảm I/O là không đọc đĩa ngay từ đầu. Chỉ khi lượng I/O thực sự chạm storage mới tới lượt các chỉnh dưới đây có ý nghĩa.
- InnoDB flush method: đặt
innodb_flush_method=O_DIRECTđể tránh double buffering giữa buffer pool và page cache của OS. - fsync: giữ độ bền dữ liệu đúng mức. Đừng tắt
innodb_flush_log_at_trx_commit=1hayfsync=oncủa Postgres chỉ để đẹp benchmark, trừ khi bạn thực sự chấp nhận mất giao dịch khi mất điện. - Tách WAL/redo log: nếu có nhiều volume, đặt log ghi tuần tự tách khỏi data file ngẫu nhiên.
- Đo bằng công cụ của database (pgbench, sysbench) song song với fio, vì fio đo storage thô còn database đo cả đường đi thật.
# Ví dụ đo Postgres bằng pgbench sau khi chỉnh
pgbench -i -s 100 mydb
pgbench -c 32 -j 4 -T 60 mydb
Khi tuning không giúp được gì
Đây là phần trung thực mà ít bài viết nói. Trên VPS dùng storage ảo hoá và chia sẻ, rất nhiều knob mất tác dụng vì bạn không sở hữu tầng dưới:
- Nếu storage backend đã ảo hoá, host có thể ép scheduler riêng của nó; chỉnh
nonetrong guest không đổi được cách host lập lịch. - IOPS và throughput thường bị giới hạn theo cấp gói (rate limit ở hypervisor). Chạm trần thì mọi mount option đều vô nghĩa.
- Hiệu ứng "hàng xóm ồn ào": VM khác trên cùng node ghi mạnh làm await của bạn nhảy vọt, và không tham số guest nào sửa được.
- Cache ảo ở tầng host có thể khiến fio đo ra số đẹp giả tạo; luôn dùng
direct=1và data set lớn hơn RAM để giảm nhiễu.
Cách phân biệt khá đơn giản với iostat. Nếu await cao trong khi aqu-sz thấp và %util chưa đầy, nghĩa là request phải chờ dù hàng đợi guest không đầy, nút thắt nhiều khả năng ở tầng dưới chứ không phải ở scheduler hay dirty ratio. Ngược lại, nếu aqu-sz cao và %util gần 100 phần trăm liên tục, bạn đang thực sự bão hoà thiết bị và lúc đó tuning trong guest mới có cửa giúp. Một phép thử nhanh khác là chạy lại fio vào các khung giờ khác nhau: nếu kết quả dao động lớn theo thời điểm mà cấu hình không đổi, gần như chắc chắn bạn đang chịu ảnh hưởng của tải chung trên node.
Khi đã xác định trần nằm ngoài guest, việc đúng là nâng gói, đổi node, hoặc chọn nền tảng cho headroom thật, chứ không phải xoay thêm sysctl vô ích. Một VPS nền NVMe với hạn mức I/O rộng cho bạn đủ headroom để những chỉnh ở trên thực sự có tác dụng thay vì chạm trần ngay từ đầu.
Tóm lược
Trình tự đúng là: lập baseline bằng fio (direct=1, block size và iodepth khớp workload thật) và quan sát live bằng iostat; để scheduler none cho NVMe trừ khi đo được ngoại lệ; bật noatime và dùng fstrim.timer; hạ vm.dirty_ratio tính từ throughput thật của bạn; tối ưu database ở tầng engine với O_DIRECT và fsync đúng mức. Và quan trọng nhất: chấp nhận rằng trên storage ảo hoá dùng chung, nhiều tham số sẽ không đổi được gì vì giới hạn nằm ngoài guest. Đo trước, đừng đoán, và để dữ liệu quyết định thay đổi nào đáng giữ.