Hình dung một bãi đỗ xe có hai giới hạn hoàn toàn tách rời: tổng diện tích mặt bằng, và số vé mà quầy in ra được. Người ta hay chỉ nghĩ tới cái thứ nhất. Nhưng nếu quầy chỉ có sẵn đúng số vé nhất định, thì đến lúc phát hết vé, bảo vệ sẽ chặn xe ở cổng với tấm biển "hết chỗ" — trong khi bạn nhìn qua hàng rào thấy bãi còn mênh mông ô trống. Hệ tệp ext4 có đúng hai giới hạn như vậy, và bài này bắt đầu từ cái sự cố mà tấm biển nói dối.

Ba khái niệm — liên kết cứng, liên kết mềm, inode — thường được giải thích bằng sơ đồ. Tôi đo giới hạn thật của chúng.

Hết ổ đĩa với 71% dung lượng còn trống

Tạo tệp rỗng trên ext4 tới khi không tạo được nữa:

dừng ở tệp thứ 131.058
No space left on device
dung lượng: 516 MB / 2,0 GB   (29%)  <- CÒN TRỐNG
inode     : 131.072 / 131.072 (100%) <- ĐÃ HẾT

Ổ đĩa còn 71%. Thông báo lỗi nói "hết chỗ", và nó không nhắc gì tới inode — tấm biển "hết chỗ" trong khi hết vé, không hết ô đỗ.

Mỗi tệp cần một inode — cấu trúc chứa quyền, chủ sở hữu, thời gian, và danh sách khối dữ liệu. Tên tệp không nằm trong inode; nó nằm trong thư mục, cùng với số hiệu inode. Và ext4 chốt số inode lúc mkfs và không đổi được sau đó — quầy in sẵn một xấp vé cố định. Cùng phép thử trên xfs:

xfs tạo hết 200.000 tệp, không báo lỗi
inode: 200.006 / 1.048.576 (20%)

xfs cấp inode động. btrfs cũng vậy — df -i trên btrfs báo 0 vì không có con số cố định nào để báo.

Đây là một trong những sự cố khó chẩn đoán nhất trên máy chủ: thư mục hàng đợi thư, bộ đệm phiên, thư mục tệp tạm ngập tệp nhỏ, và ứng dụng bắt đầu lỗi ENOSPC trong khi df -h hiện màu xanh.

df -i        # LUON kiem tra cai nay khi thay ENOSPC

Đặt trước lúc tạo hệ tệp nếu biết sẽ có nhiều tệp nhỏ:

mkfs.ext4 -i 4096 /dev/sdb1      # mot inode moi 4 KB thay vi moi 16 KB
mkfs.ext4 -N 5000000 /dev/sdb1   # hoac dat thang so luong

Không sửa được sau khi đã tạo — phải định dạng lại.

Trần số liên kết cứng

Tạo liên kết cứng tới khi hỏng:

Hệ tệp Dừng ở Lỗi
ext4 65.000 EMLINK — Too many links
btrfs 65.535 EMLINK
xfs vượt 70.001 chưa chạm trần

ext4 lưu bộ đếm liên kết trong 16 bit và giữ lại một khoảng; xfs dùng 32 bit nên trần cao tới mức thực tế không gặp.

Con số này ảnh hưởng tới các công cụ sao lưu kiểu liên kết cứng để chia sẻ dữ liệu chung — rsnapshot, rsync --link-dest, Time Machine. Một tệp không đổi qua 65.000 bản sao lưu là kịch bản có thật với sao lưu theo giờ trong bảy năm.

Liên kết cứng không tốn gì, liên kết mềm tốn theo tầng

200.000 lần stat(), đo bằng chương trình C:

Đường dẫn ns mỗi lần So với tệp gốc
Tệp gốc 235 1,00×
Liên kết cứng 244 1,04×
Liên kết mềm, 1 mức 321 1,37×
Liên kết mềm, 2 mức 399 1,70×
Liên kết mềm, 4 mức 558 2,37×
Liên kết mềm, 8 mức 879 3,74×
Liên kết mềm, 16 mức 1.506 6,41×

Liên kết cứng bằng đúng tệp gốc — vì nó là tệp gốc. Một inode có nhiều tên; không tên nào là "bản gốc", và stat không phải làm gì thêm.

Liên kết mềm là một tệp riêng chứa một chuỗi đường dẫn. Mỗi mức cộng thêm khoảng 80 ns: đọc nội dung liên kết, rồi bắt đầu lại việc tra tên từ đầu. Trong đường dẫn nóng của một dịch vụ mở hàng chục nghìn tệp mỗi giây, một chuỗi liên kết mềm bốn tầng — chuyện rất thường gặp với /etc/alternatives hoặc thư mục do trình quản lý gói dựng — nhân đôi chi phí mở tệp.

Trần chuỗi liên kết mềm là đúng 40

chuỗi 39 mức: regular file
chuỗi 40 mức: regular file
chuỗi 41 mức: Too many levels of symbolic links

ELOOP không chỉ dành cho vòng lặp. Một chuỗi thẳng dài quá 40 cũng bị từ chối, và thông báo lỗi vẫn nói "vòng lặp" — đủ để dẫn người gỡ lỗi đi sai hướng.

Sai lầm khi đo: stat không đi theo liên kết

Lần đo đầu tiên tôi dùng lệnh stat của shell và nó báo chuỗi 200 mức vẫn đọc được. Con số đó mâu thuẫn với mọi tài liệu, nên tôi kiểm lại:

stat KHÔNG -L:  s60 -> symbolic link
stat -L      :  chuỗi 60 mức -> Too many levels of symbolic links

GNU stat mặc định không đi theo liên kết. Nó mô tả chính liên kết đó, nên chuỗi dài bao nhiêu cũng "thành công" — nó có bao giờ đi đâu. Nghe hiển nhiên khi đã biết, nhưng lúc đo thì nó lặng lẽ cho ra một kết quả có vẻ hợp lý. Thứ cứu tôi là con số 200 quá tròn trịa so với hằng số 40 mà tôi từng đọc — lại một lần nữa, dấu hiệu cảnh báo là con số không khớp với hiểu biết nền, không phải một thông báo lỗi.

Ba lệnh, ba hành vi khác nhau:

Lệnh Đi theo liên kết?
stat tep Không
stat -L tep Có
ls -l tep Không (hiện mũi tên)
cat tep Có
du tep Không (đếm chính liên kết)
du -L tep Có

Chọn cái nào

Liên kết cứng Liên kết mềm
Qua được hệ tệp khác ❌ ✅
Trỏ tới thư mục ❌ ✅
Chi phí tra tên 0 ~80 ns mỗi mức
Xoá bản gốc dữ liệu còn thành liên kết hỏng
Thấy được bằng ls -l ❌ không phân biệt được ✅

Điểm cuối đáng nhớ: không có cách nào nhìn một tệp mà biết nó có liên kết cứng hay không ngoài việc đọc st_nlink:

stat -c '%h liên kết, inode %i' tep
find /duong/dan -samefile tep      # tim moi ten khac cua cung inode
find /duong/dan -links +1 -type f  # tim tep co nhieu hon mot ten

Đó cũng là lý do liên kết cứng gây bất ngờ khi sửa tệp: sửa qua tên này là sửa luôn nội dung thấy qua tên kia. Trình soạn thảo nào ghi bằng cách tạo tệp mới rồi đổi tên — phần lớn đều vậy — sẽ phá liên kết cứng mà không báo gì.

Mẫu số chung: một tài nguyên cạn trước, và thông báo lỗi đổ cho tài nguyên khác

Cái lõi của sự cố inode ở đầu bài không riêng gì hệ tệp: một hệ thống thường có nhiều giới hạn độc lập, cái ít lộ nhất cạn trước, còn thông báo lỗi lại đổ cho cái dễ thấy nhất — và bạn đi tìm sai chỗ. Cùng cái bẫy ở mọi tầng.

Trong thế giới Java, đây đúng là OutOfMemoryError mà không phải hết heap: "unable to create native thread" hay "Metaspace" cạn trong khi -Xmx còn dư mênh mông — heap là cái ai cũng nhìn, nhưng thứ hết trước là số luồng OS, file descriptor, hay metaspace. Đổ cho heap rồi nống -Xmx lên thì chẳng giải quyết gì, y như mở rộng ổ đĩa không thêm inode.

Trong Go, cùng hình dạng: dial: too many open files giữa lúc bộ nhớ và CPU đều nhàn — file descriptor (ulimit -n) cạn trước, không phải tài nguyên nào bạn đang theo dõi; hoặc goroutine leak làm cạn bộ nhớ trong khi ai cũng nhìn vào QPS. Con số ràng buộc thật hiếm khi là con số trên dashboard.

Còn ở cơ sở dữ liệu, đây là chuyện Postgres từ chối kết nối vì hết max_connections trong khi CPU/RAM/đĩa đều rảnh; hay transaction ID wraparound (XID) — một bộ đếm cạn dần mà không dashboard mặc định nào hiện, tới lúc cạn thì DB dừng ghi hoàn toàn dù đĩa còn đầy. Tài nguyên vô hình cạn trước, thông báo lỗi nói chuyện khác.

Mẫu số chung: một hệ thống có nhiều giới hạn độc lập, và cái cạn trước thường là cái ít được theo dõi nhất — trong khi thông báo lỗi lại quy cho tài nguyên phổ biến dễ thấy (dung lượng, bộ nhớ, CPU), khiến người ta "khắc phục" đúng cái vẫn còn dư. Nên khi một hệ thống báo hết một thứ mà bạn nhìn thấy thứ đó vẫn còn nhiều, câu hỏi đúng không phải "làm sao có thêm cái này" mà là "còn giới hạn nào khác tôi không đang nhìn — số vé, không phải số ô đỗ — và cái đó đã cạn chưa" — vì gần như luôn, thủ phạm là một bộ đếm không nằm trên dashboard.

Muốn tự soi máy mình có sắp hết inode không, và tìm thủ phạm:

df -i | awk 'NR==1 || ($5+0) > 50'

# thu muc nao nhieu tep nhat
for d in /var/lib /var/log /var/spool /tmp /home; do
  [ -d "$d" ] && printf "%9s  %s\n" "$(find $d -xdev 2>/dev/null | wc -l)" "$d"
done | sort -rn

# tep nao co nhieu ten (lien ket cung)
find / -xdev -type f -links +1 -printf '%n %p\n' 2>/dev/null | sort -rn | head -5

Cột thứ năm của df -i trên 80% là lúc cần hành động, và hành động đó thường là xoá tệp nhỏ, không phải mở rộng ổ đĩa — mở rộng ổ đĩa không thêm inode nào cho ext4.

Phần sau: bộ nhớ ánh xạ tệp — đo mmap so với read.