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 — thậm chí có hại. Một cơ sở dữ liệu tự quản lý bộ đệm của riêng nó; nếu kernel cũng cache mọi thứ nó đọc, dữ liệu bị đệm hai lần, phí RAM. O_DIRECT là cờ nói kernel "bỏ qua page cache, đọc thẳng từ đĩa vào buffer của tôi". Bài này đo O_DIRECT 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.

Đọc trực tiếp O_DIRECT

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, độ 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.

Một lần tôi đo hớ: hợp đồng nhưng mỗi nơi thực thi một khá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.

Bài học đo lường: đ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) 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ì sao điều này quan trọng khi lập trình

Hệ quả đầ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.

Hệ quả 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ề.

Hệ quả thứ ba là bài học đo lường mang theo: một API là một hợp đồng, và các nền tảng thực thi hợp đồng đó ở mức nghiêm ngặt khác nhau — hãy viết theo chuẩn, không theo hành vi quan sát được ở một nơi. Con số mang theo: 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, /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. Khi một hành vi phụ thuộc nền tảng, "đo thấy đúng ở đây" chưa bao giờ là "đúng ở mọi nơi"; hãy tuân thủ hợp đồng và test trên đúng thứ bạn sẽ chạy.

Thử ba mươi giây

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.