Hình dung một người soạn hàng trong kho. Nếu đơn hàng xếp theo đúng thứ tự các kệ, anh ta đi một mạch dọc lối, nhặt từng món — nhanh và đều. Nếu đơn hàng nhảy lung tung — kệ đầu này, rồi cuối kho, rồi quay lại — anh ta phải chạy tới chạy lui, và phần lớn thời gian là di chuyển giữa các kệ, không phải nhặt hàng. Cùng số món cần nhặt, nhưng thứ tự nhặt quyết định anh xong trong năm phút hay nửa tiếng. Đọc dữ liệu từ file y hệt: cùng số byte, nhưng đọc tuần tự hay ngẫu nhiên cho tốc độ rất khác nhau.

Đọc một megabyte từ file, dù bạn đọc tuần tự (từ đầu đến cuối) hay ngẫu nhiên (nhảy tới các vị trí rải rác), cũng vẫn là một megabyte — nên trực giác nói chúng tốn như nhau. Sai. Mẫu truy cập (access pattern) là một trong những yếu tố ảnh hưởng hiệu năng I/O mạnh nhất, và tuần tự gần như luôn nhanh hơn ngẫu nhiên — đôi khi rất nhiều. Tôi đo trong container gcc:13, và phải nói thẳng ngay: file của tôi nằm trong page cache nên tôi không đo được cái phần lớn nhất của khác biệt (seek đĩa vật lý). Nhưng ngay cả trong cache, gap vẫn hiện ra — và đó là một bài học thú vị về vì sao.

Vì sao tuần tự nhanh hơn

Có hai cơ chế làm tuần tự thắng, ngay cả khi không có đĩa vật lý:

Readahead (đọc trước). Nhân theo dõi mẫu truy cập file của bạn. Khi nó phát hiện bạn đang đọc tuần tự, nó chủ động đọc trước các trang kế tiếp vào page cache — trước cả khi bạn yêu cầu. Nên tới lúc bạn read trang tiếp theo, nó đã sẵn trong RAM, không phải chờ. Với truy cập ngẫu nhiên, nhân không đoán được bạn sẽ nhảy đi đâu, nên nó không đọc trước (đúng — đọc trước sai chỗ chỉ phí băng thông). Bạn mất hết lợi ích prefetch.

Locality cache/TLB. Đọc tuần tự chạm các trang liên tiếp trong bộ nhớ — hành vi cache CPU và TLB tốt trong đường sao chép dữ liệu. Đọc ngẫu nhiên nhảy khắp nơi, gây nhiều cache/TLB miss hơn. Còn trên đĩa vật lý (thứ tôi không đo được ở đây), khác biệt lớn hơn nhiều: tuần tự là streaming liền mạch, còn ngẫu nhiên bắt đầu đọc/định vị (seek) mỗi lần nhảy — đúng cái người soạn hàng chạy tới chạy lui.

Tôi đo: tạo file 512 MB, đọc 800 MB (200.000 khối 4 KB) theo hai kiểu, sau khi file đã vào page cache (để cô lập hiệu ứng readahead/locality, không lẫn với I/O đĩa lần đầu), dùng posix_fadvise gợi ý kiểu truy cập.

Đo: 2,15 lần, ngay cả trong cache

Đọc 800 MB (khối 4 KB) từ file 512 MB đang trong page cache:
  TUẦN TỰ  (posix_fadvise SEQUENTIAL): 0,049 s | 16.752 MB/s
  NGẪU NHIÊN (posix_fadvise RANDOM)  : 0,105 s |  7.798 MB/s
  -> tuần tự nhanh 2,15× (chỉ do readahead + cache locality; file trong cache, KHÔNG có seek đĩa)

Đọc tuần tự đạt 16.752 MB/s; đọc ngẫu nhiên chỉ 7.798 MB/s — chậm hơn 2,15 lần, dù đọc cùng một lượng byte từ cùng một file đã nằm sẵn trong RAM. Không có một byte nào phải chờ đĩa; toàn bộ khác biệt đến từ readahead (nhân prefetch trang kế cho đường tuần tự) và locality (đường tuần tự thân thiện cache/TLB hơn). Ngay cả khi dữ liệu đã ở trong bộ nhớ, mẫu bạn chạm tới nó vẫn quyết định tốc độ.

Phải nói thẳng giới hạn: 2,15 lần này chỉ là phần "cache/readahead" của câu chuyện. Trên một đĩa vật lý thật, gap còn khổng lồ hơn nhiều. Đĩa cứng cơ (HDD) phải di chuyển đầu đọc và chờ đĩa quay tới đúng chỗ cho mỗi lần seek — mất ~vài mili-giây mỗi lần, nên đọc ngẫu nhiên có thể chậm hơn tuần tự hàng trăm lần. SSD không có seek cơ học nên gap nhỏ hơn, nhưng vẫn vài lần (băng thông tuần tự vs số IOPS 4 KB ngẫu nhiên). Container của tôi (file trong cache, đĩa ảo) không cho tôi đo con số đĩa đó — tôi chỉ nêu để bạn biết cái tôi đo được (2,15×) là cận dưới của khác biệt thật.

Hai niềm tin đối nghịch, cùng sai

Tôi vào đo với hai niềm tin. Thứ nhất: "đọc cùng một lượng byte thì tuần tự hay ngẫu nhiên cũng tốn như nhau". Con số bác thẳng — cùng 800 MB, tuần tự nhanh 2,15 lần.

Thứ hai, tinh vi hơn: "một khi dữ liệu đã trong page cache thì hết khác biệt, vì không còn seek đĩa nữa". Cũng sai — đo cho thấy vẫn còn 2,15 lần trong cache, đến từ readahead và cache locality, hai thứ hoạt động độc lập với đĩa. Nói cách khác, mẫu truy cập ảnh hưởng hiệu năng I/O ngay cả khi không chạm đĩa; và trên đĩa vật lý, seek còn phóng đại khác biệt lên hàng trăm lần. Nếu tôi chỉ đo trong cache rồi kết luận "gap chỉ 2 lần, không đáng lo", tôi đã hiểu sai — vì trên phần cứng thật gap lớn hơn nhiều, và phép đo cache của tôi chỉ thấy phần nhỏ nhất của nó.

Về đến code hằng ngày

Điều đầu tiên: thiết kế để truy cập tuần tự khi có thể. Đây là lý do sâu xa vì sao cơ sở dữ liệu và hệ thống log thích ghi tuần tự (append-only): thêm vào cuối là streaming, nhanh; ghi rải rác giữa file là random, chậm (đặc biệt trên HDD). Log-structured storage, LSM-tree, WAL (write-ahead log) — tất cả xoay quanh việc biến ghi ngẫu nhiên thành ghi tuần tự. Khi đọc, sắp xếp yêu cầu theo offset (hoặc dùng index để gom) để chạm đĩa tuần tự nhất có thể.

Điều thứ hai: gợi ý cho nhân biết mẫu của bạn. posix_fadvise(POSIX_FADV_SEQUENTIAL) bảo nhân mở rộng cửa sổ readahead (prefetch mạnh hơn) khi bạn sắp quét tuần tự một file lớn; POSIX_FADV_RANDOM tắt readahead khi bạn sẽ nhảy lung tung (để nhân không phí băng thông đọc trước những trang bạn không dùng). Một gợi ý đúng có thể cải thiện đáng kể, nhất là với file lớn hơn cache.

Chỗ này lý giải rất nhiều quyết định thiết kế ở tầng dữ liệu lớn mà bạn gặp hằng ngày. Kafka nhanh khủng khiếp phần lớn vì nó chỉ ghi tuần tự vào log rồi để page cache lo phần đọc — biến mọi thứ thành streaming. Các database LSM-tree (RocksDB, Cassandra, LevelDB) gom ghi ngẫu nhiên vào memtable rồi flush tuần tự thành file đã sắp (SSTable), đúng để né random write trên đĩa. Ngay cả lời khuyên kinh điển "quét bảng theo clustered index nhanh hơn nhảy theo secondary index cho truy vấn dải lớn" cũng là bài này: một cái tuần tự, một cái random. Và trên SSD, tuy không có đầu đọc cơ học, các hệ thống vẫn ưu tiên ghi tuần tự lớn để giảm write amplification và giúp bộ dọn rác của SSD. Cùng một bài học người-soạn-hàng-đi-một-lối, phóng to lên tầng hệ thống lưu trữ.

Muốn tự thấy thì viết một chương trình đọc một file lớn (vài trăm MB) theo hai kiểu và bấm giờ: một vòng đọc tuần tự từng khối 4 KB từ đầu đến cuối, và một vòng pread 4 KB tại các offset ngẫu nhiên (cùng số lần đọc). Chạy trên máy bạn. Nếu file đã trong cache (chạy lần hai), bạn sẽ thấy tuần tự nhanh gấp đôi hay hơn — chỉ do readahead và locality. Rồi nếu bạn có cách xóa cache (echo 3 > /proc/sys/vm/drop_caches với quyền root) và file nằm trên đĩa thật, đo lại lần đầu (cold): trên HDD bạn sẽ thấy ngẫu nhiên chậm hàng chục tới hàng trăm lần — cú seek lộ nguyên hình. Vài phút đó cho bạn thấy một sự thật mà "cùng byte thì như nhau" che giấu: với lưu trữ, cách bạn chạm dữ liệu quan trọng ngang, có khi hơn, lượng dữ liệu bạn chạm.