从目录到磁盘数据
一、为什么要理解 Linux 文件系统
日常开发和运维中,我们经常会遇到这些问题:
/etc- 为什么
df 显示磁盘已满,du 却找不到大文件? - 一个 Java 程序执行
FileInputStream.read() 时,数据经历了哪些层次?
这些问题表面上属于不同领域,底层都指向同一套核心机制:
Linux 将文件访问抽象成统一接口,再通过 VFS、具体文件系统、页缓存、块设备和设备驱动,最终完成对物理存储介质的读写。
本文将沿着一条文件访问链路,从用户看到的目录和文件,一直讲到底层磁盘数据。
二、建立整体认知
Linux 文件系统不是一个单独的软件模块,而是一套分层协作机制。
一个应用读取文件时,大致会经历以下过程:
应用程序││ open() / read() / write()▼系统调用接口│▼VFS 虚拟文件系统│▼页缓存 Page Cache│▼具体文件系统ext4 / XFS / Btrfs / NFS│▼块设备层│▼设备驱动NVMe / SATA / SCSI / VirtIO│▼物理存储介质SSD / HDD / 云盘
需要注意,这张图是为了帮助理解而进行的简化。
在真实 Linux 内核中,读写流程并不总是严格按照单一顺序执行。例如:
O_DIRECTtmpfsprocfs、sysfs 中的内容由内核动态生成,并不存放在磁盘;- NFS、CIFS 等网络文件系统的数据来自远程服务器。
因此,Linux 中的“文件”是一种统一抽象,但文件背后的数据来源可能完全不同。
三、Linux 文件系统的六个核心层次
3.1 应用层:用户看到的文件世界
应用程序通常通过路径访问文件,例如:
cat /etc/hosts
Java 程序中可能是:
try (FileInputStreaminput=newFileInputStream("/etc/hosts")) {byte[] buffer = newbyte[4096];intlength= input.read(buffer);}
在应用层,程序只关心:
应用程序通常不需要知道:
- 底层是 SATA SSD、NVMe SSD 还是云盘。
这种隔离正是操作系统抽象的价值。
3.2 系统调用层:用户态进入内核态
应用程序不能直接访问磁盘硬件,也不能直接操作内核中的文件系统对象。
它必须通过系统调用进入内核。
常见文件系统调用包括:
| |
|---|
openat() | |
read() | |
write() | |
close() | |
stat() | |
unlink() | |
mkdir() | |
rename() | |
fsync() | |
mmap() | |
虽然我们经常说 open(),但现代 Linux 中,很多库函数最终会使用 openat() 或相关变体。
例如:
strace -e trace=openat,read,close cat /etc/hosts
可能看到类似输出:
openat(AT_FDCWD, "/etc/hosts", O_RDONLY) = 3read(3, "127.0.0.1 localhost\n...", 131072) = 221close(3) = 0
其中:
3
就是内核返回给进程的文件描述符。
3.3 VFS:统一所有文件系统的抽象层
VFS 全称 Virtual File System,即虚拟文件系统。
它位于系统调用和具体文件系统之间,为上层提供统一接口。
无论文件位于:
应用程序都可以使用类似的方式访问:
open();read();write();close();
VFS 的主要作用包括:
例如,应用程序调用:
read(fd, buffer, size);
VFS 会根据这个文件对应的文件系统类型,将读取操作交给:
应用程序不需要知道这些差异。
3.4 具体文件系统:决定数据如何组织
VFS 只是统一接口,真正负责组织文件和数据的是具体文件系统。
常见 Linux 文件系统包括:
具体文件系统负责:
不同文件系统实现不同,但都通过 VFS 向上提供统一能力。
3.5 页缓存:内存中的文件数据缓存
普通文件 I/O 通常不会每次都直接访问磁盘。
Linux 会使用 Page Cache,也就是页缓存,缓存文件内容。
读取文件时:
应用程序│▼VFS│├──页缓存中存在数据──►直接返回│└──页缓存中不存在数据│▼读取磁盘│▼数据放入页缓存│▼返回应用程序
写文件时,普通 write() 通常先把数据写入页缓存。
此时:
内核会在合适的时机将脏页写回磁盘。
这也是为什么:
outputStream.write(data);
执行成功,并不一定意味着数据已经安全落盘。
需要更强持久性保证时,应考虑:
fsync(fd);
或者在 Java 中使用:
fileDescriptor.sync();
数据库通常会通过 fsync()、fdatasync() 或直接 I/O 控制数据持久性。
Page Cache 不等于块设备缓存
需要区分几个概念:
- Buffer Head:传统块元数据和块映射机制;
这些缓存位于不同层次,不能简单混为一谈。
3.6 块设备和物理存储
具体文件系统最终需要将逻辑文件数据转换成底层存储请求。
Linux 将磁盘、分区、逻辑卷等抽象为块设备,例如:
/dev/sda/dev/sda1/dev/nvme0n1/dev/nvme0n1p1/dev/mapper/ubuntu--vg-ubuntu--lv
块设备支持按块随机访问。
文件系统通常使用 4 KiB 等大小的文件系统块管理数据,但具体大小取决于格式化参数和文件系统实现。
存储设备底层还存在扇区概念:
- 内存页在常见 x86-64 系统上通常也是 4 KiB。
这些值经常相同,但它们属于不同层次,不能认为必然一致。
四、Linux 目录树和磁盘不是一回事
4.1 Linux 只有一棵目录树
Linux 使用单根目录树:
/├── etc├── home├── usr├── var├── dev├── proc├── run└── data
所有目录都从 / 开始。
这与 Windows 的盘符模型不同:
C:\D:\E:\
Linux 不要求每块磁盘对应一个独立根目录。
一块磁盘、一个分区或一个网络文件系统,可以挂载到目录树中的任意目录。
例如:
mount /dev/sdb1 /data
含义是:
将 /dev/sdb1 上的文件系统接入 /data 这个目录节点。
从此以后访问:
/data
就是在访问 /dev/sdb1 上的文件系统。
4.2 路径不直接等于磁盘位置
例如:
/var/lib/mysql/ibdata1
这个路径并不能直接说明数据位于哪块物理磁盘。
它可能位于:
/dev/nvme0n1p2
也可能位于:
/dev/mapper/ubuntu--vg-data
还可能位于:
可以使用:
findmnt -T /var/lib/mysql/ibdata1
查看某个路径实际属于哪个挂载点。
示例:
TARGET SOURCE FSTYPE OPTIONS/ /dev/mapper/ubuntu-root ext4 rw,relatime
五、Linux 核心目录解析
Linux 目录布局通常遵循 Filesystem Hierarchy Standard 的基本原则,但不同发行版可能存在差异。
5.1 /:根目录
/ 是整棵目录树的起点。
系统启动后,根文件系统首先被挂载,其他文件系统再挂载到根目录树中的不同位置。
查看根目录所在文件系统:
findmnt /
5.2 /etc:系统配置
/etc 主要存放系统级配置文件。
常见内容:
/etc/passwd/etc/group/etc/hosts/etc/fstab/etc/ssh/sshd_config/etc/nginx/nginx.conf/etc/systemd/system/
特点:
例如:
cat /etc/fstab
可以查看系统启动时的文件系统挂载配置。
5.3 /var:持续变化的数据
var 表示 variable。
这里通常存放系统运行过程中不断变化的数据。
常见目录:
| |
|---|
/var/log | |
/var/lib | |
/var/cache | |
/var/spool | |
/var/tmp | |
例如:
/var/lib/mysql/var/lib/docker/var/log/nginx/var/log/journal
线上磁盘空间异常时,/var 往往是重点排查区域。
5.4 /home:普通用户家目录
普通用户通常拥有自己的家目录:
/home/alice/home/developer/home/ditt
用户登录后,环境变量:
echo"$HOME"
通常会指向对应目录。
用户级配置文件也经常存放在这里:
~/.bashrc~/.ssh/~/.config/~/.local/
5.5 /root:root 用户家目录
root 用户的家目录一般是:
/root
而不是:
/home/root
这样设计可以使 root 的家目录不依赖 /home 是否独立挂载。
5.6 /usr:程序和共享资源
/usr 是 Linux 系统中体量最大的目录之一。
常见子目录:
| |
|---|
/usr/bin | |
/usr/sbin | |
/usr/lib | |
/usr/share | |
/usr/local | |
现代 Linux 发行版通常采用 usr merge。
这意味着:
/bin -> /usr/bin/sbin -> /usr/sbin/lib -> /usr/lib
因此,/bin 和 /usr/bin 在很多现代系统中已经不是两个真正独立的目录。
可以执行:
ls -ld /bin /sbin /lib
查看它们是否为符号链接。
5.7 /tmp:临时文件
/tmp 用于保存短期临时数据。
特点:
- 使用 sticky bit 防止用户删除其他人的文件;
查看权限:
ls -ld /tmp
常见输出:
drwxrwxrwt
最后一个 t 表示 sticky bit。
5.8 /dev:设备文件
Linux 将设备抽象为文件。
例如:
/dev/sda/dev/nvme0n1/dev/null/dev/zero/dev/random/dev/tty
这些通常不是普通磁盘文件,而是设备节点。
查看类型:
ls -l /dev/null /dev/nvme0n1
设备文件主要分为:
其中磁盘和分区通常属于块设备。
5.9 /proc:内核和进程信息
/proc 是虚拟文件系统。
它不是普通磁盘目录,其中内容主要由内核动态生成。
例如:
/proc/cpuinfo/proc/meminfo/proc/loadavg/proc/<pid>/status/proc/<pid>/fd/
查看内存信息:
cat /proc/meminfo
查看进程打开的文件描述符:
ls -l /proc/<pid>/fd
/proc 中看到的内容通常不占用根文件系统的数据空间。
5.10 /sys:设备和内核对象
/sys 同样是虚拟文件系统。
它主要展示:
例如:
/sys/block//sys/class/net//sys/devices/
查看块设备队列参数:
ls /sys/block/nvme0n1/queue/
5.11 /run:运行时状态
/run 保存系统启动后的运行时状态。
常见内容:
/run/systemd//run/lock//run/user//run/docker.pid
/run 通常挂载为 tmpfs,因此内容主要存在于内存中,重启后会消失。
六、VFS 的四个核心对象
理解 Linux 文件系统,最关键的是理解以下四个对象:
superblockinodedentryfile
它们分别解决不同问题。
6.1 superblock:描述整个文件系统
超级块代表一个已经挂载的文件系统实例。
它通常包含:
可以把它理解为:
整个文件系统的总体管理对象。
对于 ext4 文件系统,磁盘中也存在持久化超级块结构。
查看 ext4 超级块信息:
sudo tune2fs -l /dev/sda1
或者:
sudo dumpe2fs -h /dev/sda1
操作前应确认设备名称,避免对错误设备执行命令。
6.2 inode:描述文件本身
inode 是文件的核心元数据对象。
inode 通常保存:
inode 通常不保存普通文件名。
文件名保存在目录数据中。
查看文件 inode:
ls -li /etc/hosts
示例:
131105 -rw-r--r-- 1 root root 221 Jul 20 10:30 /etc/hosts
最左侧数字:
131105
就是 inode 号。
查看详细元数据:
stat /etc/hosts
6.3 dentry:文件名到 inode 的映射
dentry 全称 directory entry,即目录项。
目录中的一个名称,例如:
hosts
会对应到某个 inode。
可以简化理解为:
文件名→ inode
例如目录:
/etc
内部保存类似关系:
hosts → inode 131105passwd → inode 131212group → inode 131213
路径解析:
/etc/nginx/nginx.conf
并不是一次性直接找到文件,而是逐层查找:
/└── etc└── nginx└── nginx.conf
内核需要依次解析每一级目录项。
为了提高路径查找效率,Linux 会维护 dentry cache。
这也是大量文件操作性能与目录结构密切相关的原因之一。
6.4 file:一次打开文件的状态
当进程打开一个文件时,内核会创建或关联一个 struct file 对象。
这个对象代表:
某个进程对某个文件的一次打开实例。
它通常包含:
例如:
int fd = open("/tmp/test.log", O_RDONLY);
内核返回的 fd 只是一个整数索引。
进程内部关系可以简化为:
文件描述符 fd│▼进程文件描述符表│▼打开文件对象 struct file│▼inode│▼文件数据
同一个文件可以被多个进程同时打开。
这些进程可能:
七、文件名、inode 和数据的真实关系
一个普通文件可以抽象成三部分:
文件名│▼目录项 dentry│▼inode│▼数据块或 extent
例如:
/var/log/myapp/app.log
其中:
因此:
文件名不是文件本体,inode 也不是文件内容,数据块才保存文件数据。
八、硬链接和符号链接
8.1 硬链接
硬链接的本质是:
多个目录项指向同一个 inode。
实验:
echo"hello" > source.txtln source.txt hard-link.txtls -li source.txt hard-link.txt
输出可能是:
105001 -rw-r--r-- 2 user user 6 Jul 27 10:00 hard-link.txt105001 -rw-r--r-- 2 user user 6 Jul 27 10:00 source.txt
可以看到:
删除其中一个:
rm source.txtcat hard-link.txt
数据仍然可以访问。
只有当:
硬链接计数为 0并且没有进程继续打开该文件
文件占用的存储空间才可以被真正回收。
硬链接的限制
通常情况下:
因为从 inode 角度看,多个名称地位相同。
8.2 符号链接
符号链接本身是一个独立文件,它保存的是目标路径。
创建符号链接:
ln -s source.txt soft-link.txt
查看:
ls -li source.txt soft-link.txt
两个文件的 inode 通常不同。
符号链接保存的内容类似:
source.txt
如果删除源文件:
rm source.txtcat soft-link.txt
符号链接将失效。
符号链接特点
九、删除文件时,系统到底做了什么
执行:
rm app.log
并不意味着立即将文件内容全部写成零。
典型过程是:
- 如果仍有进程持有打开文件对象,inode 和数据块继续保留;
- 当最后一个打开引用释放后,文件系统才回收 inode 和数据空间;
因此,删除文件需要同时区分两个条件:
硬链接数量打开文件引用数量
只有两者都归零,空间才会真正释放。
9.1 为什么删除文件后还能恢复
文件删除后,文件系统一般只是将相关空间标记为可重新使用。
在新数据覆盖之前,旧数据可能仍然残留在存储介质中。
恢复成功率取决于:
需要注意:
在现代 SSD、TRIM、写时复制文件系统和加密存储环境中,删除恢复并不一定可靠。
发现误删后,应立即:
9.2 shred 并不适用于所有存储设备
传统机械硬盘上,多次覆写可能降低数据恢复概率。
但以下环境中,shred 不能保证完全擦除:
更可靠的敏感数据保护策略是:
十、挂载:把一个文件系统接入目录树
10.1 什么是挂载
假设存在一个分区:
/dev/sdb1
先创建挂载点:
sudomkdir -p /data
执行挂载:
sudo mount /dev/sdb1 /data
此后:
/data/file.txt
实际存储在 /dev/sdb1 上的文件系统中。
挂载的本质是:
将一个文件系统的根目录连接到当前目录树的某个目录节点。
10.2 挂载遮挡
假设 /data 原本有文件:
echo"old data" | sudotee /data/old.txt
然后将新文件系统挂载到 /data:
sudo mount /dev/sdb1 /data
此时:
ls /data
看不到原来的 old.txt。
但它没有被删除,只是被新挂载的文件系统遮挡了。
卸载:
sudo umount /data
原来的文件会重新出现。
这就是挂载遮挡。
10.3 挂载点不一定必须是空目录
技术上,非空目录也可以作为挂载点。
但是这样会遮挡原有内容,因此生产环境通常建议使用空目录作为挂载点,避免误判和空间浪费。
十一、从磁盘到文件:分区、文件系统和挂载点
这几个概念容易混淆:
磁盘│▼分区│▼文件系统│▼挂载点
例如:
/dev/nvme0n1 磁盘└── /dev/nvme0n1p2 分区│├── ext4 文件系统│└── / 挂载点
完整过程可能是:
# 1. 查看磁盘lsblk# 2. 创建分区sudo fdisk /dev/sdb# 3. 创建文件系统sudo mkfs.ext4 /dev/sdb1# 4. 创建挂载点sudomkdir -p /data# 5. 挂载sudo mount /dev/sdb1 /data
生产环境中还可能增加 LVM:
物理磁盘│▼分区或整盘│▼LVM PV│▼Volume Group│▼Logical Volume│▼ext4 / XFS│▼挂载点
十二、常用排查命令
12.1 df:查看文件系统空间
df -h
示例:
Filesystem Size Used Avail Use% Mounted on/dev/sda2 98G 76G 17G 82% //dev/sdb1 500G 120G 380G 25% /data
字段说明:
查看指定路径所属文件系统:
df -h /var/log
查看文件系统类型:
df -hT
查看 inode 使用情况:
df -i
12.2 du:统计目录和文件占用
查看目录占用:
du -sh /var/log
查看一级子目录:
du -xh --max-depth=1 /var | sort -h
查找较大的目录:
sudodu -xah /var | sort -rh | head -30
参数说明:
需要注意:
du 根据当前可见目录项统计文件占用,而 df 根据文件系统整体空间统计。
因此两者结果可能不同。
12.3 lsblk:查看块设备结构
lsblk
更推荐:
lsblk -f
输出可能包括:
NAME FSTYPE FSVER LABEL UUID MOUNTPOINTSnvme0n1├─nvme0n1p1 vfat FAT32 XXXX-XXXX /boot/efi└─nvme0n1p2 ext4 1.0 xxxx-xxxx-xxxx /sdb└─sdb1 xfs yyyy-yyyy-yyyy /data
可以同时看到:
12.4 findmnt:查看挂载关系
显示完整挂载树:
findmnt
查看路径所在文件系统:
findmnt -T /var/lib/mysql
查看指定挂载点:
findmnt /data
只输出来源设备:
findmnt -n -o SOURCE -T /data
查看文件系统类型:
findmnt -n -o FSTYPE -T /data
12.5 stat:查看文件元数据
stat app.log
重点字段:
SizeBlocksInodeLinksAccessModifyChangeBirth
其中:
SizeBlocksInodeLinksModifyChangeBirth
注意:
ctime 是 change time,不是 create time。
12.6 lsof:查看进程打开的文件
查看已删除但仍然打开的文件:
sudo lsof +L1
也可以:
sudo lsof | grep '(deleted)'
推荐优先使用:
sudo lsof +L1
因为它直接筛选硬链接计数小于 1 的打开文件。
查看某个进程:
sudo lsof -p <PID>
查看指定目录下被打开的文件:
sudo lsof +D /var/log/myapp
对于大型目录,+D 可能较慢,应谨慎使用。
十三、为什么 df 很满,du 却不大
这是 Linux 运维中的经典问题。
13.1 已删除文件仍被进程占用
场景:
rm /var/log/myapp/app.log
但 Java 进程仍然保持该文件打开。
此时:
检查:
sudo lsof +L1
可能看到:
java 1823 app 12w REG 8,1 21474836480 0 123456 /var/log/myapp/app.log (deleted)
表示 Java 进程仍然占用一个约 20 GiB 的已删除文件。
13.2 挂载遮挡
如果某个非空目录被挂载新文件系统,原目录内容会被遮挡。
例如:
/data
原本在根分区中占用 100 GiB。
后来 /dev/sdb1 挂载到 /data,原内容不可见。
此时:
可以使用 bind mount 查看根文件系统原始目录。
假设 /data 位于根文件系统:
sudomkdir -p /mnt/root-viewsudo mount --bind / /mnt/root-viewsudodu -sh /mnt/root-view/data
这样可以绕过 /data 当前的挂载,查看根文件系统中的原始内容。
完成后卸载:
sudo umount /mnt/root-view
13.3 文件系统保留空间
ext4 默认可能为特权用户保留一部分空间。
这可以防止普通用户写满整个文件系统,确保系统服务和 root 仍有一定操作空间。
查看:
sudo tune2fs -l /dev/sda2 | grep -E 'Reserved block count|Block size'
因此:
df 中的 Avail 不一定等于 Size - Used;
13.4 inode 耗尽
有时磁盘容量还很多,但无法创建新文件。
检查:
df -i
如果看到:
IUse% 100%
说明 inode 已耗尽。
常见原因:
此时即使还有几十 GiB 空间,也可能报错:
No space left on device
13.5 稀疏文件
稀疏文件的逻辑大小可能远大于实际占用空间。
创建一个 10 GiB 稀疏文件:
truncate -s 10G sparse.img
查看逻辑大小:
ls -lh sparse.img
可能显示:
10G
查看实际占用:
du -h sparse.img
可能只显示:
0
查看:
stat sparse.img
可以对比:
因此:
十四、实战案例:Java 日志写满磁盘
14.1 故障现象
线上 Java 服务出现异常:
java.io.IOException: No space left on device
监控显示根分区使用率达到 95%。
日志目录:
/var/log/myapp
14.2 第一步:确认是哪个文件系统满了
df -hT
重点确认:
查看目标目录:
findmnt -T /var/log/myapp
不要看到 /var/log/myapp 就直接假设它一定属于根分区。
14.3 第二步:检查 inode
df -i
如果块空间未满,但 inode 使用率 100%,应重点排查大量小文件。
查找文件数量较多的目录:
sudo find /var/log -xdev -type f | \awk -F/ '{ path=""; for (i=1; i<NF; i++) { if ($i != "") { path=path "/" $i; } } count[path]++;}END { for (dir in count) { print count[dir], dir; }}' | sort -nr | head
生产环境目录很大时,应控制查找范围,避免产生较高 I/O。
14.4 第三步:定位大目录
先看一级目录:
sudodu -xhd1 /var | sort -h
继续深入:
sudodu -xhd1 /var/log | sort -h
再查看应用日志:
sudodu -xhd1 /var/log/myapp | sort -h
查找大文件:
sudo find /var/log/myapp -xdev -type f \ -printf'%s %p\n' |sort -nr |head -20 |numfmt --field=1 --to=iec
14.5 第四步:检查已删除文件
如果:
df -h
显示使用了 90 GiB,而:
du -shx /
只能统计到 50 GiB,应立即检查:
sudo lsof +L1
针对 Java:
sudo lsof -p "$(pgrep -f 'myapp.jar' | head -1)" +L1
也可以查看进程文件描述符:
sudols -l /proc/<PID>/fd
可能看到:
12 -> /var/log/myapp/app.log (deleted)
14.6 如何安全释放已删除文件占用
方案一:让应用重新打开日志
最规范的方式是:
对于 Java 应用,是否支持信号重新打开日志,取决于日志框架和运行方式。
如果没有明确支持,重启服务通常最可靠:
sudo systemctl restart myapp
重启前应确认:
方案二:通过文件描述符清空
如果暂时不能重启,可以对打开的文件描述符执行截断。
先确认文件描述符:
sudols -l /proc/<PID>/fd
假设文件描述符为 12:
sudotruncate -s 0 /proc/<PID>/fd/12
或者:
sudo sh -c ': > /proc/<PID>/fd/12'
该操作风险较高,执行前必须确认:
14.7 不应简单认为 rm 和 truncate 完全等价
对于正在持续写入的日志文件:
rm app.log
删除的是目录项。
进程仍可能继续写入旧 inode。
而:
truncate -s 0 app.log
会将当前文件长度截断为 0,通常不会改变 inode。
因此,清理正在写入的日志时,通常优先考虑:
truncate -s 0 app.log
但更正确的长期方案不是人工清空,而是配置日志滚动。
十五、使用 logrotate 管理 Java 日志
假设日志文件:
/var/log/myapp/app.log
可以创建:
/etc/logrotate.d/myapp
配置示例:
/var/log/myapp/app.log { daily rotate 14 size 100M compress delaycompress missingok notifempty copytruncate}
字段含义:
| |
|---|
daily | |
rotate 14 | |
size 100M | |
compress | |
delaycompress | |
missingok | |
notifempty | |
copytruncate | |
测试配置:
sudo logrotate -d /etc/logrotate.d/myapp
强制执行:
sudo logrotate -f /etc/logrotate.d/myapp
15.1 copytruncate 的风险
copytruncate 的流程是:
复制和截断之间存在很短的时间窗口,可能丢失少量日志。
因此,更推荐的方案是:
Java 常见日志框架,如 Logback、Log4j2,通常都支持按日期和大小滚动。
例如 Logback:
<appendername="ROLLING"class="ch.qos.logback.core.rolling.RollingFileAppender"><file>/var/log/myapp/app.log</file><rollingPolicyclass="ch.qos.logback.core.rolling.SizeAndTimeBasedRollingPolicy"><fileNamePattern> /var/log/myapp/app.%d{yyyy-MM-dd}.%i.log.gz</fileNamePattern><maxFileSize>100MB</maxFileSize><maxHistory>14</maxHistory><totalSizeCap>5GB</totalSizeCap></rollingPolicy><encoder><pattern> %d{yyyy-MM-dd HH:mm:ss.SSS} %-5level [%thread] %logger{36} - %msg%n</pattern></encoder></appender>
相比人工删除日志,应用层滚动通常更可靠。
十六、动手实验:理解删除文件为什么不释放空间
下面实验建议在测试环境执行。
16.1 创建测试文件
mkdir -p /tmp/fs-labcd /tmp/fs-labddif=/dev/zero of=test.log bs=1M count=100
查看空间:
ls -lh test.logdu -h test.log
16.2 使用进程持续打开文件
执行:
tail -f test.log
保持该终端不关闭。
在另一个终端中查看:
pgrep -f 'tail -f test.log'
16.3 删除文件
rm /tmp/fs-lab/test.log
此时:
ls -l /tmp/fs-lab
文件已经消失。
但执行:
sudo lsof +L1 | grep test.log
仍可能看到:
tail ... /tmp/fs-lab/test.log (deleted)
说明文件仍被进程打开。
16.4 释放文件
停止 tail:
Ctrl+C
再次检查:
sudo lsof +L1 | grep test.log
记录消失,文件空间才真正释放。
这个实验完整展示了:
删除目录项≠立即释放文件数据
十七、动手实验:观察 inode 和硬链接
创建文件:
cd /tmpecho"Linux filesystem" > inode-test.txt
查看 inode:
ls -li inode-test.txt
创建硬链接:
ln inode-test.txt inode-test-hard.txt
再次查看:
ls -li inode-test.txt inode-test-hard.txt
可以观察到:
删除原名称:
rm inode-test.txt
读取硬链接:
cat inode-test-hard.txt
内容仍存在。
查看链接数:
stat inode-test-hard.txt
链接数变回 1。
十八、动手实验:观察页缓存影响
创建测试文件:
ddif=/dev/urandom of=/tmp/cache-test.bin \ bs=1M count=1024 status=progress
第一次读取:
timecat /tmp/cache-test.bin > /dev/null
第二次读取:
timecat /tmp/cache-test.bin > /dev/null
第二次通常更快,因为数据可能已经进入页缓存。
查看内存:
free -h
Linux 中较大的 buff/cache 不一定代表内存浪费。
页缓存可以在应用需要内存时被回收,它是提升文件 I/O 性能的重要机制。
十九、开发人员必须理解的几个细节
19.1 write() 成功不等于物理落盘
普通写入通常先进入页缓存。
因此:
write() 返回成功
通常只说明数据已经被内核接受,并不一定已经到达持久化介质。
对于数据库、账务、订单等关键数据,应正确使用:
19.2 文件重命名通常比复制更轻量
同一文件系统内执行:
mv old.log new.log
通常主要修改目录项,不需要复制文件内容。
但跨文件系统执行:
mv /data1/file /data2/file
通常相当于:
判断两个路径是否属于同一文件系统:
df -h /data1/file /data2
或:
findmnt -T /data1/filefindmnt -T /data2
19.3 原子重命名是常见安全写入手段
许多程序更新配置文件时,会先写临时文件:
config.tmp
写入完成后执行:
mv config.tmp config.json
在同一文件系统中,rename() 通常具有原子性。
这样可以避免读取方看到只写了一半的文件。
但要获得严格持久化保证,还需要考虑:
19.4 打开文件后,路径可以消失
进程打开文件后,主要通过文件描述符访问打开文件对象。
即使文件随后被:
进程仍可能继续访问原来的 inode。
这对以下场景十分重要:
二十、常见认知误区
误区一:目录就是磁盘
错误。
目录只是文件系统命名空间中的节点。
它可能属于:
误区二:文件名存储在 inode 中
错误。
普通情况下,文件名保存在目录数据中,inode 保存文件元数据和数据映射。
误区三:删除文件后,数据立即消失
错误。
删除通常先移除目录项。
如果还有硬链接或打开引用,文件数据不会释放。
即使空间已释放,底层数据也不一定立即物理擦除。
误区四:df 和 du 应该完全一致
错误。
两者统计角度不同:
已删除文件、挂载遮挡、保留块等情况都会导致差异。
误区五:内存中的缓存越少越好
错误。
Linux 会利用空闲内存作为页缓存,以提高文件访问性能。
页缓存通常可以在需要时回收。
误区六:rm 大日志一定能释放空间
错误。
如果进程仍然打开日志文件,空间不会释放。
应结合:
lsof +L1
检查。
二十一、磁盘问题标准排查流程
遇到“磁盘满”时,可以按照以下顺序排查。
第一步:确认块空间
df -hT
确认:
第二步:确认 inode
df -i
排除 inode 耗尽。
第三步:确认路径所属文件系统
findmnt -T /var/log
避免排查错分区。
第四步:统计大目录
sudodu -xhd1 / | sort -h
逐层深入:
sudodu -xhd1 /var | sort -hsudodu -xhd1 /var/log | sort -h
第五步:查找大文件
sudo find /var -xdev -type f \ -size +1G \ -printf'%s %p\n' |sort -nr |numfmt --field=1 --to=iec
第六步:检查已删除文件
sudo lsof +L1
第七步:检查挂载遮挡
findmntlsblk -f
必要时通过 bind mount 查看被遮挡目录。
第八步:检查特殊目录
重点关注:
/var/log/var/lib/docker/var/lib/containerd/var/lib/mysql/tmp/var/tmp/home/root
容器环境还应检查:
docker system df
但不要在不理解影响的情况下直接执行:
docker system prune -a
因为它可能删除未使用镜像、构建缓存和其他资源。
二十二、总结:真正需要掌握的核心模型
Linux 文件系统可以归纳为三条主线。
1. 文件访问链路
应用程序↓系统调用↓VFS↓页缓存↓具体文件系统↓块设备↓存储设备
2. 文件对象关系
文件名↓目录项 dentry↓inode↓数据块或 extent
其中:
3. 磁盘接入目录树
磁盘↓分区或逻辑卷↓文件系统↓挂载点↓Linux 目录树
当你真正理解以下五个概念后,大多数 Linux 文件系统问题都会变得清晰:
它们可以解释:
- 为什么同一套 API 能操作 ext4、XFS 和 NFS;
Linux 文件系统的关键,不是记住所有命令,而是建立一套稳定的心智模型:
路径负责定位,目录项负责映射,inode 负责描述,页缓存负责加速,文件系统负责组织,块设备负责传输,存储介质负责保存。
*上层(路径、Dentry、Inode)管“在哪、是什么”——负责命名与描述。*中层(Page Cache、文件系统)管“怎么快、怎么存”——负责组织与加速。*下层(块设备、存储介质)管“怎么传、怎么留”——负责搬运与持久化。