Cùng một quy định "hành lý xách tay tối đa 7kg", mỗi sân bay soi một kiểu: nơi cân từng cái chặt chẽ và chặn ngay, nơi liếc qua cho đi, nơi thậm chí không có quầy xách tay. Nếu bạn chỉ bay qua sân bay dễ tính rồi đinh ninh "vali 10kg của mình ổn mà", đến sân bay soi chặt là kẹt cứng ở cửa. O_DIRECT có một quy định căn-lề y hệt vậy, và mỗi filesystem "soi" nó một mức khác nhau — nên code chạy ngon ở máy này có thể vỡ ở máy kia. Ở bài về page cache, ta thấy kernel giữ dữ liệu file trong RAM để đọc lại nhanh. Nhưng đôi khi cái cache đó là thừa — một cơ sở dữ liệu tự quản bộ đệm riêng thì không muốn kernel cache trùng, phí RAM. O_DIRECT nói kernel "bỏ qua page cache, đọc thẳng từ đĩa vào buffer của tôi". Tôi đo nó trong container — và phát hiện cái ràng buộc nổi tiếng của nó được thực thi mỗi nơi một khác.
O_DIRECT bỏ qua page cache
Đọc file thông thường (có đệm) đi qua page cache: kernel đọc dữ liệu từ đĩa vào một trang trong cache RAM, rồi copy từ trang đó sang buffer của bạn. Lần sau đọc cùng dữ liệu, nó lấy thẳng từ cache — nhanh, nhưng tốn một bản sao trong RAM và một lần copy.
Mở file với O_DIRECT thay đổi con đường: dữ liệu đi thẳng từ đĩa vào buffer người dùng của bạn, không dừng ở page cache. Không có bản cache, không có lần copy thừa. Đây là cái các cơ sở dữ liệu (PostgreSQL với một số cấu hình, Oracle, nhiều engine lưu trữ) hay dùng: chúng có bộ đệm thông minh riêng, biết rõ dữ liệu nào nên giữ, nên không muốn kernel đoán hộ và làm đầy RAM bằng một bản cache trùng lặp.
Cái giá là một ràng buộc khắt khe: vì O_DIRECT nói chuyện thẳng với phần cứng lưu trữ (vốn làm việc theo khối), mọi thứ phải căn lề theo kích thước khối — thường 512 hoặc 4096 byte. Cụ thể: địa chỉ buffer trong bộ nhớ, vị trí (offset) đọc trong file, và độ dài đọc đều phải là bội số của kích thước khối. Buffer thường cấp bằng malloc không đảm bảo căn lề; phải dùng posix_memalign. Sai căn lề không phải lỗi biên dịch — nó là một lỗi lúc chạy: lời gọi read trả về EINVAL.
Đo: cùng một buffer lệch, ba filesystem ba kết cục
Tôi mở một file với O_DIRECT rồi thử pread bằng hai buffer: một cái lệch (địa chỉ malloc cộng 1, không căn lề) và một cái căn lề 4096 (posix_memalign). Chạy trên ba nơi trong container:
| Filesystem | Mở O_DIRECT | Buffer không căn lề | Buffer căn lề |
|---|---|---|---|
overlayfs (/tmp) |
OK | EINVAL (errno 22) | OK |
bind mount FUSE (/m) |
OK | vẫn chạy (r=4096) | OK |
/dev/zero |
EINVAL | — | — |
Ba dòng, ba hành vi khác nhau cho cùng một đoạn mã. Trên overlayfs, mọi thứ đúng như sách: buffer căn lề đọc được, buffer lệch bị từ chối thẳng với EINVAL (mã lỗi 22, "Invalid argument"). Trên bind mount FUSE, cùng cái buffer lệch đó lại pread thành công trả về 4096 byte — FUSE đơn giản lờ đi ràng buộc căn lề của O_DIRECT. Và trên /dev/zero, ngay cả việc mở file với O_DIRECT cũng thất bại EINVAL, vì thiết bị ký tự này không hỗ trợ đọc trực tiếp.
Hợp đồng thì một, nhưng mỗi nơi thực thi một mức
Câu tôi định viết ban đầu rất gọn: "O_DIRECT cần buffer căn lề, sai thì EINVAL". Và tôi định chứng minh điều đó bằng một phép thử duy nhất. Nếu tôi tình cờ chọn chạy phép thử trên bind mount FUSE trước — nơi rất nhiều người để dữ liệu vì tiện gắn từ máy chủ — tôi sẽ thấy buffer lệch vẫn chạy ngon, và có thể kết luận nhầm rằng "ràng buộc căn lề này không quan trọng lắm, code của mình chạy tốt mà".
Đó chính là cái bẫy. Ràng buộc căn lề của O_DIRECT là một hợp đồng do từng filesystem tự thực thi, và mỗi filesystem thực thi khác nhau: overlayfs bắt chặt (lệch là EINVAL), FUSE thả lỏng (lệch vẫn cho qua), /dev/zero không tham gia hợp đồng (không mở nổi). Nếu tôi phát triển và test trên FUSE, code thiếu căn lề của tôi sẽ im lặng chạy đúng trong lúc phát triển — rồi khi triển khai lên một filesystem thật (ext4, XFS trên đĩa thật), nó EINVAL ngay lần đọc đầu tiên, giữa production. Một lỗi không bao giờ xuất hiện trong lúc test, chỉ nổ khi ra thật.
Điều rút ra: đo hành vi I/O trên một filesystem không nói cho bạn điều gì về filesystem khác. Cùng một syscall, cùng một cờ, mà kết quả phụ thuộc vào tầng lưu trữ cụ thể — và trong một thế giới đầy overlayfs, tmpfs, NFS, FUSE, các volume ảo hóa, "chạy được trên máy mình" là một lời hứa đặc biệt rỗng với O_DIRECT. Cách duy nhất để chắc là viết mã đúng theo hợp đồng chuẩn (luôn căn lề buffer/offset/độ dài, dù filesystem hiện tại có bắt hay không) và test trên đúng filesystem sẽ chạy trong production. Đây là biến ẩn "môi trường" của bài page cache ở một dạng sắc bén hơn: không chỉ số đo đổi theo môi trường, mà tính đúng sai của mã cũng đổi.
Về đến code hằng ngày
Điều đầu tiên: luôn căn lề đúng chuẩn khi dùng O_DIRECT, đừng dựa vào filesystem có tha thứ hay không. Cấp buffer bằng posix_memalign(&buf, 4096, size) chứ không phải malloc, đọc/ghi theo offset và độ dài là bội số của kích thước khối (lấy từ statvfs hay ioctl BLKSSZGET, hoặc dùng 4096 cho an toàn). Viết đúng hợp đồng thì mã chạy trên mọi filesystem, kể cả những cái bắt chặt; viết ẩu thì bạn đang phó mặc cho một chi tiết filesystem mà bạn không kiểm soát.
Điều thứ hai: O_DIRECT là công cụ chuyên biệt, không phải "cách đọc nhanh hơn". Nó chậm hơn đọc có đệm cho tải thông thường, vì bạn mất luôn lợi ích của page cache (readahead, gộp ghi, phục vụ đọc lại từ RAM). Nó chỉ đáng khi bạn thật sự tự quản lý cache tốt hơn kernel — điển hình là cơ sở dữ liệu với buffer pool riêng — hoặc khi bạn cần đọc/ghi một khối dữ liệu khổng lồ đúng một lần và không muốn nó đẩy dữ liệu nóng của người khác ra khỏi page cache. Dùng O_DIRECT vì tưởng "trực tiếp thì nhanh" thường làm mọi thứ chậm đi và phức tạp lên vì ràng buộc căn lề.
Và cái bẫy "một hợp đồng API được các nền tảng thực thi ở mức nghiêm ngặt khác nhau, nên đúng-ở-đây không phải đúng-ở-mọi-nơi" này lặp lại khắp nghề, không riêng O_DIRECT. Trong Java, String.getBytes() hay new FileReader() dùng charset mặc định của nền tảng — code chạy đúng trên máy dev UTF-8 rồi hỏng trên server dùng charset khác, đó là lý do luôn phải chỉ định StandardCharsets.UTF_8 tường minh. SQL là ổ bẫy kinh điển: một truy vấn dựa vào collation, hành vi NULL, hay kiểu ngầm định "chạy đúng" trên MySQL rồi sai trên PostgreSQL; GROUP BY lỏng lẻo được MySQL cho qua mà chuẩn SQL cấm. Ngay cả đường dẫn file (/ so với \), thứ tự byte, hay Math.round nửa-lẻ khác nhau giữa ngôn ngữ. Mẫu số chung: khi một hành vi phụ thuộc nền tảng/cấu hình, viết theo chuẩn nghiêm ngặt nhất và chỉ định tường minh mọi thứ có mặc định — rồi test trên đúng môi trường production, vì một nền tảng dễ tính chỉ đang giấu bug cho tới lúc bạn đổi chỗ chạy. Gói lại bằng số: O_DIRECT bỏ qua page cache và đòi buffer/offset/độ dài căn lề theo khối; nhưng việc thực thi tùy filesystem — overlayfs từ chối buffer lệch bằng EINVAL (errno 22), FUSE bind mount lờ đi cho chạy (r=4096), /dev/zero không mở nổi O_DIRECT — nên code thiếu căn lề có thể chạy tốt lúc test rồi vỡ trên filesystem production.
Muốn tự thấy thì viết một chương trình mở một file với open(fn, O_RDONLY | O_DIRECT), rồi thử pread hai lần: một lần với buffer từ malloc(4096) cộng thêm 1 byte (địa chỉ lệch), một lần với buffer từ posix_memalign(&buf, 4096, 4096). Chạy trên các loại lưu trữ bạn có: ổ đĩa thật (ext4/XFS), /tmp, một thư mục NFS hay một volume Docker. Bạn sẽ thấy buffer căn lề luôn đọc được, còn buffer lệch thì tùy filesystem — chỗ trả EINVAL, chỗ lại chạy ngon. Đó là bằng chứng tận mắt rằng O_DIRECT là một hợp đồng được thực thi không đồng đều, và là lý do bạn phải căn lề đúng chuẩn thay vì tin vào cái filesystem trước mặt.