Hãy nhớ cái kim của một chiếc máy hát đĩa than: muốn nghe một bài ở giữa đĩa, bạn phải nhấc kim lên, dịch sang đúng rãnh, đặt xuống — và mỗi lần nhảy bài là một lần di chuyển vật lý mất thời gian. Nghe hết bài này sang bài kế bên cạnh thì kim tự trôi tới, gần như miễn phí. Đó chính xác là thế giới mà quy tắc "đọc tuần tự nhanh hơn đọc ngẫu nhiên" ra đời. Nhưng ổ SSD không có cây kim nào cả — nó giống một bảng đèn LED, bấm ô nào cũng sáng tức thì, không gì phải nhấc lên đặt xuống. Bài này đo lại quy tắc cũ trên cái bảng đèn ấy.
Kết quả: không có khác biệt
fio, khối 4 KB, O_DIRECT để bỏ qua mọi bộ đệm, ba lần đo mỗi bên:
| Lần 1 | Lần 2 | Lần 3 | |
|---|---|---|---|
| Tuần tự | 27.767 | 26.521 | 27.973 IOPS |
| Ngẫu nhiên | 28.325 | 27.921 | 28.034 IOPS |
Ngẫu nhiên thậm chí nhỉnh hơn một chút — nằm trong sai số. Với khối lớn hơn cũng vậy:
| Khối | Tuần tự | Ngẫu nhiên |
|---|---|---|
| 4 KB | 108,3 MB/s | 109,6 MB/s |
| 16 KB | 437,6 MB/s | 423,8 MB/s |
| 64 KB | 1.557,5 MB/s | 1.552,6 MB/s |
| 1 MB | 8.955,4 MB/s | 8.001,9 MB/s |
Vì sao quy tắc cũ không còn đúng
Quy tắc "tuần tự nhanh hơn" là quy tắc của đĩa quay. Ở đó, đọc một khối ở vị trí ngẫu nhiên đòi hỏi đầu đọc di chuyển sang rãnh khác (5–10 ms) rồi chờ đĩa quay tới đúng chỗ (2–4 ms) — đúng cây kim nhấc lên đặt xuống. Đọc khối kế tiếp thì không cần gì cả. Tỷ lệ chênh lệch lên tới hàng trăm lần, và mọi thiết kế cơ sở dữ liệu của ba thập kỷ trước đều xoay quanh việc tránh cái giá đó.
Ổ thể rắn không có bộ phận nào chuyển động. Đọc ô nhớ nào cũng như nhau. Quy tắc mất nền tảng vật lý. Điều này vẫn còn ảnh hưởng tới nhiều quyết định kiến trúc, nên nói thẳng: nếu hệ thống của bạn chạy trên SSD hoặc NVMe, tối ưu để đọc tuần tự thay vì ngẫu nhiên gần như không mang lại gì. Công sức đó nên dồn vào chỗ khác.
Chỗ khác đó là độ sâu hàng đợi
Cùng khối 4 KB, cùng đọc ngẫu nhiên, chỉ đổi số yêu cầu gửi đi cùng lúc:
iodepth |
IOPS | Thông lượng | Độ trễ trung bình |
|---|---|---|---|
| 1 | 27.128 | 106,0 MB/s | 36,3 µs |
| 4 | 67.846 | 265,0 MB/s | 58,6 µs |
| 16 | 174.239 | 680,6 MB/s | 91,6 µs |
| 64 | 233.687 | 912,8 MB/s | 273,2 µs |
| 128 | 233.973 | 914,0 MB/s | 546,3 µs |
8,6 lần thông lượng. Lý do: NVMe có nhiều kênh song song bên trong và chỉ đạt công suất khi có nhiều yêu cầu đang bay cùng lúc — cả bảng đèn có hàng nghìn ô, không phải một cây kim. Một chương trình đọc từng khối một, chờ xong rồi mới đọc tiếp, dùng được chưa tới 12% năng lực ổ đĩa.
Đây là cùng một hiện tượng mà phần 9 đã đo ở tầng bộ nhớ đệm CPU: truy cập phụ thuộc nhau thì chậm, truy cập độc lập thì song song hoá được. Ở tầng RAM, tỷ lệ là 28 lần; ở tầng ổ đĩa, 8,6 lần.
Bảng trên cũng cho thấy điểm dừng
Từ iodepth 64 lên 128, thông lượng đứng yên (233.687 → 233.973 IOPS) trong khi độ trễ tăng gấp đôi (273,2 → 546,3 µs). Đó là chữ ký kinh điển của bão hoà: hàng đợi đã đầy, yêu cầu thêm chỉ xếp hàng chờ chứ không được phục vụ sớm hơn.
Hệ quả thực dụng: tăng số kết nối hoặc số luồng khi ổ đĩa đã bão hoà chỉ làm mọi yêu cầu chậm đi, không làm hệ thống nhanh hơn. Đây chính là lý do các bể kết nối cơ sở dữ liệu có kích thước tối ưu, và nó thường nhỏ hơn nhiều so với trực giác. Điểm bão hoà tìm bằng cách quét đúng như bảng trên: tăng độ sâu tới khi thông lượng ngừng tăng, rồi lùi lại một bậc.
Ba cách để có nhiều yêu cầu đang bay
Nhiều luồng hoặc nhiều tiến trình. Cách đơn giản nhất, và là cách máy chủ web vốn đã làm.
I/O bất đồng bộ. io_uring (nhân 5.1 trở lên) hoặc libaio. Một luồng phát hàng trăm yêu cầu rồi thu kết quả về sau.
Đọc trước. Với truy cập tuần tự, nhân tự làm việc này. Bảo nó làm mạnh hơn:
posix_fadvise(fd, 0, 0, POSIX_FADV_SEQUENTIAL); /* tang cua so doc truoc */
readahead(fd, offset, len); /* keo truoc mot doan cu the */
blockdev --getra /dev/nvme0n1 # cua so doc truoc, don vi 512 byte
blockdev --setra 512 /dev/nvme0n1 # 256 KB
Đây là chỗ duy nhất mà tuần tự thật sự còn lợi thế trên SSD: nhân nhận ra mẫu tuần tự và tự tạo ra độ sâu hàng đợi giúp bạn. Với truy cập ngẫu nhiên, bạn phải tự tạo.
Giới hạn của phép đo này
Tôi đo trong một container chạy trên máy ảo. O_DIRECT bỏ qua bộ đệm của nhân trong máy ảo, nhưng không bỏ qua được bộ đệm của hệ điều hành máy chủ bên dưới — đây là cùng vấn đề đã gặp ở phần 12 với drop_caches.
Nghĩa là con số 233.973 IOPS là giới hạn của lớp lưu trữ ảo, không phải của chip NVMe. Hình dạng đường cong — bão hoà ở đâu, tuần tự bằng ngẫu nhiên — vẫn đúng và vẫn là điều đáng nhớ. Con số tuyệt đối thì phải đo lại trên chính máy chủ của bạn.
Vì lý do tương tự, tôi không đo được đĩa mạng như đề bài của phần này mong đợi. Điều chắc chắn về đĩa mạng: độ trễ mỗi thao tác cao hơn hẳn (hàng trăm µs tới vài ms), nên tỷ lệ lợi ích của việc tăng độ sâu hàng đợi còn lớn hơn nhiều so với bảng trên.
Mẫu số chung: một quy tắc chỉ đúng chừng nào giả định vật lý bên dưới nó còn đúng
Bài học lớn nhất ở đây không phải con số, mà là một quy tắc "ai cũng biết" đã sống lâu hơn cái lý do khiến nó đúng — và người ta vẫn tối ưu theo nó vì quên hỏi giả định gốc còn không. Cùng cái bẫy ở mọi tầng.
Trong thế giới Java, đây đúng là những "tối ưu" từ thời JVM cũ vẫn được truyền tai: gộp chuỗi bằng StringBuffer (đồng bộ, chậm hơn StringBuilder từ Java 5), hay tránh tạo object nhỏ vì "GC đắt" — trong khi escape analysis và GC hiện đại đã đổi hẳn cuộc chơi. Quy tắc từng đúng khi vật lý (bộ thu gom, trình biên dịch) khác; giữ nó bây giờ là tối ưu cho một cỗ máy không còn tồn tại.
Trong Go, cùng chuyện với "dùng con trỏ để tránh copy": trên struct nhỏ, truyền theo giá trị thường nhanh hơn vì nó ở lại trên stack, còn con trỏ đẩy object lên heap và tạo việc cho GC — quy tắc "con trỏ rẻ hơn copy" mượn từ C, nơi không có GC và escape analysis.
Còn ở cơ sở dữ liệu, đây là hàng loạt lời khuyên schema từ thời đĩa quay: "chuẩn hoá tối đa để tránh đọc thừa", "gom dữ liệu hay dùng chung vào một chỗ để đọc tuần tự" — nhiều cái trong đó xoay quanh đúng cái giá seek mà bài này vừa chỉ ra là đã biến mất trên SSD; và column store lật ngược hẳn nhiều quy tắc row-store kinh điển vì mô hình truy cập vật lý khác.
Mẫu số chung: mọi quy tắc tối ưu đều đứng trên một giả định về vật lý của tầng bên dưới (đĩa có kim, GC đắt, con trỏ rẻ), và khi tầng đó đổi thì quy tắc trở thành mê tín — vẫn được lặp lại, vẫn tốn công tuân theo, mà không còn mua được gì. Nên trước khi tối ưu theo một quy tắc "ai cũng biết", câu hỏi đúng không phải "làm thế nào cho đúng quy tắc" mà là "giả định vật lý khiến quy tắc này đúng có còn thật trên phần cứng và runtime tôi đang chạy không, và đã đo lại chưa" — vì gần như luôn, đo lại một quy tắc cũ đáng giá hơn tối ưu mù theo nó.
Muốn tự đo điểm bão hoà của chính ổ đĩa bạn:
f=/duong/dan/thu.bin
fallocate -l 2G $f || dd if=/dev/zero of=$f bs=1M count=2048
for d in 1 4 16 64; do
fio --name=t --filename=$f --rw=randread --bs=4k --size=2G \
--runtime=10 --time_based --ioengine=libaio --direct=1 \
--iodepth=$d --output-format=terse 2>/dev/null | \
awk -F';' -v d=$d '{printf "iodepth=%-4s %8s IOPS tre tb %8s us\n", d, $8, $16/1000}'
done
rm -f $f
Độ sâu mà thông lượng ngừng tăng chính là số yêu cầu song song tối đa có ích. Cấu hình bể kết nối, số worker, hay innodb_io_capacity vượt quá con số đó chỉ làm độ trễ xấu đi.
Phần sau: bộ lập lịch I/O — đo bốn lựa chọn của nhân.