Hình dung bạn nhận hàng. Một thùng lớn 78 kg tới cửa: bạn ký một lần, mở một lần, khiêng vào. Giờ đổi lại: cùng 78 kg đó chia thành 20.000 gói tí hon, mỗi gói tới riêng — bạn phải ra cửa, ký, mở, xử lý hai vạn lần, dù mỗi gói nhẹ hều. Cái mệt không nằm ở trọng lượng, mà ở số lần ra cửa. Đọc dữ liệu từ file y hệt vậy: mỗi lần open một file là một lần "ra cửa ký nhận", và nó tốn gần như nhau dù file to hay nhỏ.
Bạn có 78 MB dữ liệu cần đọc. Nó nằm trong một file 78 MB, hay trong 20.000 file nhỏ mỗi cái 4 KB, thì thời gian đọc có khác nhau không? Trực giác nói không — cùng số byte mà. Nhưng có một chi phí ẩn mà mỗi file phải trả, độc lập với việc file to hay nhỏ: chi phí mở file (open). Tôi đo trong container gcc:13, và không chỉ thấy nhiều file nhỏ chậm hơn, mà còn chứng minh được chi phí đó là per-file, không phải per-byte — bằng một phép so sánh mà con số nói thẳng.
Cái giá của mỗi lần mở file
Mỗi khi bạn open một file, hệ điều hành làm nhiều việc bất kể file lớn nhỏ: nó phải phân giải đường dẫn (path lookup — đi qua từng thành phần thư mục, tra bảng dentry), nạp inode của file (metadata: quyền, chủ sở hữu, kích thước, danh sách block trên đĩa), rồi cấp một file descriptor. Đó là một lời gọi hệ thống — một lần vượt ranh giới user/kernel — cộng công tra cứu metadata. Khi xong, close là một syscall nữa. Toàn bộ cái đó là chi phí cố định mỗi file, chẳng liên quan gì tới việc bạn sắp đọc 4 KB hay 4 MB từ nó — đúng cái "ra cửa ký nhận" tốn như nhau dù gói nặng nhẹ.
Một file lớn trả cái giá đó một lần: một open, rồi bạn stream toàn bộ dữ liệu bằng nhiều read nối tiếp (được readahead của phần 40 giúp sức). Nhưng N file nhỏ trả cái giá đó N lần: N × (open + read + close + tra metadata). Nếu chi phí per-file lớn so với lượng byte mỗi file, tổng thời gian bị nó chi phối. Tôi đo để xem, và để tách bạch per-file khỏi per-byte.
Đo: 4× chậm hơn, và bằng chứng per-file
Đọc bằng open+read+close mỗi file (file đã trong page cache):
20.000 file 4 KB (78 MB) : 0,022 s | 1,10 µs/file
20.000 file 512 B (9,8 MB, ÍT hơn 8 lần): 0,023 s | 1,14 µs/file <- gần BẰNG!
1 file 78 MB (stream khối 64 KB) : 0,0052 s (một open)
-> 20.000 file nhỏ chậm ~4× một file lớn cùng byte
Đọc 78 MB dưới dạng 20.000 file 4 KB mất 0,022 giây; đọc cùng 78 MB đó dưới dạng một file chỉ mất 0,0052 giây — nhanh 4 lần. Cùng dữ liệu, cùng số byte, nhưng cách chia thành file quyết định tốc độ. Mỗi file nhỏ tốn ~1,10 µs chỉ cho khâu mở/đóng/metadata; nhân với 20.000 thành ~22 ms, trong khi stream 78 MB từ một file chỉ ~5 ms. Phần lớn thời gian không phải đọc dữ liệu, mà là mở file.
Đây là chỗ tôi chứng minh chi phí là per-file chứ không phải per-byte: tôi đọc 20.000 file 512 byte — cùng số file nhưng dữ liệu ít đi 8 lần (9,8 MB thay vì 78 MB). Nếu chi phí theo byte, nó phải nhanh hơn ~8 lần. Nhưng đo ra: 0,023 giây — gần như bằng với 20.000 file 4 KB. Giảm dữ liệu 8 lần không làm nó nhanh hơn, vì thời gian bị chi phối bởi 20.000 lần open, không phải bởi lượng byte. Chi phí per-file (~1,1 µs) là cố định, dù file 512 byte hay 4 KB.
Một điều nói thẳng cho sòng phẳng: các file đều đã trong page cache (tôi warmup trước), nên tôi không đo phần I/O đĩa. Chi phí per-file tôi đo được là open/metadata/syscall — thứ có thật bất kể cache. Trên một đĩa thật (nhất là HDD), mỗi lần mở một file nhỏ mới có thể phải seek để đọc inode rồi seek lần nữa để đọc dữ liệu — nên gap giữa "nhiều file nhỏ" và "một file lớn" trên đĩa thật còn tệ hơn nhiều con số 4× tôi đo trong cache.
Hai niềm tin đối nghịch, cùng sai
Tôi vào đo với niềm tin thẳng thắn: "đọc cùng một lượng byte thì chia thành bao nhiêu file cũng vậy". Con số phá tan: 20.000 file nhỏ chậm 4 lần một file lớn cùng byte — và cái test 512 byte cho thấy vì sao: chi phí gắn với số file, không phải số byte. Mỗi open là một khoản phí cố định (path lookup + inode + syscall), nên hàng vạn file nhỏ nghĩa là hàng vạn lần trả phí đó, lấn át việc đọc dữ liệu thật.
Còn một niềm tin ngược cũng đáng chỉnh: "vậy cứ nhồi tất cả vào một file khổng lồ là luôn tốt nhất". Không hẳn — một file quá lớn gây khó khi cần cập nhật một phần nhỏ (phải ghi lại nhiều), khó song song hóa, khó phân tán, và mất tính cô lập lỗi (một file hỏng mất tất cả). Cái đúng không phải "một file", mà là gộp hợp lý: đủ ít file để chi phí open không lấn át, nhưng vẫn chia theo đơn vị truy cập/cập nhật có nghĩa — đó là lý do database dùng nhiều file cỡ vừa (một page/segment) chứ không một file duy nhất.
Về đến code hằng ngày
Điều đầu tiên: tránh "một file cho mỗi mẩu dữ liệu nhỏ". Nếu bạn có hàng nghìn/triệu mẩu nhỏ (bản ghi, ảnh thumbnail, mảnh dữ liệu), đừng lưu mỗi mẩu một file — chi phí mở sẽ giết bạn khi đọc/liệt kê hàng loạt, và còn phí cả inode lẫn không gian (mỗi file chiếm tối thiểu một block). Gộp chúng: một file dữ liệu có index, một database, một archive. "Nhiều file nhỏ" là một trong những phản mẫu hiệu năng phổ biến nhất, đặc biệt trên mạng lưu trữ và filesystem phân tán.
Điều thứ hai: khi phải xử lý nhiều file, giảm số lần chạm metadata. Nếu buộc phải đọc nhiều file, đọc chúng tuần tự (để filesystem tối ưu), tránh stat thừa (mỗi stat cũng là một syscall + metadata), và cân nhắc readdir + đọc theo lô.
Chỗ này chính là lý do tồn tại của cả một họ công nghệ gộp file mà bạn dùng mỗi ngày. tar/zip gói cả cây thư mục thành một file để trả open một lần; SQLite là "một database = một file" thay vì một file mỗi bản ghi (chính khẩu hiệu của nó là thay thế fopen()); git nén hàng nghìn object rời thành packfile; các engine game gói hàng nghìn texture vào một asset bundle; và ở web, bundler (webpack, esbuild) gộp hàng trăm file JS/CSS nhỏ thành ít file lớn — cùng lý do, cộng thêm tiết kiệm round-trip mạng. Ở tầng dữ liệu lớn, đây là "small files problem" nổi tiếng của Hadoop/HDFS và các data lake: hàng triệu file nhỏ làm nghẽn namenode và giết hiệu năng, nên người ta gộp thành file Parquet/ORC lớn. Cùng một bài học hai-vạn-gói-hàng, lặp lại ở mọi quy mô.
Muốn tự thấy thì tạo một thư mục với vài nghìn file nhỏ (for i in $(seq 10000); do echo x > f$i; done) và một file lớn cùng tổng dung lượng, rồi bấm giờ đọc từng cái: time cat dir/* > /dev/null so với time cat bigfile > /dev/null. Bạn sẽ thấy cái nhiều-file-nhỏ chậm hơn hẳn — dù cat đọc cùng lượng byte. Nếu muốn thấy bằng chứng per-file, làm hai bộ file cùng số lượng nhưng khác kích thước 8 lần — chúng sẽ tốn thời gian gần bằng nhau. Vài phút đó cho bạn thấy một chi phí mà "cùng byte thì như nhau" giấu đi: mở một file có giá, và khi bạn mở hàng nghìn, cái giá đó — không phải dữ liệu — mới là thứ bạn đang trả.