Linux-04 文件与目录操作:从 ls 的每个参数到 inode 引用计数
这一篇假设你已经能用 ls、cd、cp、mv、rm 完成日常操作。所以我们不从「怎么复制文件」讲起,而是从五个用了很久却答不上来的问题讲起:
- 磁盘满了,删掉一个 10G 的日志文件,
df显示空间一点没少。为什么? mv同一个 20G 文件,有时一瞬间完成,有时要等好几分钟。区别在哪?- 能不能秒级复制一个 10G 文件?(能,条件是文件系统支持)
ls一个有 100 万文件的目录会卡住十几秒,ls -U却很快。差在哪?- 一个文件名叫
-rf,rm -rf删不掉,怎么办?
这五个问题的答案都指向同一个认知:在 Linux 里,「文件名」和「文件」是两个东西。绝大多数困惑都来自把它们当成一个东西。
本篇会把 inode 讲清楚——它是什么、里面存了什么、为什么这样设计。至于它在内核里如何被抽象成统一接口(VFS)、ext4 的 inode 表和日志在磁盘上的具体布局,留给第 20 篇 VFS 和第 30 篇 文件系统。
1. 文件的真身:名字与数据是两回事
先做一个实验,它会颠覆「文件名就是文件」的直觉:
cd /tmp
echo "hello" > a.txt
ln a.txt b.txt # 注意:ln 不带 -s,这是【硬链接】
ls -li a.txt b.txt
# 2621441 -rw-r--r-- 2 lhx lhx 6 Aug 13 20:00 a.txt
# ^^^^^^^ ^
# 同一个 inode 号 硬链接数是 2
# 2621441 -rw-r--r-- 2 lhx lhx 6 Aug 13 20:00 b.txt
echo "world" >> b.txt # 改 b.txt
cat a.txt
# hello
# world # a.txt 也变了!
rm a.txt
cat b.txt # 数据还在,一点没丢
# hello
# world
a.txt 和 b.txt 不是「两个文件」,也不是「一个文件和它的副本」。它们是同一份数据的两个名字。
1.1 inode 到底是什么
inode = index node,索引节点。
一句话定义:inode 是文件系统里一个定长的结构体,它保存了一个文件的全部元数据、以及数据存在磁盘哪些块上的索引;唯独不保存文件名。
「唯独不保存文件名」是整篇文章的枢纽。文件名在目录里(下一节讲),inode 里没有名字这个字段。所以刚才那个实验才成立:两个名字指向同一个 inode,删掉一个名字,inode 毫发无损。
ext4 上一个 inode 默认占 256 字节,主要字段是:
| 字段 | 含义 |
|---|---|
i_mode |
文件类型(普通文件/目录/软链接/设备…)+ 9 位权限 |
i_uid / i_gid |
属主 uid、属组 gid(数字,不是名字) |
i_size |
文件大小(字节) |
i_blocks |
实际占用的块数 |
i_atime / i_mtime / i_ctime / i_crtime |
访问 / 修改 / inode 变更 / 创建时间 |
i_links_count |
硬链接数 —— 有多少个目录项指向我。第 3 节讲的「删除」就是在减这个数 |
i_block[15] 或 extent 树 |
数据块的索引:文件内容到底存在磁盘哪些块上 |
i_flags / i_extra_isize / xattr 指针 |
属性标志、扩展属性(ACL、SELinux 标签存在这里) |
可以用 debugfs 把一个真实 inode 的内容打印出来(只读,安全):
stat -c '%i' /tmp/a.txt # 先拿到 inode 号
# 2621441
sudo debugfs -R 'stat <2621441>' /dev/vda1 2>/dev/null
# Inode: 2621441 Type: regular Mode: 0644 Flags: 0x80000
# User: 1000 Group: 1000 Project: 0 Size: 12
# File ACL: 0
# Links: 2 Blockcount: 8
# ^^^^^^^^^ 就是 i_links_count
# ctime: 0x68a1c2f3 -- Wed Aug 13 20:00:03 2026
# atime: 0x68a1c2f3 -- Wed Aug 13 20:00:03 2026
# mtime: 0x68a1c2f3 -- Wed Aug 13 20:00:03 2026
# EXTENTS:
# (0):10485762
# ^^^^^^^^ 数据在第 10485762 号物理块上 <- 这就是"索引"
三层结构:路径名 → 目录项 → inode → 数据块。
"/tmp/a.txt"
|
| (1) 读目录 /tmp 的数据块,按名字 "a.txt" 查到 inode 号
v
+------------------------------+
| dir /tmp name -> inode no.|
+------------------------------+
| a.txt -> 2621441 | <-+ 两行指向同一个 inode 号
| b.txt -> 2621441 | <-+ 这就是硬链接
| other -> 2700000 |
+------------------------------+
|
| (2) inode 号即下标:表起始 + (号-1) x 256,直接算出磁盘位置
v
+------------------------------+
| inode 2621441 |
+------------------------------+
| i_mode 0644 regular | <- 类型 + 权限
| i_uid/i_gid 1000 / 1000 | <- 只有数字,没有用户名
| i_size 12 |
| i_links_count 2 | <- 删除时减的就是这个数
| i_mtime 1755086403 |
| extents -> block 10485762| <- 数据块的索引
+------------------------------+
|
| (3) 按 extents 指向的块号,去读真正的内容
v
+------------------------------+
| block 10485762 |
+------------------------------+
| "hello\nworld\n" |
+------------------------------+
打开 /var/log/app.log 时,内核要走完整条链,而且每一级目录都要重复一遍「读 inode → 读它的数据块 → 查下一级的名字」:
根目录 inode(固定为 2) → 读 / 的数据 → 找到 "var" 的 inode 号
→ 读 var 的 inode → 读 var 的数据 → 找到 "log" 的 inode 号
→ 读 log 的 inode → 读 log 的数据 → 找到 "app.log" 的 inode 号
→ 读 app.log 的 inode → 拿到数据块索引 → 读内容
这解释了两件事:① 路径越深,打开文件的开销越大(所以内核用 dentry cache 把「路径 → inode」的结果缓存起来);② 根目录的 inode 号是固定的 2,否则这条链没有起点。
stat -c '%i' / # 2 <- ext4 上根目录永远是 2(1 号留给坏块记录)
为什么要设计成「名字与元数据分离」
这是本节的「所以然」。把文件名放进目录、把元数据放进 inode,换来三个能力:
- 一份数据可以有多个名字(硬链接)。如果名字存在 inode 里,一个文件就只能有一个名字。
- 改名和移动极其廉价。
mv在同一文件系统内只改目录里的一行,不碰 inode、不碰数据,所以 20G 文件重命名也是瞬间的,而且是原子的(第 5 节详述)。 - 元数据定长 + 编号即下标 → O(1) 定位。inode 号本质上是 inode 表的数组下标,磁盘位置可以直接算出来:
inode 表起始位置 + (inode号 - 1) × inode大小,不需要任何查找。
代价也很明确:ext4 在格式化时就把 inode 表的大小固定了,用完就没有了。这就是那个著名的故障——
# 空间明明还有,却创建不了文件
df -h /data
# /dev/vdb1 50G 20G 28G 42% /data <- 还剩 28G
touch /data/newfile
# touch: cannot touch '/data/newfile': No space left on device
# ^^^^^^^^^^^^^^^^^^^^^^ 报的是"没空间",但空间明明有
df -i /data
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/vdb1 3276800 3276800 0 100% /data <- 真正的原因:inode 用尽
inode 的总数在 mkfs 时按「每多少字节数据配一个 inode」决定,默认 16KB 一个:
sudo dumpe2fs -h /dev/vdb1 2>/dev/null | grep -iE 'inode count|block count|inode size'
# Inode count: 3276800 <- 总共只有 327 万个 inode
# Block count: 13107200
# Inode size: 256
# 格式化时可以调整(存海量小文件的盘必须调)
mkfs.ext4 -i 4096 /dev/vdb1 # 每 4KB 数据一个 inode,数量翻 4 倍
mkfs.ext4 -N 20000000 /dev/vdb1 # 或直接指定 inode 总数
# ⚠️ 格式化后无法增加,只能重建文件系统
xfs 是动态分配 inode 的,几乎不会用尽(受 imaxpct 限制),这也是它在海量小文件场景更受欢迎的原因之一。这种「预分配 vs 动态分配」的取舍属于文件系统设计层面,第 30 篇会展开。
最后强调 inode 不保存的两样东西,它们是很多误解的来源:
- 不保存文件名 —— 名字在目录里。所以「一个 inode 的所有名字」无法从 inode 本身查出,只能反向扫描(
find / -inum 2621441)。 - 不保存路径 —— Linux 里根本不存在「这个文件的路径」这个概念,只有「能到达它的若干条路径」。所以进程里拿到 fd 后想知道对应路径,只能查
/proc/<pid>/fd/<n>这个符号链接,而且文件被删掉后它会显示(deleted)。
1.2 stat:把文件的元数据全部摊开
stat 是理解文件的第一工具。ls -l 只是它的一个精简视图。
stat /etc/passwd
File: /etc/passwd
Size: 2986 Blocks: 8 IO Block: 4096 regular file
^^^^ ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^
文件内容的字节数 实际占用的 512B 扇区数 文件系统块大小
Device: 803h/2051d Inode: 2621441 Links: 1
^^^^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^^
所在设备号(主:次) inode 号 + 有几个名字指向它
Access: (0644/-rw-r--r--) Uid: ( 0/ root) Gid: ( 0/ root)
^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
权限的八进制与符号两种表示 + 属主属组的数字与名字两种表示
Access: 2026-08-13 09:12:03.120000000 +0800 <- atime:最后被【读】的时间
Modify: 2026-08-01 17:40:11.000000000 +0800 <- mtime:内容最后被【改】的时间
Change: 2026-08-01 17:40:11.884000000 +0800 <- ctime:inode 最后被改的时间
Birth: 2026-07-20 10:03:22.000000000 +0800 <- 创建时间(需要文件系统支持)
这里有三个必须记住的点:
① Size 和 Blocks 可以毫不相干。 Size 是「文件逻辑上有多大」,Blocks 是「真的占了多少磁盘」。稀疏文件里两者能差几个数量级:
truncate -s 1G sparse.img # 造一个 1GB 的稀疏文件
ls -lh sparse.img
# -rw-r--r-- 1 lhx lhx 1.0G Aug 13 20:10 sparse.img <- 看起来 1GB
du -h sparse.img
# 0 sparse.img <- 实际占 0 字节
stat -c '%s bytes / %b blocks' sparse.img
# 1073741824 bytes / 0 blocks
虚拟机磁盘镜像、数据库预分配文件、/var/log/lastlog 都是稀疏文件。这解释了「为什么 ls 加起来 100G,du 只有 3G」。
② Inode 是文件在这个文件系统里的真正身份证。 文件名只是指向它的一个标签。
③ Links 是有几个目录项指向这个 inode。 这个数字是理解「删除」的钥匙,下一节会用到。
stat 的 --format(-c)可以只取需要的字段,脚本里很好用:
stat -c '%n %s %U %a' /etc/passwd
# | | | +- 权限(八进制)
# | | +---- 属主名
# | +------- 大小
# +---------- 文件名
# /etc/passwd 2986 root 644
# 批量:找出 /etc 下权限是 777 的文件
stat -c '%a %n' /etc/* 2>/dev/null | grep '^777'
1.3 目录只是一张「名字 → inode」的映射表
这是本篇最重要的一句话:目录是一种特殊的文件,它的内容是一张表,每行记录「文件名 + inode 号」。
ls -ld /etc
# drwxr-xr-x 145 root root 12288 Aug 13 10:00 /etc
# ^^^^^ 12288 字节
很多人以为这个 12288 是「/etc 里所有文件的总大小」——不是。它是这张映射表本身占的字节数。/etc 下条目多,表就大。想看目录里内容的总大小,得用 du:
du -sh /etc
# 12M /etc
# ^^ 这才是里面所有文件加起来的大小
用 debugfs 可以直接把这张表打印出来(ext4,需要 root,只读打开很安全):
sudo debugfs -R 'ls -l /tmp' /dev/vda1 2>/dev/null | head -5
# 2621441 100644 (1) 1000 1000 6 13-Aug-2026 20:00 a.txt
# ^^^^^^^ ^^^^^^ ^^^^ ^^^^
# inode 号 类型+权限 uid gid
理解了「目录 = 映射表」,一串现象立刻自洽:
| 现象 | 原因 |
|---|---|
| 删文件需要目录的写权限,不需要文件本身的写权限 | 删除是从这张表里划掉一行,改的是目录 |
| 同一个目录下不能有两个同名文件 | 表里的 key 是文件名 |
| 硬链接不能跨文件系统 | 表里存的是 inode 号,而 inode 号只在本文件系统内唯一 |
mv 同分区是瞬间的 |
只是把一行从一张表挪到另一张表,数据没动 |
| 空目录也占 4096 字节 | 表至少占一个块,且里面已经有 . 和 .. 两行 |
顺便解释一个高频疑问:为什么一个空目录的硬链接数是 2?
mkdir d && ls -ld d
# drwxr-xr-x 2 lhx lhx 4096 Aug 13 20:20 d
# ^ 2
mkdir d/sub && ls -ld d
# drwxr-xr-x 3 lhx lhx 4096 Aug 13 20:21 d
# ^ 变成 3 了
因为指向 d 这个 inode 的名字有两个:父目录里的 d,和它自己里面的 .。每加一个子目录,子目录里的 .. 又是一个指向它的名字,所以计数 +1。由此可得一个实用技巧:目录的链接数 - 2 = 它有几个直接子目录。
# 不用 ls 就知道有几个子目录
stat -c '%h' /etc # 145 -> /etc 有 143 个直接子目录
2. 硬链接与软链接:为什么必须存在两种
上一节的 ln a.txt b.txt 是硬链接。日常用得更多的是软链接 ln -s。两者不是「新旧」关系,而是解决不同问题。
2.1 硬链接:给同一个 inode 加一个名字
ln 原文件 新名字 # 硬链接,两个名字完全平等
关键性质:两个名字地位完全平等,没有「谁是本体谁是快捷方式」的区别。删掉任何一个,另一个照常工作。
它有两个硬性限制,而且这两个限制都不是「设计者偷懒」,而是逻辑上的必然:
限制一:不能跨文件系统。
ln /tmp/a.txt /mnt/data/b.txt
# ln: failed to create hard link '/mnt/data/b.txt' => '/tmp/a.txt':
# Invalid cross-device link
因为目录项里存的是 inode 号(比如 2621441),这个数字只在自己的文件系统内有意义。/mnt/data 那个文件系统里的 2621441 是完全不同的另一个文件。跨文件系统的硬链接在语义上无法表达。
限制二:普通用户不能给目录建硬链接。
ln /tmp/d /tmp/d2
# ln: /tmp/d: hard link not allowed for directory
因为目录构成一棵树,而硬链接会把树变成有环的图。一旦有环:find、du、rm -r 这些递归工具会无限循环;.. 该指向哪个父目录也变得无解。所以内核直接禁止(. 和 .. 是内核自己维护的例外)。
硬链接的实际用途:
# ① 备份工具的「增量快照」:没变的文件用硬链接,不占额外空间
cp -al backup_20260812 backup_20260813 # -l = 用硬链接代替复制
rsync -a --link-dest=/backup/day1 /data/ /backup/day2/
# day2 里没变的文件与 day1 共享 inode,10 天快照只占 1 份多一点的空间
# Time Machine、borg、restic 的核心思路
# ② 同一个程序按名字改变行为(busybox 的做法)
ls -li /bin/busybox /bin/ls
# 同一个 inode -> 程序用 argv[0] 判断自己被当成谁调用
2.2 软链接:一个内容是路径字符串的小文件
ln -s 目标路径 链接名
# ^^ symbolic
软链接是一个独立的文件,它有自己的 inode,内容就是一串路径文本:
ln -s /etc/nginx/nginx.conf myconf
ls -li myconf
# 2621500 lrwxrwxrwx 1 lhx lhx 23 Aug 13 20:30 myconf -> /etc/nginx/nginx.conf
# ^^^^^^^ 自己的 inode ^ 类型是 l
# ^^ 大小 23 = 目标路径的字符数
stat -c '%s %N' myconf
# 23 'myconf' -> '/etc/nginx/nginx.conf'
注意 lrwxrwxrwx——软链接自身的权限永远是 777 且无意义,能不能访问由目标文件的权限决定。改软链接的权限(chmod)实际改的是目标。
因为内容只是一串路径,所以它可以指向任何东西,包括不存在的东西:
ln -s /nonexistent broken
ls -l broken
# lrwxrwxrwx 1 lhx lhx 13 Aug 13 20:32 broken -> /nonexistent <- 通常显示为红色
cat broken
# cat: broken: No such file or directory <- 悬空链接(dangling symlink)
# 批量找出所有悬空链接(部署后清理的常用检查)
find /opt -xtype l
# ^^^^^^^ x = 检查链接指向的类型,l 表示"指向的东西不是文件也不存在"
2.3 相对路径软链接的坑
这是最常踩的一个坑:
cd /tmp
ln -s ../etc/passwd rel_link # 用相对路径
cat rel_link # 在 /tmp 下能读
mv rel_link /var/ # 挪到别的目录
cat /var/rel_link
# cat: /var/rel_link: No such file or directory <- 断了
因为软链接里存的字符串就是 ../etc/passwd,解析时相对于「链接文件自己所在的目录」。挪到 /var 之后就变成了 /etc/passwd(/var/.. = /),恰好这个还存在;如果层级更深就彻底断了。
规则:软链接指向的相对路径,是相对于链接所在目录,不是相对于你的当前目录。 所以创建时最容易出错的是这种:
cd /tmp
ln -s conf/app.yaml /opt/link # ❌ 想指向 /tmp/conf/app.yaml
readlink /opt/link
# conf/app.yaml # 实际解析成 /opt/conf/app.yaml,断的
ln -s /tmp/conf/app.yaml /opt/link # ✅ 用绝对路径最稳
ln -sr /tmp/conf/app.yaml /opt/link # ✅ -r 让 ln 自动算出正确的相对路径
readlink /opt/link
# ../tmp/conf/app.yaml
2.4 用软链接做原子版本切换(实战套路)
这是软链接最有价值的用法,几乎所有部署系统都在用:
/opt/app/
├── releases/
│ ├── v1.2.2/
│ ├── v1.2.3/
│ └── v1.2.4/
└── current -> releases/v1.2.3 # 服务的 systemd 配置里只写 current
切版本 = 改软链接指向。关键是必须用 -sfn 三个参数,否则会出错:
cd /opt/app
# ❌ 错误做法:ln -sf 在链接已存在且指向目录时,会钻进去创建
ln -sf releases/v1.2.4 current
ls -l current/
# current -> releases/v1.2.3
# current/v1.2.4 -> releases/v1.2.4 <- 在旧目标【里面】建了个链接!
# ✅ 正确做法:加 -n
ln -sfn releases/v1.2.4 current
# ^^^ f=force 覆盖已存在, n=no-dereference 把 current 当成链接本身而不是它指向的目录
# ✅ 更严谨的做法:新建 + 原子 rename(避免中间出现"没有 current"的瞬间)
ln -sfn releases/v1.2.4 current.tmp
mv -Tf current.tmp current
# ^^ -T 把目标当成普通文件而不是目录,mv 的 rename 是原子的
最后一种写法是零窗口的:任何时刻 current 要么指向旧版本要么指向新版本,不存在「不存在」的瞬间。原理是 rename() 系统调用的原子性,第 5 节会详细讲。
2.5 两者对比
硬链接 ln |
软链接 ln -s |
|
|---|---|---|
| 本质 | 目录表里多加一行,指向同一 inode | 一个新文件,内容是目标路径字符串 |
| 自己的 inode | 没有(共用) | 有 |
| 跨文件系统 | ❌ 不行(inode 号只在本 fs 有效) | ✅ 可以(存的只是字符串) |
| 指向目录 | ❌ 禁止(会成环) | ✅ 可以 |
| 目标删除后 | 照常可用(引用计数还 > 0) | 变成悬空链接 |
| 目标移动后 | 照常可用(跟的是 inode) | 断开(跟的是路径) |
ls -l 显示 |
与普通文件无异 | l 类型 + -> 目标 |
| 占空间 | 几乎为 0(一个目录项) | 一个 inode + 路径字符串 |
| 典型用途 | 增量备份快照、busybox | 版本切换、/usr/bin 里的多版本、配置集中管理 |
一个判断技巧:如果你希望"目标改名/移动后仍然有效",用硬链接;如果你希望"指向一个位置而不是一份数据",用软链接。
3. 删除的真相:Linux 里根本没有「删除文件」这个操作
回到开篇第一个问题:磁盘满了,删掉 10G 的日志,df 空间没释放。
要解释它,先看一个事实:删文件用的系统调用叫 unlink,不叫 delete。
strace -f -e trace=unlink,unlinkat rm /tmp/a.txt
# unlinkat(AT_FDCWD, "/tmp/a.txt", 0) = 0
# ^^^^^^ "解除链接",不是"删除"
unlink 做的事只有一件:从目录这张映射表里划掉一行,然后把 inode 的链接计数 -1。
数据什么时候真正被回收?内核的条件是:
链接计数(硬链接数) == 0 并且 打开该文件的 fd 数 == 0
两个条件都满足,空间才释放。 只满足第一个不行。
3.1 经典事故:删了日志但磁盘没释放
这是运维面试必问,也是线上真实高频事故:
df -h /
# Filesystem Size Used Avail Use% Mounted on
# /dev/vda1 50G 49G 0.5G 99% / <- 磁盘快满了
# 找到罪魁祸首
du -sh /var/log/app/app.log
# 12G /var/log/app/app.log
rm -f /var/log/app/app.log # 删掉它
df -h /
# /dev/vda1 50G 49G 0.5G 99% / <- 空间一点没释放!
原因:应用进程还开着这个文件的 fd。 目录项没了(ls 看不到),但链接计数虽然到 0,fd 计数还是 1,所以 inode 和它的数据块都还在。这种文件叫 deleted but still open。
怎么确诊和处理:
# ① 用 lsof 找出所有"已删除但仍被打开"的文件
sudo lsof +L1
# ^^^^ L = link count,+L1 表示"只显示链接数 < 1 的文件"
# COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
# java 1234 app 3w REG 253,1 12884901888 0 2621998 /var/log/app/app.log (deleted)
# ^^^^^^^^^^^ ^ ^^^^^^^^^
# 还占 12G NLINK=0 标记 deleted
# 也可以从 /proc 直接看(不依赖 lsof)
sudo ls -l /proc/1234/fd | grep deleted
# l-wx------ 1 root root 64 Aug 13 21:00 3 -> /var/log/app/app.log (deleted)
# ② 处理方式一:让进程重新打开文件(最正规)
systemctl reload app # 大多数服务的 reload 会重开日志文件
kill -USR1 $(pidof nginx) # nginx 的日志切割就是靠这个信号重开
# ③ 处理方式二:不重启也不删,直接【截断】文件(推荐!)
: > /var/log/app/app.log
# 或
truncate -s 0 /var/log/app/app.log
# 空间立刻释放,进程的 fd 依然有效,不用重启任何东西
这里得出一条重要的运维原则:清理正在被写入的日志,永远用「截断」而不是「删除」。
# ❌ rm 之后必须重启服务才能释放空间,且进程还在往一个不存在的文件写
rm /var/log/app/app.log
# ✅ 截断,进程无感知,空间立即回收
: > /var/log/app/app.log
有一个副作用要知道:截断后进程的写偏移量不会重置。如果进程用的是 O_APPEND(追加模式)没问题;如果是普通写且偏移量已经在 12G 处,下次写入会在 12G 偏移处落笔,中间变成一个巨大的稀疏空洞——ls 显示 12G 但 du 只有几 KB。这也是为什么正规做法还是配 logrotate 加 copytruncate 或让程序支持重开。
3.2 顺带解释一个「反直觉」
既然 unlink 改的是目录,那么权限检查看的也是目录:
cd /tmp && mkdir d && echo x > d/f
chmod 444 d/f # 文件设为只读
rm d/f
# rm: remove write-protected regular file 'd/f'? y <- 只是问一下,回车就删了
chmod 555 d # 目录去掉 w
rm -f d/f
# rm: cannot remove 'd/f': Permission denied <- 这才真删不掉
你对文件有全部权限也删不掉它,只要你对它所在的目录没有写权限。 反过来,你对一个 root 的只读文件毫无权限,只要目录是你的,你就能删掉它。这个模型的设计动机(以及 Sticky 位为什么被发明来打补丁)在第 23 篇 权限与授权的演进史里讲。
3.3 rm 的危险与防护
rm 没有回收站,-rf 更是不可逆。几个真实的翻车姿势:
# ① 变量为空 —— 最经典的删库姿势
DIR="" # 变量赋值失败/拼写错误
rm -rf "$DIR"/* # 展开成 rm -rf /* 💀
# 防护:脚本里必须校验
[[ -n "$DIR" && -d "$DIR" ]] || { echo "bad DIR"; exit 1; }
rm -rf "${DIR:?DIR must be set}"/*
# ^^^^^^^^^^^^^^^^^ 变量为空时 shell 直接报错退出,不执行
# ② 多打了一个空格
rm -rf /opt/app / # 后面那个 / 独立成参数
# 现代 rm 有保护:
# rm: it is dangerous to operate recursively on '/'
# rm: use --no-preserve-root to override this failsafe
# 但 rm -rf /opt/app/* / 这种就没保护了
# ③ 通配符与隐藏文件
rm -rf /opt/app/* # ⚠️ 不会删 .git、.env 等隐藏文件(glob 默认不匹配 . 开头)
rm -rf /opt/app/{*,.[!.]*} # 连隐藏文件一起(.[!.]* 避免匹配到 . 和 ..)
# ④ 文件名以 - 开头(开篇第 5 个问题)
touch -- -rf
rm -rf
# (什么都没发生,-rf 被当成参数了)
rm -- -rf # ✅ -- 表示"后面的都是文件名不是选项"
rm ./-rf # ✅ 或者加路径前缀
实用的防护习惯:
# 交互确认(-I 只在删 3 个以上文件时问一次,比 -i 每个都问更实用)
alias rm='rm -I'
# 危险操作前先 echo 一遍(dry-run 的穷人版)
echo rm -rf "$DIR"/* # 先看展开成什么,确认无误再去掉 echo
# 用 find 代替通配符做批量删除,条件更精确且不受 glob 长度限制
find /tmp/cache -type f -mtime +7 -delete
# ^^^^^^^^^ 7 天前修改的 ^^^^^^^ find 内建删除
3.4 删掉了还能救回来吗
诚实的答案:基本不能,别指望。
- 数据块可能立即被覆盖:inode 一释放,块就进了空闲池,任何写入都可能占用它。
- ext4 的
extundelete、ext4magic在立即卸载分区的前提下有一定成功率,但生产上「立即卸载根分区」通常不现实。 - 有一种情况是真能救的:文件被删了但进程还开着(3.1 那个场景)。此时可以从
/proc里把它捞回来:
# 服务还在运行,误删了它正在写的文件
sudo lsof +L1 | grep deleted
# java 1234 app 3w REG 253,1 12884901888 0 2621998 /var/log/app/app.log (deleted)
# ^^^^ 进程还开着
# 通过 /proc/<pid>/fd/<fd> 这个"活链接"把内容拷出来
sudo cp /proc/1234/fd/3 /var/log/app/app.log.recovered
# 数据完整找回(因为 inode 和数据块都还在)
这个技巧的原理正是本节讲的:只要 fd 还开着,文件就还活着,/proc/<pid>/fd/<n> 就是通往它的一条路。
真正的答案是:靠快照和备份,不靠恢复工具。 LVM 快照、云盘快照、rsync --link-dest 增量快照,都比事后恢复靠谱一个数量级。
4. 复制的层次:cp 到底做了什么
cp 是被低估最严重的命令。默认行为会丢掉大量东西。
4.1 默认的 cp 丢了什么
ls -l original
# -rwxr-xr-x 1 root root 1024 Aug 1 10:00 original
cp original copy1
ls -l copy1
# -rwxr-xr-x 1 lhx lhx 1024 Aug 13 21:00 copy1
# ^^^ ^^^ ^^^^^^^^^^^^
# 属主属组变成你了 时间戳变成现在了
默认 cp 只保证内容和执行位,属主、属组、时间戳、ACL、扩展属性、软链接本身、稀疏性全都会变。所以备份和迁移场景必须用 -a:
cp -a src/ dst/
# ^^ archive = -dR --preserve=all
# d = --no-dereference 软链接照抄成软链接,不跟进去复制目标
# R = --recursive 递归
# --preserve=all 保留 mode,ownership,timestamps,context,links,xattr
记住一句话:cp 用于「造一个新文件」,cp -a 用于「搬一份一模一样的」。
对比几个常见组合的差异:
# 只想保留时间戳(比如同步静态资源,让 nginx 的 Last-Modified 不变)
cp -p src dst # p = preserve mode,ownership,timestamps
# 目录里递归复制,但软链接要跟进去(打包发布时把链接变成实体)
cp -rL src/ dst/ # L = --dereference 跟进软链接
cp -rP src/ dst/ # P = --no-dereference 保持软链接(-a 隐含这个)
# 增量复制:只覆盖比目标新的文件
cp -u src/* dst/ # u = update
# ⚠️ -u 只比较 mtime,不比较内容。mtime 被改过的相同文件不会被复制
# 要按内容比对必须用 rsync -c
# 覆盖前备份
cp -b --suffix=.bak src dst # b = backup,dst 存在时先存成 dst.bak
# 别覆盖已存在的
cp -n src dst # n = no-clobber
cp -i src dst # i = interactive,覆盖前问一次
# ⚠️ 很多发行版给 root 配了 alias cp='cp -i',脚本里会因为等待输入而卡住
# 脚本里用 \cp 或 command cp 绕过 alias
4.2 秒级复制 10G 文件:reflink
回到开篇第三个问题。普通 cp 复制 10G 要真读真写 10G,机械盘要几分钟。但在支持 CoW(Copy-on-Write) 的文件系统上可以做到瞬间:
# btrfs / xfs(reflink=1) / bcachefs 上
cp --reflink=always bigfile.img copy.img
# 瞬间完成,且 du 显示两个文件共占 10G 而不是 20G
time cp --reflink=always 10G.img c1.img
# real 0m0.004s <- 4 毫秒
# 原理:不复制数据块,只让新文件的 extent 指向同一批物理块,
# 并标记为"写时复制"。谁写谁才真正分配新块。
# --reflink 的三个取值
cp --reflink=always src dst # 必须 reflink,不支持就报错
cp --reflink=auto src dst # 能 reflink 就 reflink,不能就退化成普通复制(安全)
cp --reflink=never src dst # 强制真复制(默认)
# 检查你的文件系统支不支持
df -T /data # 先看类型
# /dev/vdb1 xfs ...
xfs_info /data | grep reflink
# reflink=1 <- 支持(CentOS 8+/RHEL 8+ 默认开启)
顺便还有稀疏文件的处理:
# 复制稀疏文件时保持稀疏(否则 1GB 空洞会被写成 1GB 的真实零字节)
cp --sparse=always sparse.img copy.img
cp --sparse=auto sparse.img copy.img # 默认,检测源文件是否稀疏
cp --sparse=never sparse.img copy.img # 把空洞展开成真实的 0
这解释了一个常见事故:虚拟机镜像 cp 之后从 3G 变成 100G,因为默认 --sparse=auto 在某些场景下判断失效,或者用了 cat/dd 这类不理解稀疏性的工具。
4.3 cp 不适合的场景:换成 rsync
cp 有三个天生的短板:没有进度、不能断点续传、不能限速。大文件和跨机场景一律用 rsync:
# 本地大目录复制:带进度、可中断续传
rsync -aH --info=progress2 /data/ /backup/
# ^ ^^ ^^^^^^^^^^^^^^^^ 总体进度(而不是每文件进度)
# | +- H = --hard-links 保留硬链接关系(cp -a 也做不到跨文件保持!)
# +---- a = archive,等价于 -rlptgoD
# ⚠️ 源路径末尾斜杠的差别(rsync 最著名的坑)
rsync -a /data /backup/ # 变成 /backup/data/... (带上目录本身)
rsync -a /data/ /backup/ # 变成 /backup/... (只同步内容)
# 镜像同步:删掉目标里源没有的文件
rsync -a --delete /data/ /backup/
# 💀 危险:如果 /data/ 打错或挂载丢失变成空目录,会把 /backup 清空
rsync -a --delete --dry-run /data/ /backup/ | head # 先 dry-run
rsync -a --delete --max-delete=100 /data/ /backup/ # 超过 100 个删除就中止
# 限速,避免打满生产带宽和磁盘 IO
rsync -a --bwlimit=50M /data/ backup-host:/backup/
# 按内容校验而不是按 mtime+size(慢,但能查出静默损坏)
rsync -avc /data/ /backup/
# 只同步某类文件
rsync -av --include='*/' --include='*.go' --exclude='*' src/ dst/
cp vs rsync 的选择:单个/少量文件用 cp,目录树、大文件、跨机、需要可重试用 rsync。
5. 移动与重命名:为什么有时瞬间有时很慢
回到开篇第二个问题:mv 一个 20G 文件,有时瞬间完成,有时要几分钟。
mv 内部有两条完全不同的路径:
# 路径一:同一个文件系统内 -> 调 rename(),只改目录项
strace -e trace=rename,renameat2,unlink mv /data/big.img /data/backup/big.img
# renameat2(AT_FDCWD, "/data/big.img", AT_FDCWD, "/data/backup/big.img", 0) = 0
# 就这一个系统调用,20G 也是瞬间
# 路径二:跨文件系统 -> 退化成"复制 + 删除"
strace -e trace=renameat2,openat,unlink mv /data/big.img /mnt/nfs/big.img
# renameat2(...) = -1 EXDEV (Invalid cross-device link) <- 先试 rename,失败
# openat(...) = 3 <- 然后老老实实读写
# ... 20G 的 read/write ...
# unlink("/data/big.img") = 0 <- 最后删源文件
判断在不在同一个文件系统,看 df 的挂载点:
df /data /mnt/nfs
# Filesystem 1K-blocks Used Available Use% Mounted on
# /dev/vdb1 104857600 52428800 52428800 50% /data
# nfs-server:/x 524288000 262144000 262144000 50% /mnt/nfs
# ^^^^^^^^ 不同挂载点 = 不同文件系统
# 或者直接比设备号
stat -c '%d' /data /mnt/nfs # 数字不同就是跨文件系统
推论(很重要):跨文件系统的 mv 不是原子的,中途被中断会留下一个不完整的目标文件,而源文件还在。 所以脚本里跨设备搬运大文件,宁可用 rsync + 校验 + 手动删源,也不要指望 mv 的原子性。
5.1 rename() 的原子性:原子写文件的标准套路
rename() 的原子性是个很有价值的保证:要么完全成功,要么完全没发生,读者永远不会看到"半个文件"。 而且如果目标已存在,替换也是原子的——旧 inode 在最后一刻才被解引用。
这直接给出了「安全更新一个配置文件」的标准做法:
# ❌ 危险做法:直接重定向写
cat template.yaml | envsubst > /etc/app/config.yaml
# 问题:> 会先把文件截断成 0 字节再写。如果这期间:
# - 服务正好重新加载配置 -> 读到空文件或半个文件
# - 磁盘满了/进程被杀 -> 配置永久损坏
# ✅ 正确做法:写临时文件 -> 落盘 -> 原子 rename
tmp=$(mktemp /etc/app/config.yaml.XXXXXX) # ⚠️ 临时文件必须在【同一个文件系统】
# 否则 rename 会 EXDEV 失败
envsubst < template.yaml > "$tmp"
chmod 644 "$tmp" # mktemp 默认 600,按需修正
sync -f "$tmp" # 或用 python/go 的 fsync,确保数据真落盘
mv -f "$tmp" /etc/app/config.yaml # 原子替换
Go 里写这个套路是这样(服务端常用,配置热更新、缓存文件落盘都靠它):
func atomicWrite(path string, data []byte, perm os.FileMode) error {
dir := filepath.Dir(path)
// 临时文件必须与目标同目录,保证同文件系统,否则 Rename 会失败
f, err := os.CreateTemp(dir, filepath.Base(path)+".tmp*")
if err != nil {
return err
}
tmp := f.Name()
defer os.Remove(tmp) // 失败时清理残留
if _, err := f.Write(data); err != nil {
f.Close()
return err
}
if err := f.Chmod(perm); err != nil {
f.Close()
return err
}
// 关键:Rename 只保证目录项原子,不保证数据已落盘。
// 不 Sync 的话,掉电后可能出现"文件名在了但内容是空的"
if err := f.Sync(); err != nil {
f.Close()
return err
}
if err := f.Close(); err != nil {
return err
}
return os.Rename(tmp, path) // 原子替换
}
注释里那个 Sync 很多人会省掉,但它是 ext4 历史上一场著名事故的教训——rename 的元数据操作和数据写入是两条独立路径,只 rename 不 fsync,掉电后能得到一个空文件。这段历史(以及 ext3 到 ext4 的 delayed allocation 争议)在第 30 篇 文件系统里讲。
5.2 mv 的参数与坑
# 目标是已存在的目录时,mv 会"挪进去"而不是"替换掉"
mv link_new current # 如果 current 是目录,变成 current/link_new ❌
mv -T link_new current # -T = --no-target-directory,强制当成文件替换 ✅
mv -n src dst # n = no-clobber,目标存在就不动
mv -b src dst # b = backup,覆盖前把目标存成 dst~
mv -u src dst # u = update,只在源更新时才移动
mv -v src dst # v = verbose,打印做了什么
mv -i src dst # i = interactive
# ⚠️ 参数太多导致 mv 失败
mv /data/logs/*.log /archive/
# bash: /usr/bin/mv: Argument list too long
# 原因:shell 展开出几十万个参数,超过内核的 ARG_MAX 限制
getconf ARG_MAX # 2097152 字节
# 解法一:用 find + xargs 分批
find /data/logs -maxdepth 1 -name '*.log' -print0 | xargs -0 mv -t /archive/
# ^^^^^^^^ ^^ ^^ -t 指定目标目录
# 用 \0 分隔,文件名带空格也安全
# 解法二:find -exec 的 + 形式(自动分批,比 \; 快几个数量级)
find /data/logs -maxdepth 1 -name '*.log' -exec mv -t /archive/ {} +
# ^ + 表示尽量多攒几个再执行一次
6. 三个时间戳与 touch
stat 里那三个时间(外加一个 Birth)经常被搞混,尤其是 ctime 不是创建时间。
| 名称 | 全称 | 什么时候变 | ls 怎么看 |
|---|---|---|---|
| atime | access time | 文件内容被读(cat、grep) |
ls -lu |
| mtime | modify time | 文件内容被改(写入、截断) | ls -l(默认) |
| ctime | change time(不是 create!) | inode 被改:改内容、改权限、改属主、改硬链接数 | ls -lc |
| btime | birth time | 文件创建时间,之后永不变 | stat 的 Birth(需 statx + ext4/xfs/btrfs) |
验证一下 ctime 的语义:
touch f && stat -c 'mtime=%y ctime=%z' f
# mtime=2026-08-13 21:00:00 ctime=2026-08-13 21:00:00
sleep 2 && chmod 600 f # 只改权限,不碰内容
stat -c 'mtime=%y ctime=%z' f
# mtime=2026-08-13 21:00:00 ctime=2026-08-13 21:00:02
# ^^^^^^^^ 没变 ^^^^^^^^ 变了
用途上的关键区别:ctime 无法被普通手段伪造。 touch -d 能任意设置 atime/mtime,但改完之后 ctime 一定会变成"现在"。所以入侵检测(如 AIDE、tripwire)都会检查 ctime——攻击者可以把木马的 mtime 改回一年前,但 ctime 会暴露它昨天被动过。
# 攻击者的做法
touch -d '2024-01-01 00:00:00' /usr/bin/backdoor
stat -c 'mtime=%y ctime=%z' /usr/bin/backdoor
# mtime=2024-01-01 00:00:00 <- 伪造成功
# ctime=2026-08-13 21:05:33 <- 藏不住
6.1 atime 为什么默认是 relatime
atime 有一个致命的设计问题:读文件也要写磁盘。每次 cat 一个文件,内核都得更新它的 atime,也就是一次 inode 写入。对于「读多写少」的负载(大部分服务都是),这意味着大量本可以避免的写 IO,SSD 上还额外消耗寿命。
Linux 的演进给出了三档选择,现在默认是中间那档:
mount | grep ' / '
# /dev/vda1 on / type ext4 (rw,relatime)
# ^^^^^^^^^ 现代默认
# 三种挂载选项
strictatime # 严格按 POSIX:每次读都更新 atime(性能最差,几乎没人用)
relatime # 只在以下情况更新:atime 比 mtime/ctime 旧,或 atime 超过 24 小时
# -> 既能满足"判断文件最近有没有被读过"的需求,又几乎没有写放大
noatime # 完全不更新 atime(性能最好)
nodiratime # 只对目录不更新
# 数据库/日志服务器上常见的优化
mount -o remount,noatime /data
# 持久化到 /etc/fstab
# UUID=xxx /data ext4 defaults,noatime 0 2
⚠️ 用 noatime 前确认没有依赖 atime 的程序——mutt 判断邮件是否已读、某些缓存清理脚本(find -atime +30 -delete)会失效。
6.2 touch 的参数
touch f # 文件不存在就创建(空文件),存在就把 atime+mtime 设为现在
touch -c f # c = --no-create,不存在时不创建(只想改时间戳)
touch -a f # 只改 atime
touch -m f # 只改 mtime
touch -d '2026-01-01 10:00' f # d = --date,接受自然语言
touch -d '-2 hours' f # 两小时前
touch -t 202601011000.30 f # t = 时间戳格式 [[CC]YY]MMDDhhmm[.ss]
touch -r ref.txt f # r = --reference,把 f 的时间戳设成和 ref.txt 一样
touch -h link # h = --no-dereference,改软链接自己的时间戳而不是目标的
# 实用场景:让构建系统认为文件没变(跳过重编译)
touch -r main.go.bak main.go
# 实用场景:批量伪造/统一时间戳,让 tar 包内容可复现(reproducible build)
find dist -exec touch -d '2026-01-01 00:00:00' {} +
6.3 find 的时间条件(连带一个高频坑)
find /var/log -mtime +7 # 修改时间超过 7 天
find /var/log -mtime -1 # 修改时间在 1 天内
find /var/log -mtime 7 # 恰好第 7 天那一天(很少用,容易误解)
find /var/log -mmin +60 # 超过 60 分钟(分钟粒度)
find /var/log -newer ref.txt # 比 ref.txt 新
find /var/log -newermt '2026-08-01' # 比这个时刻新(比 -mtime 直观得多,推荐)
# ⚠️ -mtime +7 的坑:它是【向下取整】的整数天比较
# 内核算法:age = (now - mtime) / 86400,取整后与 7 比较
# "7.9 天前"的文件 age 取整为 7,不满足 +7(要求 > 7),不会被匹配到!
# 所以 "-mtime +7" 实际是 "超过 8 天"
# 想精确表达"7 天前",用 -newermt 的反向
find /var/log -type f ! -newermt '7 days ago' -delete
# ^^^^^^^^^^^^^^^^^^^^^^^ 不比"7天前"新 = 7 天前或更早,精确到秒
find 的完整表达式求值模型(为什么 -o 和 -delete 混用会误删)在第 07 篇 查找里专门讲。
7. 目录遍历的性能:为什么 ls 会卡住
回到开篇第四个问题:ls 一个百万文件的目录卡十几秒,ls -U 却很快。
ls 默认做了两件你没要求的事:
- 把整个目录读进内存并按名字排序(要读完所有条目才能排序,所以第一个字符都要等到最后才输出)
- 为了着色和加类型后缀(
-F),对每个文件额外调用一次stat(一次系统调用)
100 万文件 = 100 万次 stat + 一次百万元素排序。所以:
# 慢:默认行为
time ls /data/millions/ > /dev/null
# real 0m14.3s
# 快:不排序 + 不着色 + 不 stat
time ls -U --color=never /data/millions/ > /dev/null
# real 0m1.2s
# ^^ U = 不排序,按目录里的物理顺序输出(边读边输出)
# 更快:ls -f 等价于 -aU 且关闭着色
time ls -f /data/millions/ > /dev/null
# real 0m0.9s
# 只要个数,别用 ls | wc -l(要构建全部输出),用这个
find /data/millions -maxdepth 1 -type f -printf '.' | wc -c
# 或者更快的(读目录但完全不 stat)
getfattr -h /dev/null 2>/dev/null; \
python3 -c "import os,sys; print(sum(1 for _ in os.scandir(sys.argv[1])))" /data/millions
7.1 删除百万小文件
rm -rf 在超大目录上会显得"卡死"(其实在跑,只是慢),几个更好的方案:
# ① find -delete:不需要构建参数列表,边遍历边删
find /data/cache -type f -delete
# ② 并行删(IO 允许时能快数倍)
find /data/cache -type f -print0 | xargs -0 -P 8 -n 1000 rm -f
# ^^^^ 8 个并发 ^^^^^^^ 每批 1000 个
# ③ 最快的做法:如果整个目录都不要了,用 rsync 的空目录覆盖
mkdir /tmp/empty
rsync -a --delete /tmp/empty/ /data/cache/
# 比 rm -rf 快,因为 rsync 的批量 unlink 模式对目录项缓存更友好
# ④ 核弹级:如果这个目录是独占一个文件系统的,直接重建文件系统
umount /data/cache && mkfs.ext4 /dev/vdc1 && mount /data/cache
# 从 O(n) 变成 O(1)
7.2 目录不会自动收缩
一个反直觉的现象:ext4 的目录只增不减。
mkdir big && touch big/{1..500000} # 建 50 万个文件
ls -ld big
# drwxr-xr-x 2 lhx lhx 11493376 Aug 13 21:30 big <- 目录本身 11MB
rm -f big/*
ls -ld big
# drwxr-xr-x 2 lhx lhx 11493376 Aug 13 21:31 big <- 删空了,还是 11MB!
目录项被标记为空闲但空间不归还。这带来一个真实的性能后果:一个曾经装过百万文件的空目录,遍历它仍然要扫 11MB 的目录块,ls 依然慢。修复方式只有重建目录:
mv big big.old && mkdir big && rmdir big.old
(xfs 会做一定程度的回收,btrfs 不存在这个问题。这属于 ext4 的 htree 目录索引设计取舍,第 30 篇会讲。)
8. 文件名:比你想象的宽松得多
Linux 对文件名的限制只有两条:
- 不能含
/(它是路径分隔符) - 不能含
\0(C 字符串的结束符)
其他任何字节都是合法的——空格、换行、制表符、引号、星号、中文、emoji,甚至以 - 开头。这份宽松是无数脚本 bug 的来源。
# 造几个"合法但恶劣"的文件名
touch 'a b.txt' # 空格
touch $'line1\nline2.txt' # 换行!
touch -- '-rf' # 以横杠开头
touch '*' # 就叫星号
touch 'x;rm -rf /tmp/y' # 带分号
ls
# '-rf' '*' 'a b.txt' 'line1'$'\n''line2.txt' 'x;rm -rf /tmp/y'
# ^ 现代 ls 会自动加引号提示你(GNU coreutils 8.25+)
8.1 三条防御规则
# 规则一:所有变量都加双引号
f="a b.txt"
rm $f # ❌ 展开成 rm a b.txt,删两个文件(都不存在,报错)
rm "$f" # ✅
# 规则二:不要解析 ls 的输出,用 glob 或 find -print0
for f in $(ls *.log); do gzip "$f"; done # ❌ 空格/换行直接崩
for f in *.log; do gzip "$f"; done # ✅ shell glob 保持完整
find . -name '*.log' -print0 | xargs -0 gzip # ✅ \0 分隔,唯一安全的管道方案
# ^^^^^^^ ^^
# 用 \0 而不是换行分隔 xargs 按 \0 切分
# 规则三:路径前加 ./ 或用 -- 隔断选项
rm -- "$f" # 变量内容以 - 开头时,避免被当成选项
rm ./"$f" # 同效
while read 循环也有同样的问题,正确写法是:
# ✅ 处理任意文件名的标准循环
while IFS= read -r -d '' f; do
echo "处理: $f"
done < <(find . -name '*.log' -print0)
# ^^^^ IFS= 防止首尾空格被吃
# -r 防止反斜杠被转义
# -d '' 以 \0 为分隔符
8.2 大小写与隐藏文件
# Linux 的文件系统默认大小写敏感(macOS 默认不敏感,这是跨平台踩坑重灾区)
touch README readme
ls
# README readme <- 在 Linux 上是两个文件,在 macOS 上是一个
# "隐藏文件"没有任何特殊属性,只是名字以 . 开头,而 ls 默认跳过它们
ls -a # 显示全部,含 . 和 ..
ls -A # 显示全部,不含 . 和 ..(脚本里更常用)
# ⚠️ 所以 cp/mv/rm 的通配符也不会匹配隐藏文件
cp -r src/* dst/ # .env、.git 不会被复制!
cp -a src/. dst/ # ✅ 用 src/. 包含隐藏文件(推荐)
rsync -a src/ dst/ # ✅ rsync 天然包含
9. du 与 df 为什么会不一致
这是磁盘排查最常见的困惑:du -sh / 说用了 20G,df 说用了 45G。差出来的 25G 在哪?
| 原因 | 说明 | 怎么查 |
|---|---|---|
| 已删除但仍被打开的文件 | 最常见。du 遍历目录看不到它,df 统计块使用量能看到 |
lsof +L1 |
| 文件系统保留块 | ext4 默认给 root 预留 5%,普通用户不可用但 df 算作已用 |
tune2fs -l /dev/vda1 | grep Reserved |
| du 统计的是 apparent 还是实际 | 稀疏文件、压缩文件系统上两者不同 | du --apparent-size 对比 |
| 被挂载遮盖的目录 | /data 下原有文件,之后在 /data 挂了新盘,老文件仍占空间但 du /data 看不到 |
临时 mount --bind / /mnt 后查看 |
| inode 用尽(不是空间问题) | 空间还有但创建文件报 No space left | df -i |
| du 权限不足跳过了目录 | 非 root 跑 du / 会漏掉读不了的目录 |
用 sudo du |
排查顺序:
# ① 先确认是空间还是 inode
df -h /data
df -i /data
# Filesystem Inodes IUsed IFree IUse% Mounted on
# /dev/vdb1 6553600 6553600 0 100% /data <- inode 用尽!空间可能还很多
# ② 空间问题:找大目录(-x 不跨文件系统,避免把别的盘算进来)
du -xh --max-depth=1 / 2>/dev/null | sort -h | tail -10
# ^ x = --one-file-system
# ③ 找大文件
find /data -xdev -type f -size +1G -exec ls -lh {} + 2>/dev/null
# ^^^^^ 等价于 du 的 -x
# ④ 查已删除仍被占用的
sudo lsof +L1 | awk '{print $7, $9, $10}' | sort -rn | head
# ⑤ 查被挂载遮盖的文件
sudo mount --bind / /mnt && du -sh /mnt/data && sudo umount /mnt
# ^^^^^^ 把根重新挂到 /mnt,这样能看到被遮盖的原始内容
# ⑥ inode 问题:找出小文件最多的目录
for d in /data/*; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head
9.1 安全删除:shred 与它的局限
rm 只是解除链接,数据块还在盘上。要真正覆写:
shred -vzun 3 secret.key
# | | | +- n 3 = 覆写 3 遍(默认 3 遍,早期版本 25 遍)
# | | +--- u = 覆写后删除文件
# | +----- z = 最后再写一遍 0,掩盖"这里被 shred 过"的痕迹
# +------- v = 显示进度
但在两类介质上 shred 是不可靠的,必须知道:
- SSD / NVMe / U 盘:闪存有磨损均衡(wear leveling),你写的"覆盖"会被 FTL 映射到另一块物理单元,原数据还留在旧单元里。正确做法是全盘
blkdiscard、厂商的 Secure Erase(nvme format -s1),或者从一开始就全盘加密(LUKS),销毁时只销毁密钥。 - CoW / 日志文件系统:btrfs、ZFS 以及 ext4 的
data=journal模式下,覆写会写到新位置。而且如果有快照,旧数据仍然可以被读出来。
# 全盘丢弃(SSD 上正确的擦除方式,会丢掉所有数据,务必确认设备名)
sudo blkdiscard -s /dev/nvme0n1 # -s = secure
# 更实际的方案:一开始就加密,销毁时销毁 header 即可
sudo cryptsetup luksErase /dev/vdb1 # 擦掉所有 keyslot,数据永久不可读
10. 知识点扩展
这一章把本篇涉及的命令系统整理一遍,每个命令按「全称 → 作用 → 语法 → 参数表 → 输出解读 → 常用场景 → 坑」的顺序写,可以当字典查。
10.1 ls — list
全称:ls = list(list directory contents)
作用:列出目录内容,或显示指定文件的信息。它的实际动作是:读取目录这张「名字 → inode」映射表得到条目列表,然后根据需要对每个条目调用 stat 取元数据(这是它在大目录上慢的根源),最后排序输出。
语法:
ls [选项]... [文件或目录]...
# 不给参数 = 列出当前目录;给目录 = 列出里面内容;给文件 = 显示这个文件
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-l |
— | long listing format | 长格式,显示权限/属主/大小/时间 |
-h |
--human-readable |
human readable | 大小带单位(K/M/G),必须配合 -l/-s 才有效 |
-S |
— | sort by Size | 按大小排序,默认就是大→小 |
-t |
— | sort by time | 按 mtime 排序,新→旧 |
-r |
--reverse |
reverse | 反转当前排序结果 |
-a |
--all |
all | 显示隐藏文件,含 . 和 .. |
-A |
--almost-all |
almost all | 显示隐藏文件,不含 . 和 ..(脚本常用) |
-d |
--directory |
directory | 只显示目录本身,不展开内容 |
-i |
--inode |
inode number | 显示 inode 号 |
-s |
--size |
size in blocks | 显示每个文件占用的块数 |
-F |
--classify |
classify | 名字后加类型符:/目录 @软链接 *可执行 |管道 =socket |
-p |
— | — | 只给目录加 /(比 -F 干净) |
-n |
--numeric-uid-gid |
numeric uid/gid | 属主属组显示为数字(用户名解析卡住时救命) |
-1 |
— | one file per line | 每行一个,管道友好 |
-R |
--recursive |
recursive | 递归列出子目录 |
-U |
— | unsorted | 不排序,按目录物理顺序输出(大目录救命参数) |
-f |
— | — | 等价 -aU 且关闭着色,最快 |
-X |
— | sort by eXtension | 按扩展名排序 |
-v |
— | version sort | 按版本号自然排序(f2 在 f10 前面) |
-u |
— | use atime | 用 atime 排序/显示(配合 -l 或 -t) |
-c |
— | use ctime | 用 ctime 排序/显示 |
-L |
--dereference |
dereference | 显示软链接指向的目标的信息 |
-Q |
--quote-name |
quote | 名字加双引号 |
-b |
--escape |
escape | 特殊字符用 \ 转义显示(看清诡异文件名) |
| — | --time-style=full-iso |
— | 时间精确到秒 + 时区 |
| — | --group-directories-first |
— | 目录排在文件前面 |
| — | --color=auto|always|never |
— | 着色;auto 在管道中会自动关闭 |
| — | --block-size=M |
— | 指定大小单位 |
输出解读:
total 32 <- 本目录条目占用的块数(1KB 单位),不是文件总大小
-rw-r--r-- 1 lhx dev 1024 Aug 13 21:00 file
^^^^^^^^^^ ^ ^^^ ^^^ ^^^^ ^^^^^^^^^^^^ ^^^^
| | | | | | 文件名
| | | | | mtime(超过 6 个月显示年份而不是时间)
| | | | 大小(字节;目录显示的是目录项数据大小)
| | | 属组
| | 属主
| 硬链接数(目录 = 2 + 直接子目录数)
类型 + 9 位权限
类型位:- 普通文件 d 目录 l 软链接 c 字符设备 b 块设备 s socket p 命名管道
常用场景:
ls -lhS # 按大小倒序(大→小),带单位 ← 最常用
ls -lhSr | head # 最小的几个
ls -lhS | head -6 # 最大的 5 个(第一行是 total)
ls -lt --time-style=full-iso | head # 最近改动的文件,精确到秒(查"谁动了配置")
ls -lct | head # 按 ctime 排(查权限/属主最近被改的)
ls -ld */ # 只列出目录
ls -ldh -- */ | sort -k5 -h # 目录按大小排(注意是目录项大小)
ls -1 | wc -l # 数条目(不含隐藏)
ls -A | wc -l # 数条目(含隐藏)
ls -i file # 看 inode 号
ls -li *.log # 批量看 inode,找硬链接
ls --group-directories-first -lh # 目录优先,日常看着舒服
ls -lh /proc/1234/fd/ # 看某进程打开的文件(含 deleted 标记)
ls -f /data/millions/ # 百万文件目录的唯一可用姿势
ls -lb 'weird'* # 用转义看清含特殊字符的文件名
坑:
# ① 永远不要解析 ls 的输出(文件名可含空格与换行)
for f in $(ls *.log); do ...; done # ❌
for f in *.log; do ...; done # ✅
# ② -h 单独用无效
ls -h # 什么都不会变,必须 ls -lh
# ③ -S / -t 只对当前目录生效,不递归
ls -lSR # 是"每个子目录内部各自排序",不是全局排序
find . -type f -printf '%s\t%p\n' | sort -rn | head # ✅ 全局找最大文件
# ④ ls -l 的 total 是块数不是字节数,也不含子目录内容
10.2 stat — file status
全称:stat = file status(display file or file system status)
作用:把 inode 里的元数据完整打印出来。ls -l 只是它的一个精简视图——stat 能看到 ls 看不到的东西:inode 号、占用块数、设备号、三个时间戳的纳秒精度、创建时间。
语法:
stat [选项]... 文件...
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-c FMT |
--format=FMT |
format | 自定义输出格式,每个参数后自动换行 |
| — | --printf=FMT |
— | 同上但不自动换行,支持 \n \t 转义 |
-f |
--file-system |
file system | 显示文件所在文件系统的信息,而不是文件本身 |
-L |
--dereference |
dereference | 跟进软链接,显示目标的信息(默认不跟进) |
-t |
--terse |
terse | 单行紧凑输出,全部字段用空格分隔,脚本用 |
文件格式符(-c):
| 符 | 含义 | 符 | 含义 |
|---|---|---|---|
%n |
文件名 | %N |
带引号的名字,软链接显示 -> 目标 |
%i |
inode 号 | %h |
硬链接数 |
%s |
大小(字节,表观) | %b |
占用的块数 |
%B |
一个块的字节数(%b×%B = 实际占用) |
%o |
最佳 IO 块大小 |
%d / %D |
设备号(十进制/十六进制) | %t / %T |
设备的主/次号(仅设备文件) |
%a / %A |
权限(八进制/符号) | %F |
文件类型描述(regular file、directory…) |
%u / %U |
uid / 属主名 | %g / %G |
gid / 属组名 |
%x / %X |
atime(可读/秒) | %y / %Y |
mtime(可读/秒) |
%z / %Z |
ctime(inode 变更,不是创建) | %w / %W |
btime 创建时间(- 表示不支持) |
文件系统格式符(-f -c):
| 符 | 含义 | 符 | 含义 |
|---|---|---|---|
%n |
路径 | %T |
文件系统类型名(ext4/xfs/tmpfs) |
%i |
文件系统 ID | %S |
块大小 |
%b / %f / %a |
总块/空闲块/非 root 可用块 | %c / %d |
总 inode / 空闲 inode |
常用场景:
stat file # 全部信息
stat -c '%i %h %s %a %U %G %n' file # inode 链接数 大小 权限 属主 属组 名字
stat -c '%a' file # 只要八进制权限(脚本里判断权限)
stat -c '%Y' file # mtime 的 Unix 秒(做时间差计算)
stat -c '%s' file # 只要字节数(比 ls|awk 可靠)
stat -c '%d' /a /b # 比设备号,判断是否同一文件系统(能否硬链接/原子 rename)
stat -c '%h' /etc # 链接数 - 2 = 直接子目录数
stat -c '%N' link # 看软链接指向哪(等价 ls -l 的箭头)
stat -c 'mtime=%y ctime=%z btime=%w' file # 三个关键时间一起看
stat -f -c '%T 块大小=%S 可用=%a/%b inode=%d/%c' /data # 文件系统概况
stat --printf='%n\t%s\t%a\n' /etc/*.conf # 批量制表符输出,可直接喂给 awk
# 检查一批文件的权限是否符合预期(配置巡检)
stat -c '%a %n' /etc/ssh/ssh_host_*_key | grep -v '^600'
坑:
# ① %z 是 ctime(change time,inode 变更),不是 create time
# 创建时间是 %w / %W,需要 statx() + ext4/xfs/btrfs 支持,老内核显示 -
# ② stat 默认【不】跟进软链接,看的是链接自身(大小 = 路径字符数)
stat link # Size: 23 regular... 其实是 symbolic link
stat -L link # 才是目标文件的信息
# ③ -c 每个参数自动换行,批量输出想控制格式要用 --printf
stat -c '%s' a b # 两行
stat --printf='%s ' a b # 一行,空格分隔
# ④ macOS/BSD 的 stat 参数完全不同(-f 是格式化而不是文件系统),脚本注意可移植性
10.3 cp — copy
全称:cp = copy(copy files and directories)
作用:创建一份新的数据副本——分配新 inode、复制内容。所以它天生比 mv(改目录项)慢,且默认不保留元数据。
语法:
cp [选项]... 源... 目标
cp [选项]... -t 目标目录 源... # 目标在前的写法,配合 xargs
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-r / -R |
--recursive |
recursive | 递归复制目录 |
-a |
--archive |
archive | = -dR --preserve=all,备份/迁移必用 |
-p |
--preserve |
preserve | 保留 mode、ownership、timestamps |
| — | --preserve=LIST |
— | 精确指定保留项:mode,ownership,timestamps,links,xattr,context,all |
| — | --no-preserve=LIST |
— | 排除某些保留项 |
-d |
--no-dereference --preserve=links |
— | 软链接照抄成软链接 |
-L |
--dereference |
dereference | 跟进软链接,把目标内容复制过来 |
-P |
--no-dereference |
— | 不跟进软链接(-a 隐含) |
-l |
--link |
hard link | 用硬链接代替复制(瞬间完成、不占空间) |
-s |
--symbolic-link |
symbolic link | 创建软链接代替复制 |
-u |
--update |
update | 仅当源比目标新(或目标不存在)才复制 |
-n |
--no-clobber |
no clobber | 目标已存在则跳过 |
-i |
--interactive |
interactive | 覆盖前询问 |
-f |
--force |
force | 目标打不开时先删除再复制 |
-b |
--backup[=CONTROL] |
backup | 覆盖前把目标备份成 dst~ |
-S SUF |
--suffix=SUF |
suffix | 指定备份后缀 |
-v |
--verbose |
verbose | 打印每个操作 |
-t DIR |
--target-directory |
target | 显式指定目标目录 |
-T |
--no-target-directory |
— | 目标当成文件而非目录 |
-x |
--one-file-system |
— | 不跨越文件系统边界(备份根目录必用) |
| — | --reflink=always|auto|never |
— | CoW 秒级复制(btrfs/xfs reflink=1) |
| — | --sparse=always|auto|never |
— | 稀疏文件处理策略 |
-Z |
--context |
SELinux context | 设置 SELinux 安全上下文 |
常用场景:
cp -a src/. dst/ # 完整复制目录内容(含隐藏文件) ← 最稳的写法
cp -a src dst # 把 src 整个目录复制成 dst
cp -av src/ dst/ | tail # 带过程输出
cp -p index.html /var/www/ # 只保留时间戳(让 Last-Modified 不变)
cp -al snap_day1 snap_day2 # 硬链接式快照,秒完成、几乎不占空间
cp --reflink=auto big.img copy.img # CoW 文件系统上秒复制大文件
cp --sparse=always disk.img backup.img # 保持稀疏,避免 3G 变 100G
cp -x -a / /mnt/backup/ # 备份根,自动跳过 /proc /sys /dev 等挂载点
cp -u -r src/ dst/ # 增量同步(简易版,只比 mtime)
cp --backup=numbered app app.new # 保留多个编号备份 app.~1~ app.~2~
find . -name '*.go' -print0 | xargs -0 cp -t /tmp/gofiles/ # 批量复制到一个目录
坑:
# ① 默认 cp 会丢属主、属组、时间戳、ACL、xattr —— 迁移必须 -a
# ② cp -r src/* dst/ 不会复制隐藏文件(.env、.git 全丢)
cp -a src/. dst/ # ✅ 用 /. 结尾
# ③ 很多发行版给 root 配了 alias cp='cp -i',脚本里会卡住等输入
\cp -a src dst # ✅ 反斜杠绕过 alias
command cp -a src dst # ✅ 或者用 command
# ④ -u 只比 mtime 和大小,不比内容。要按内容判断得用 rsync -c
# ⑤ 目标是已存在目录时,cp file dir 会放进去;cp -T file dir 才是替换
10.4 mv — move
全称:mv = move(move / rename files)
作用:移动或重命名。同一文件系统内只调一次 rename()(改目录项,原子且与文件大小无关);跨文件系统则退化成「复制 + 删除源」(非原子、慢)。
语法:
mv [选项]... 源... 目标
mv [选项]... -t 目标目录 源...
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-f |
--force |
force | 直接覆盖不询问(默认行为,除非有 -i alias) |
-n |
--no-clobber |
no clobber | 目标存在则跳过 |
-i |
--interactive |
interactive | 覆盖前询问 |
-u |
--update |
update | 仅当源更新才移动 |
-b |
--backup[=CONTROL] |
backup | 覆盖前备份目标 |
-S SUF |
--suffix |
suffix | 备份后缀 |
-v |
--verbose |
verbose | 打印操作 |
-t DIR |
--target-directory |
target | 指定目标目录(配合 xargs 必用) |
-T |
--no-target-directory |
— | 目标当文件处理(切换软链接必用) |
-Z |
--context |
— | 恢复 SELinux 默认上下文 |
| — | --strip-trailing-slashes |
— | 去掉源路径末尾斜杠 |
常用场景:
mv old.txt new.txt # 重命名
mv *.log /archive/ # 批量移动(注意 ARG_MAX 限制)
mv -T current.tmp current # 原子替换软链接/文件(零窗口切换)
mv -b config.yaml config.yaml.new # 覆盖前留备份
mv -n src/* dst/ # 不覆盖已有的
find /logs -name '*.gz' -print0 | xargs -0 mv -t /archive/ # 海量文件搬移
rename 's/\.txt$/.md/' *.txt # 批量改名用 rename(perl 版)而不是 mv
for f in *.jpeg; do mv -- "$f" "${f%.jpeg}.jpg"; done # 或用 shell 参数展开
坑:
# ① 跨文件系统的 mv 不是原子的,中断会留半个文件;先确认是否同一 fs
stat -c '%d' /data/f /mnt/other # 设备号相同才是同一文件系统
# ② 目标是目录时会"挪进去"而不是"替换掉"
mv new_link current # current 是目录 -> 变成 current/new_link ❌
mv -T new_link current # ✅
# ③ 参数太多导致失败
mv /logs/*.log /archive/
# bash: /usr/bin/mv: Argument list too long (超过 ARG_MAX,见 getconf ARG_MAX)
find /logs -maxdepth 1 -name '*.log' -exec mv -t /archive/ {} + # ✅
# ④ mv 不能像 cp 那样保留"两份";也不支持进度显示,大文件跨盘搬运用 rsync --remove-source-files
rsync -a --info=progress2 --remove-source-files /data/big/ /mnt/big/
10.5 rm / rmdir — remove
全称:rm = remove(remove files or directories);rmdir = remove directory
作用:rm 调用的是 unlink()/unlinkat()——从目录里划掉一行并把 inode 链接计数 -1,不是"擦除数据"。数据块要等「链接数 == 0 且 打开的 fd 数 == 0」才回收。rmdir 调用 rmdir(),只能删空目录(更安全,脚本里适合做"确认目录已清空"的断言)。
语法:
rm [选项]... 文件...
rmdir [选项]... 目录...
rm 参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-r / -R |
--recursive |
recursive | 递归删除目录及内容 |
-f |
--force |
force | 忽略不存在的文件、不询问、不因权限提示 |
-i |
--interactive |
interactive | 每个文件都询问 |
-I |
--interactive=once |
— | 删 3 个以上或递归时只问一次(日常最实用) |
-d |
--dir |
dir | 删除空目录(等价 rmdir) |
-v |
--verbose |
verbose | 打印删了什么 |
| — | --one-file-system |
— | 递归时不跨文件系统(防止误删挂载盘内容) |
| — | --preserve-root |
— | 拒绝对 / 操作(默认开启,但拦不住 /*) |
| — | --no-preserve-root |
— | 解除对 / 的保护(几乎没有正当用途) |
-- |
— | — | 后面全部当文件名,用于处理 - 开头的文件 |
rmdir 参数表:
| 短 | 长 | 作用 |
|---|---|---|
-p |
--parents |
连带删除变空的父目录(rmdir -p a/b/c 删 c、b、a) |
| — | --ignore-fail-on-non-empty |
非空时不报错(批量清理空目录时用) |
-v |
--verbose |
打印操作 |
常用场景:
rm -I -r build/ # 递归删除,只确认一次
rm -rf -- "$dir" # 变量可能以 - 开头时用 --
rm -- -rf # 删掉名字叫 "-rf" 的文件
rm ./-rf # 同上,另一种写法
rm -rf /opt/app/{*,.[!.]*} # 连隐藏文件一起删(.[!.]* 不会匹配 . 和 ..)
rm --one-file-system -rf /mnt/target/ # 目标里有挂载点时防止误伤
find /tmp/cache -type f -mtime +7 -delete # 按条件删(推荐替代通配符)
find /data -depth -type d -empty -delete # 清理所有空目录
find /data -type d -empty -exec rmdir -v {} + # 同上,显式版
find /cache -type f -print0 | xargs -0 -P8 -n1000 rm -f # 并行删百万小文件
坑:
# ① 变量为空 = 删库
DIR=""
rm -rf "$DIR"/* # 展开成 rm -rf /* 💀
rm -rf "${DIR:?DIR must be set}"/* # ✅ 空值直接报错退出
[[ -n "$DIR" && -d "$DIR" ]] || exit 1 # ✅ 显式校验
# ② --preserve-root 只拦 / 本身,不拦 /*
rm -rf / # 被拦
rm -rf /* # 不拦!
# ③ 通配符不含隐藏文件(.git、.env 会残留)
# ④ rm 之后空间可能不释放(进程还开着 fd)—— 用截断代替
: > /var/log/app.log
# ⑤ rm 不是"擦除",数据仍在盘上;敏感文件用 shred 或全盘加密(见 10.17)
# ⑥ 别用 alias rm='rm -i'(养成"反正会问"的习惯,换到没 alias 的机器就翻车),用 -I 显式写
10.6 ln / readlink / realpath — link
全称:ln = link(make links between files);readlink = read link(打印软链接的内容);realpath = real path(把路径规范化成绝对真实路径)
作用:ln 不带 -s 时给同一个 inode 再加一个名字(硬链接);带 -s 时新建一个内容为路径字符串的小文件(软链接)。readlink/realpath 是它的反向工具,用来解析链接指向哪。
语法:
ln [选项]... 目标 链接名 # 只给目标,则在当前目录用同名创建
ln [选项]... -t 目录 目标...
readlink [选项] 链接...
realpath [选项] 路径...
ln 参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
| (无) | — | — | 创建硬链接 |
-s |
--symbolic |
symbolic | 创建软链接 |
-f |
--force |
force | 链接名已存在则先删除它 |
-n |
--no-dereference |
— | 链接名是「指向目录的软链接」时,替换它本身而不是钻进目录里创建 |
-r |
--relative |
relative | 自动计算相对路径(配合 -s) |
-T |
--no-target-directory |
— | 链接名当成普通文件处理 |
-t DIR |
--target-directory |
target | 在指定目录里创建链接 |
-b |
--backup |
backup | 覆盖前备份 |
-v |
--verbose |
verbose | 打印操作 |
-P |
--physical |
physical | 硬链接直接指向软链接本身(不解引用) |
-L |
--logical |
logical | 目标是软链接时,硬链接指向它最终指向的文件 |
readlink / realpath 参数表:
| 参数 | 全称 | 作用 |
|---|---|---|
readlink link |
— | 只打印链接内容(一层,不递归) |
readlink -f |
--canonicalize |
递归解析到最终路径,最后一层允许不存在 |
readlink -e |
--canonicalize-existing |
递归解析,所有部分都必须存在 |
readlink -m |
--canonicalize-missing |
递归解析,不检查存在性 |
readlink -n |
--no-newline |
不输出末尾换行(脚本内嵌用) |
realpath 路径 |
— | 等价 readlink -e,但更常用、语义更清楚 |
realpath -m |
--canonicalize-missing |
允许路径不存在 |
realpath -s |
--strip / --no-symlinks |
只做词法规范化(去 ..、.),不解析软链接 |
realpath --relative-to=DIR |
— | 输出相对于 DIR 的路径 |
常用场景:
# 硬链接
ln data.log data.log.bak # 同一份数据两个名字,删一个不丢数据
cp -al snap1 snap2 # 批量硬链接快照
# 软链接:版本切换(部署核心套路)
ln -sfn releases/v1.2.4 current # 三个参数缺一不可:s 软链接 f 覆盖 n 不钻进去
ln -sfn releases/v1.2.4 current.tmp && mv -Tf current.tmp current # 零窗口原子切换
ln -sr /data/conf/app.yaml /etc/app.yaml # 自动算相对路径
ln -s /usr/local/go/bin/go /usr/local/bin/go # 把命令挂到 PATH 里
# 解析
readlink current # releases/v1.2.4 (原始内容,一层)
readlink -f current # /opt/app/releases/v1.2.4 (最终绝对路径)
realpath current/config.yaml # 完整解析
realpath --relative-to=/opt/app /opt/app/releases/v1.2.4 # releases/v1.2.4
readlink -f /proc/1234/exe # 查进程对应的可执行文件真实路径 ← 排查常用
readlink -f "$(which python3)" # 看 python3 最终指向哪个版本
# 脚本里获取自身所在目录的标准写法
SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f "$0")")" && pwd)
# 找出所有悬空软链接
find /opt -xtype l
# 找出所有软链接及其目标
find /etc -type l -printf '%p -> %l\n'
坑:
# ① ln -sf 少写 -n,会在旧目标【里面】创建链接
ln -sf releases/v1.2.4 current # current/v1.2.4 -> ... ❌
ln -sfn releases/v1.2.4 current # ✅
# ② 软链接里的相对路径是相对于【链接所在目录】,不是你的 cwd
cd /tmp && ln -s conf/a.yaml /opt/link # 实际指向 /opt/conf/a.yaml ❌
ln -sr /tmp/conf/a.yaml /opt/link # ✅
# ③ 硬链接不能跨文件系统(EXDEV)、不能指向目录
# ④ 软链接权限恒为 777 且无意义;chmod 改的是目标
# ⑤ tar 默认打包软链接本身,要打包目标内容用 tar -h(--dereference)
# ⑥ readlink 不加 -f 只解析一层,链的中间层不会被展开
10.7 du — disk usage
全称:du = disk usage(estimate file space usage)
作用:递归遍历目录树,把每个文件实际占用的磁盘块累加起来。关键词是"遍历"——所以它 ① 看不到已删除但仍被打开的文件;② 会跳过没有读权限的目录;③ 在大目录上很慢(要 stat 每个 inode);④ 同一个 inode 的多个硬链接只计一次。
语法:
du [选项]... [路径]...
# 不给路径 = 当前目录
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-s |
--summarize |
summarize | 只输出每个参数的总计,不列子目录 |
-h |
--human-readable |
human readable | 带单位(K/M/G),1024 进制 |
| — | --si |
— | 带单位但用 1000 进制 |
-x |
--one-file-system |
— | 不跨文件系统(查 / 占用必用,否则把别的盘算进来) |
-d N |
--max-depth=N |
max depth | 只汇总到第 N 层(-d 0 等价 -s) |
-a |
--all |
all | 每个文件也输出一行(默认只输出目录) |
-c |
--total |
total | 最后追加一行总计 |
| — | --apparent-size |
— | 用表观大小(stat 的 Size)而不是磁盘占用 |
-b |
--bytes |
bytes | 等价 --apparent-size -B1 |
-k / -m |
— | — | 以 KB / MB 为单位输出 |
-B SIZE |
--block-size |
block size | 指定单位,如 -BM |
-L |
--dereference |
dereference | 跟进软链接统计目标大小 |
-D / -H |
--dereference-args |
— | 只跟进命令行上给出的软链接 |
-l |
--count-links |
count links | 硬链接重复计算(默认只算一次) |
-S |
--separate-dirs |
separate dirs | 目录大小不含子目录(各层独立) |
| — | --exclude=PATTERN |
— | 排除匹配的路径 |
-X FILE |
--exclude-from=FILE |
— | 从文件读排除列表 |
| — | --inodes |
— | 统计 inode 数量而不是空间(coreutils 8.22+,查 inode 用尽神器) |
| — | --time |
— | 显示目录里最新的 mtime |
-0 |
--null |
null | 输出以 \0 结尾(配合 xargs -0) |
输出解读:
du -h /var/log
# 4.0K /var/log/private <- 两列:占用大小 + 路径
# 12M /var/log/nginx
# 156M /var/log <- 最后一行是参数本身的累计(含所有子目录)
默认单位是 1KB 块,且是磁盘实际占用(会向上取整到块大小,所以 1 字节的文件也显示 4.0K)。
常用场景:
du -sh /var/log # 一个目录总共多大 ← 最常用
du -sh * # 当前目录下每个条目多大
du -sh * 2>/dev/null | sort -h | tail # 按大小排序,找出最大的 ← 排查第一步
du -xh --max-depth=1 / | sort -h # 逐层下钻找大目录(-x 不跨盘)
du -ah /data | sort -h | tail -20 # 含文件,找最大的 20 个条目
du -sh --exclude='*.log' /app # 排除日志后有多大
du -sh --apparent-size f; du -sh f # 对比表观与实际(识别稀疏文件)
du --inodes -d1 /data | sort -n | tail # 哪个子目录 inode 用得最多 ← inode 用尽时用
du -sh --time /backup/* # 每个备份目录多大 + 最后更新时间
du -sb file # 精确字节数
du -shL /link # 跟进软链接统计
# 找出 inode 消耗大户的另一种写法(老版本没有 --inodes)
for d in /data/*/; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head
坑:
# ① 硬链接默认只算一次 —— 所以硬链接快照目录"各自的和"远大于"总和"
du -sh snap1 snap2 # 40G 1.2G <- 第二个只算了新增部分
du -sh --count-links snap1 snap2 # 40G 40G <- 强制重复计算
# ② 不加 -x 会把挂载在里面的其他盘一起算进去
du -sh / # 可能把 /mnt/nfs 的 10T 算进来
du -xsh / # ✅
# ③ 普通用户跑 du / 会静默跳过读不了的目录,结果偏小
sudo du -xsh / # ✅ 或者 du -xsh / 2>/dev/null 时要意识到有遗漏
# ④ du 看不到"已删除但被打开"的文件 —— 这正是 du 与 df 不一致的最常见原因
sudo lsof +L1 # ✅ 用这个查
# ⑤ du 在百万文件目录上很慢(要 stat 每个 inode),生产上考虑 ionice 降优先级
ionice -c3 nice -n19 du -xsh /data
10.8 df — disk free
全称:df = disk free(report file system disk space usage)
作用:对每个已挂载的文件系统调用一次 statfs(),直接读它超级块里的统计数字。这是 O(1) 查询,不遍历任何文件——所以它 ① 极快;② 能看到已删除但仍被打开的文件占的空间;③ 只能按"文件系统"为粒度,无法告诉你是哪个目录占的。
du 和 df 是互补的:df 回答"哪个文件系统满了",du 回答"里面是谁占的"。排查顺序永远是先 df 后 du。
语法:
df [选项]... [文件或设备]...
# 给一个文件路径 = 显示这个文件所在的文件系统(很实用)
参数表:
| 短 | 长 | 全称 | 作用 |
|---|---|---|---|
-h |
--human-readable |
human readable | 带单位,1024 进制 |
-H |
--si |
— | 带单位,1000 进制(磁盘厂商口径) |
-i |
--inodes |
inodes | 显示 inode 使用量而不是空间 |
-T |
--print-type |
print type | 增加一列显示文件系统类型 |
-t TYPE |
--type=TYPE |
type | 只显示指定类型 |
-x TYPE |
--exclude-type=TYPE |
exclude type | 排除指定类型(-x tmpfs -x overlay) |
-a |
--all |
all | 包含伪文件系统(proc、sysfs 等,默认隐藏) |
-l |
--local |
local | 只显示本地文件系统(跳过 NFS,避免卡住) |
-k / -m |
— | — | 以 1KB / 1MB 块为单位 |
-B SIZE |
--block-size |
block size | 指定块大小 |
-P |
--portability |
POSIX portability | POSIX 格式,强制一行输出(长设备名不换行,脚本必用) |
| — | --total |
— | 追加总计行 |
| — | --output=FIELD,... |
— | 自定义列:source,fstype,itotal,iused,iavail,ipcent,size,used,avail,pcent,file,target |
| — | --sync / --no-sync |
— | 取数据前是否先 sync(默认不 sync,更快) |
输出解读:
df -hT /data
# Filesystem Type Size Used Avail Use% Mounted on
# /dev/vdb1 ext4 50G 45G 2.5G 95% /data
# ^^^^^^^^ ^^^^ ^^^ ^^^ ^^^^ ^^^ ^^^^^^^^^
# | | | | | | 挂载点
# | | | | | 已用百分比 = Used/(Used+Avail),不是 Used/Size
# | | | | 非 root 用户还能用多少
# | | | 已用
# | | 总容量
# | 文件系统类型
# 设备名(tmpfs/overlay 这类会显示名字而非设备)
注意 Size ≠ Used + Avail:上例 45+2.5=47.5G,少了 2.5G。差额是 ext4 给 root 预留的 5% 保留块(防止磁盘写满导致系统无法登录修复)。查看和调整:
sudo tune2fs -l /dev/vdb1 | grep -i 'reserved block count'
# Reserved block count: 655360 <- 单位是块(4KB)= 2.5G
sudo tune2fs -m 1 /dev/vdb1 # 把保留比例从 5% 降到 1%(大盘上能释放不少)
常用场景:
df -h # 全局概况 ← 排查第一条命令
df -hT -x tmpfs -x devtmpfs -x overlay # 只看真实磁盘,去掉噪音
df -h /var/log/app.log # 这个文件在哪个文件系统、还剩多少 ← 很实用
df -i # inode 使用量("空间还有但创建文件失败"必查)
df -ih /data # inode + 可读单位
df -h --total # 带总计
df --output=target,fstype,size,pcent,ipcent -h # 自定义列,空间和 inode 一起看
df -Pk /data | tail -1 | awk '{print $5}' # 脚本里取使用率(-P 保证单行)
df -hl # 跳过网络文件系统,避免 NFS 挂掉时命令卡死
timeout 5 df -h # 更保险的做法
# 监控脚本:任何文件系统超过 85% 就告警
df -PH -x tmpfs -x devtmpfs | awk 'NR>1 && int($5)>85 {print $6, $5}'
坑:
# ① Size ≠ Used + Avail,差额是 root 保留块(默认 5%)
# 症状:Use% 100% 但 root 还能写,普通用户报 No space left
# ② 容器里 df 看到的是 overlay 层,不代表宿主机真实磁盘
df -h / # overlay ... <- 容器内
cat /sys/fs/cgroup/... # 存储配额要看别处
# ③ 空间没满但创建文件失败 = inode 用尽,df -h 完全看不出来
df -h /data # 60% used <- 看起来没问题
df -i /data # IUse% 100% <- 真正的原因
# ④ NFS/CIFS 挂载点失联时,df 会长时间卡住(statfs 阻塞)
df -l # ✅ 或 timeout 5 df
# ⑤ df 与 du 不一致的原因见第 9 节;最常见的是"已删除但被打开的文件"
# ⑥ 同一块盘 bind mount / 多个挂载点会重复出现,别把数字加两遍
10.9 truncate / fallocate — 直接操作文件大小
全称:truncate = truncate(shrink or extend the size of a file);fallocate = file allocate(preallocate or deallocate space to a file)
作用:两个都是不写入实际数据就改变文件大小,区别在于:truncate 调 truncate(),扩大时产生空洞(稀疏文件,不占磁盘);fallocate 调 fallocate(),真正向文件系统预留物理块(不稀疏,且能保证后续写入不会因空间不足失败)。
语法:
truncate [选项]... -s 大小 文件...
fallocate [选项]... -l 长度 文件
truncate 参数表:
| 短 | 长 | 作用 |
|---|---|---|
-s SIZE |
--size=SIZE |
设为指定大小;支持 +10M(增加)、-10M(减少)、% 等前缀 |
-c |
--no-create |
文件不存在时不创建 |
-o |
--io-blocks |
SIZE 按 IO 块数而不是字节 |
-r FILE |
--reference=FILE |
大小取自参考文件 |
fallocate 参数表:
| 短 | 长 | 作用 |
|---|---|---|
-l LEN |
--length |
分配长度 |
-o OFF |
--offset |
起始偏移 |
-d |
--dig-holes |
把已有文件里全 0 的区域变成空洞(就地节省空间) |
-p |
--punch-hole |
在指定区间打洞(释放该段空间,配合 -o -l) |
-z |
--zero-range |
把区间置零(尽量用打洞实现) |
-c |
--collapse-range |
删掉一段区间,后面的数据前移 |
-x |
--keep-size |
分配空间但不改变文件的表观大小 |
-n |
--keep-size 的旧写法 |
同上 |
常用场景:
# 清空正在被写入的日志(保留 fd 有效,空间立即释放) ← 最重要的用法
truncate -s 0 /var/log/app.log
: > /var/log/app.log # 等效的 shell 写法
truncate -s 1G test.img # 造 1GB 稀疏文件(du 显示 0)
truncate -s +100M data.bin # 在现有基础上加 100M
truncate -s -1 file # 去掉最后 1 字节(删掉末尾换行)
truncate -r ref.bin -s 0 new.bin # 大小与 ref.bin 一致
fallocate -l 2G /swapfile # 真实预分配(建 swap 文件必须用它,不能用稀疏)
fallocate -l 10G db.data # 数据库预分配,避免运行时空间不足
fallocate -d bloated.img # 把已有镜像里的零区域挖成空洞,就地瘦身
fallocate -p -o 0 -l 100M big.log # 释放文件前 100M 的空间(不移动后面数据)
# 快速造测试文件的三种方式对比
truncate -s 1G a.img # 瞬间,稀疏,du=0
fallocate -l 1G b.img # 瞬间,真实占 1G,内容未定义
dd if=/dev/zero of=c.img bs=1M count=1024 # 慢,真实写入 1G 的零
坑:
# ① truncate 扩大文件产生稀疏空洞,读出来是 0,但备份工具处理不当会膨胀
# 做 swap 文件绝对不能用 truncate(内核要求连续真实块),必须 fallocate 或 dd
# ② 截断不会重置进程的写偏移
# 如果程序不是 O_APPEND 模式,截断后它继续在原偏移(如 12G 处)写,
# 文件立刻变成"ls 显示 12G、du 只有几 KB"的稀疏文件 —— 日志切割要配合程序重开文件
# ③ fallocate 需要文件系统支持(ext4/xfs/btrfs 支持;NFS、tmpfs 部分不支持)
fallocate -l 1G /nfs/f
# fallocate: fallocate failed: Operation not supported <- 退回用 dd
# ④ truncate 缩小文件会【立即丢数据】,且不可恢复
10.10 file — 判断文件类型
全称:file = file(determine file type)
作用:不看扩展名,而是读取文件开头的若干字节(magic bytes)与 /usr/share/misc/magic.mgc 魔数数据库比对,判断真实类型。Linux 不靠扩展名区分文件类型,file 是唯一可靠的判断手段。
语法:
file [选项]... 文件...
参数表:
| 短 | 长 | 作用 |
|---|---|---|
-b |
--brief |
不输出文件名,只输出类型(脚本用) |
-i |
--mime |
输出 MIME 类型(text/plain; charset=utf-8) |
| — | --mime-type |
只输出 MIME 类型,不含 charset |
| — | --mime-encoding |
只输出编码 |
-L |
--dereference |
跟进软链接判断目标 |
-z |
--uncompress |
顺带看压缩包内部的类型 |
-s |
--special-files |
读取块/字符设备(用于判断磁盘上的文件系统类型) |
-k |
--keep-going |
匹配到一个后继续尝试其他规则 |
-f FILE |
--files-from |
从文件读取待检测列表 |
-F SEP |
--separator |
自定义名字与结果之间的分隔符 |
常用场景:
file mystery # mystery: PNG image data, 1920 x 1080, 8-bit/color RGBA
file -b mystery # 只要类型
file -i doc.txt # text/plain; charset=utf-8 ← 查文件编码
file --mime-encoding doc.txt # utf-8
file * # 批量识别
file -s /dev/vdb1 # /dev/vdb1: Linux rev 1.0 ext4 filesystem data ← 判断分区格式
file /bin/ls
# ELF 64-bit LSB pie executable, x86-64, dynamically linked, ...
# ^^^^^^^^^^^^^^^^^ 动态链接(静态会显示 statically linked)
file -z archive.tar.gz # 看压缩包里面是什么
file core.1234 # core file ... from 'myapp' ← 判断 core dump 来自哪个程序
# 找出目录里所有真正的图片(不管扩展名叫什么)
find . -type f -exec file --mime-type {} + | grep 'image/'
坑:file 对文本文件的编码判断是启发式的,短文本容易判错(GBK 中文常被识别成 iso-8859-1)。要确定编码更可靠的方式是 enca、chardet,或者直接试 iconv -f gbk -t utf8。另外 file 不做安全校验——扩展名 .jpg 而内容是脚本时它能识破,但内容被精心构造时仍可能误判,不要当作安全边界。
10.11 tree — 树形展示目录
全称:tree(list contents of directories in a tree-like format)。多数发行版需要手动安装(apt install tree / dnf install tree)。
参数表:
| 参数 | 全称 | 作用 |
|---|---|---|
-L N |
— | 最大深度 N(必给,否则大目录会刷屏) |
-d |
— | 只显示目录 |
-a |
— | 显示隐藏文件 |
-f |
— | 显示完整路径 |
-h |
— | 显示大小(可读单位) |
--du |
— | 目录显示累计大小(配合 -h) |
-p |
— | 显示权限 |
-u / -g |
— | 显示属主 / 属组 |
-D |
— | 显示 mtime |
-P PAT |
— | 只显示匹配的文件 |
-I PAT |
— | 排除匹配的(多个用 | 分隔) |
--dirsfirst |
— | 目录排前面 |
-J |
— | 输出 JSON |
-C |
— | 强制着色(管道里也着色) |
--charset=ASCII |
— | 用 ASCII 画线(避免终端乱码) |
tree -L 2 # 看两层结构 ← 最常用
tree -L 2 -h --du /opt # 带累计大小
tree -d -L 3 /etc # 只看目录骨架
tree -a -I 'node_modules|.git' -L 2 # 排除噪音目录
tree -f -i -P '*.go' -L 4 # 只列 go 文件的完整路径
tree -J -L 2 > structure.json # 导出 JSON 给脚本用
# 没装 tree 时的替代
find . -maxdepth 2 | sort | sed 's|[^/]*/| |g'
10.12 basename / dirname — 路径拆分
全称:basename = base name(strip directory and suffix from a filename);dirname = directory name(strip last component from file name)
作用:纯字符串处理,不检查文件是否存在。用于在脚本里拆分路径。
basename /var/log/app.log # app.log
basename /var/log/app.log .log # app ← 去掉后缀
basename -s .log /var/log/app.log # app (-s = --suffix,等效写法)
basename -a /a/x.txt /b/y.txt # x.txt y.txt (-a = --multiple 批量)
dirname /var/log/app.log # /var/log
dirname -z ... # 输出用 \0 结尾
# shell 内置的参数展开更快(不用起进程),脚本里更推荐
p=/var/log/app.log
echo "${p##*/}" # app.log 去掉最长的前缀 */ = basename
echo "${p%/*}" # /var/log 去掉最短的后缀 /* = dirname
echo "${p%.log}" # /var/log/app 去掉后缀
echo "${p##*.}" # log 取扩展名
# 脚本获取自身绝对目录(最稳的写法)
SCRIPT_DIR=$(cd -- "$(dirname -- "$(readlink -f "$0")")" && pwd)
坑:basename/dirname 遇到以 - 开头的路径会当成选项,脚本里要写 basename -- "$p"。另外 dirname a.txt(无目录部分)返回 .,不是空字符串。
10.13 mktemp — 安全创建临时文件
全称:mktemp = make temporary(create a temporary file or directory)
作用:原子地创建一个名字不可预测的临时文件/目录,权限默认 600/700。它存在的意义是防御符号链接攻击:如果你用 /tmp/myapp.$$ 这种可预测名字,攻击者可以提前创建一个指向 /etc/passwd 的软链接,让你的脚本以自己的权限覆盖系统文件。
参数表:
| 短 | 长 | 作用 |
|---|---|---|
-d |
--directory |
创建目录而不是文件 |
-u |
--dry-run |
只生成名字不创建(有竞态风险,尽量别用) |
-q |
--quiet |
出错不打印诊断信息 |
-p DIR |
--tmpdir=DIR |
指定父目录(默认取 $TMPDIR 或 /tmp) |
-t |
— | 在 $TMPDIR 里创建(兼容旧写法) |
| — | --suffix=SUF |
加后缀(如 .json) |
tmp=$(mktemp) # /tmp/tmp.7bK3xQ 权限 600
tmpdir=$(mktemp -d) # /tmp/tmp.9xLm2P 权限 700
tmp=$(mktemp --suffix=.json) # 带后缀,某些工具靠扩展名识别
tmp=$(mktemp /data/upload.XXXXXX) # 指定位置与模板(至少 6 个 X)
tmp=$(mktemp -p /etc/app config.XXXXXX) # 原子写文件时:临时文件必须与目标同文件系统
# 标准的自动清理写法
tmpdir=$(mktemp -d) || exit 1
trap 'rm -rf "$tmpdir"' EXIT INT TERM
# ^^^^^^^^^^^^^^^^^ 无论正常退出还是被中断都清理
坑:原子写文件(10.4 的 mv -T 套路)时,临时文件必须与目标文件在同一个文件系统,否则 rename() 会返回 EXDEV 失败。所以要用 mktemp -p "$(dirname "$target")",不能图省事用 /tmp。
10.14 install — 复制 + 设权限一步完成
全称:install(copy files and set attributes)
作用:cp + chmod + chown + mkdir -p 的合体,而且先写临时文件再 rename,比 cp 更接近原子替换。部署脚本和 Makefile 里应该优先用它——用 cp 覆盖一个正在运行的二进制会得到 Text file busy 或半个文件,install 不会。
参数表:
| 短 | 长 | 作用 |
|---|---|---|
-m MODE |
--mode |
设置权限(默认 755) |
-o USER |
--owner |
设置属主(需 root) |
-g GROUP |
--group |
设置属组 |
-d |
--directory |
创建目录(可多级,自动带权限) |
-D |
— | 自动创建目标的父目录 |
-t DIR |
--target-directory |
指定目标目录 |
-b |
--backup |
覆盖前备份 |
-C |
--compare |
内容相同就不动(避免无意义地改 mtime) |
-s |
--strip |
复制后 strip 掉符号表(缩小二进制) |
-v |
--verbose |
打印操作 |
-p |
--preserve-timestamps |
保留时间戳 |
install -m 755 myapp /usr/local/bin/myapp # 部署二进制(覆盖运行中的程序也安全)
install -m 640 -o root -g app config.yaml /etc/app/ # 一步搞定权限与属主
install -d -m 750 -o app -g app /var/lib/app /var/log/app # 批量建目录
install -D -m 644 unit.service /etc/systemd/system/app.service # 父目录不存在也没关系
install -C -m 644 static/* /var/www/html/ # 内容没变就不碰(保持 mtime,利于缓存)
10.15 lsof — 列出打开的文件
全称:lsof = list open files
作用:列出进程打开的所有"文件"(Linux 语义下的文件:普通文件、目录、socket、管道、设备)。本篇第 3 节用它诊断「删了文件但空间不释放」,它也是排查端口占用和 umount: target is busy 的主力工具。
参数表:
| 参数 | 作用 |
|---|---|
+L1 |
只列出链接数 < 1 的文件,即"已删除但仍被打开"(本篇核心用法) |
-p PID |
只看指定进程 |
-c NAME |
按进程名过滤(-c nginx) |
-u USER |
按用户过滤;-u ^root 排除 root |
-i |
只看网络连接;-i :8080、-i TCP:LISTEN、-i @1.2.3.4 |
+D DIR |
递归查看某目录下被打开的文件(查 umount busy) |
+d DIR |
同上但不递归 |
-n |
不解析主机名(快很多) |
-P |
不解析端口名(显示 8080 而不是 http-alt) |
-t |
只输出 PID(可直接喂给 kill) |
-a |
多个条件取与(默认是或) |
-r N |
每 N 秒重复输出 |
-w |
抑制警告信息 |
输出列解读:
COMMAND PID USER FD TYPE DEVICE SIZE/OFF NLINK NODE NAME
java 1234 app 3w REG 253,1 12884901888 0 2621998 /var/log/app.log (deleted)
^^^^ ^^^^ ^^^^ ^^ ^^^ ^^^^^^ ^^^^^^^^^^^ ^ ^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^
进程名 PID 用户 | 类型 设备号 大小/偏移 链接数 inode 路径(deleted = 已删除)
文件描述符号 + 模式:r 读 w 写 u 读写
FD 列的特殊值:cwd 当前工作目录 rtd 根目录 txt 程序代码段 mem 内存映射 DEL 已删除的映射
TYPE 常见值:REG 普通文件 DIR 目录 CHR 字符设备 FIFO 管道 unix Unix socket IPv4/IPv6 网络
sudo lsof +L1 # 已删除但仍占空间的文件 ← 磁盘满了必查
sudo lsof +L1 | awk '{print $7, $9, $10}' | sort -rn | head # 按占用排序
sudo lsof -p 1234 # 某进程打开的一切
sudo lsof -p 1234 | wc -l # 数 fd(查 fd 泄漏)
sudo lsof -i :8080 # 谁占了 8080 端口
sudo lsof -i TCP:LISTEN -P -n # 所有监听端口
sudo lsof -u app -a -i # app 用户的网络连接(-a 表示条件取与)
sudo lsof +D /mnt/data # umount 说 busy 时,查谁在用
sudo lsof -t -c nginx | xargs kill -USR1 # 按进程名批量发信号
sudo lsof /var/log/app.log # 谁在读写这个文件
# 不装 lsof 的替代方案(容器里常见)
sudo ls -l /proc/1234/fd | grep deleted # 查已删除
sudo ss -tlnp | grep 8080 # 查端口占用(比 lsof 快得多)
坑:lsof 不加过滤条件时会扫描 /proc 下所有进程的所有 fd,在高负载机器上可能要几十秒并明显消耗 CPU。生产上尽量加 -p/-c/+L1 缩小范围,查端口优先用 ss -tlnp。另外看别人的进程需要 root。
10.16 rsync — 增量同步
全称:rsync = remote sync(a fast, versatile, remote and local file-copying tool)
作用:cp 的加强版。核心能力是只传输差异(本地按 mtime+size 判断,远程可用滚动校验算法只传变化的块),并且支持进度、断点续传、限速、镜像删除、硬链接保留。本篇第 4.3 节用它替代 cp 处理大目录与跨机场景。
语法:
rsync [选项]... 源... 目标
rsync [选项]... 源 user@host:目标 # 推
rsync [选项]... user@host:源 目标 # 拉
参数表:
| 短 | 长 | 作用 |
|---|---|---|
-a |
--archive |
= -rlptgoD,最常用的组合 |
-r |
--recursive |
递归 |
-l |
--links |
软链接照抄成软链接 |
-p |
--perms |
保留权限 |
-t |
--times |
保留 mtime(很关键,没有它每次都会重传) |
-g / -o |
--group / --owner |
保留属组 / 属主(属主需 root) |
-D |
— | = --devices --specials,保留设备与特殊文件 |
-H |
--hard-links |
保留硬链接关系(-a 不含它!) |
-A |
--acls |
保留 ACL |
-X |
--xattrs |
保留扩展属性 |
-v |
--verbose |
显示传输的文件 |
-P |
— | = --partial --progress,断点续传 + 每文件进度 |
| — | --info=progress2 |
总体进度(比 -P 更适合大目录) |
-z |
--compress |
传输时压缩(跨网络有用,本地反而变慢) |
-n |
--dry-run |
只演练不执行(危险操作前必跑) |
-c |
--checksum |
按内容校验而不是 mtime+size(慢但能发现静默损坏) |
-u |
--update |
跳过目标端更新的文件 |
| — | --delete |
删除目标端源里没有的文件(镜像同步) |
| — | --delete-excluded |
连被排除的也删 |
| — | --max-delete=N |
删除超过 N 个就中止(--delete 的安全带) |
| — | --exclude=PAT / --include=PAT |
排除 / 包含(顺序敏感) |
| — | --exclude-from=FILE |
从文件读排除规则 |
| — | --link-dest=DIR |
与 DIR 中未变化的文件建硬链接(增量快照核心) |
| — | --bwlimit=RATE |
限速(--bwlimit=50M) |
| — | --remove-source-files |
传完删源(当"带进度的 mv"用) |
| — | --inplace |
就地更新(省空间,但中断会留下损坏文件) |
| — | --numeric-ids |
不做用户名映射,直接用 uid/gid(跨机器必用) |
-e |
--rsh |
指定远程 shell(-e 'ssh -p 2222') |
| — | --partial-dir=DIR |
断点数据放到指定目录 |
常用场景:
rsync -aH --info=progress2 /data/ /backup/ # 本地大目录复制,带总进度
rsync -a --delete --dry-run /data/ /backup/ | head # 先演练看会删什么 ← 必做
rsync -a --delete --max-delete=100 /data/ /backup/ # 加安全带的镜像同步
rsync -avz -e 'ssh -p 2222' /app/ user@host:/app/ # 跨机部署
rsync -a --bwlimit=50M /data/ host:/data/ # 限速 50MB/s,别打满生产带宽
rsync -avc /data/ /backup/ # 按内容校验(怀疑静默损坏时)
rsync -a --link-dest=/backup/day1 /data/ /backup/day2/ # 增量快照,未变文件用硬链接
rsync -a --remove-source-files --info=progress2 /a/big/ /b/big/ # 带进度的"mv"
rsync -av --include='*/' --include='*.go' --exclude='*' src/ dst/ # 只同步 .go
mkdir /tmp/empty && rsync -a --delete /tmp/empty/ /cache/ # 快速清空百万文件目录
坑:
# ① 源路径末尾斜杠决定语义(最著名的坑)
rsync -a /data /backup/ # -> /backup/data/xxx 带上目录本身
rsync -a /data/ /backup/ # -> /backup/xxx 只同步内容
# ② --delete 在源路径写错或挂载丢失(变成空目录)时会清空目标
# 必须配 --dry-run 预演,或 --max-delete 兜底
# ③ -a 不包含 -H -A -X:硬链接、ACL、xattr 都不会保留
rsync -aHAX /data/ /backup/ # ✅ 真正的"完整"
# ④ 跨机器时用户名相同但 uid 不同 -> 属主错乱,加 --numeric-ids
# ⑤ -z 在本地磁盘间复制是纯粹的 CPU 浪费,只在跨网络时用
# ⑥ 默认按 mtime+size 判断"是否需要传",被 touch 过的相同文件会被重传,
# 内容相同但 mtime 不同的场景用 -c(代价是全量读取校验)
10.17 shred / blkdiscard — 真正擦除数据
全称:shred(overwrite a file to hide its contents);blkdiscard(discard sectors on a device)
作用:rm 只解除链接,数据仍在盘上。shred 用随机数据反复覆写文件内容;blkdiscard 对整块设备下发 TRIM/discard 指令。
| shred 参数 | 全称 | 作用 |
|---|---|---|
-n N |
--iterations |
覆写 N 遍(默认 3) |
-z |
--zero |
最后再写一遍 0,掩盖被 shred 过的痕迹 |
-u |
--remove[=HOW] |
覆写后删除文件 |
-v |
--verbose |
显示进度 |
-s N |
--size |
只覆写前 N 字节 |
-x |
--exact |
不扩展到块边界 |
-f |
--force |
必要时先改权限以便写入 |
shred -vzun 3 secret.key # 覆写 3 遍 + 写零 + 删除
shred -vz -n 1 /dev/vdb1 # 擦整个分区(机械盘上有效)
sudo blkdiscard -s /dev/nvme0n1 # SSD 的正确擦除方式(-s = secure)
sudo nvme format -s1 /dev/nvme0n1 # NVMe 的 Secure Erase
sudo cryptsetup luksErase /dev/vdb1 # 加密盘:销毁密钥即等于销毁数据(最实际的方案)
坑(重要):shred 在 SSD/NVMe/U 盘上不可靠——闪存的磨损均衡会把"覆写"映射到另一块物理单元,原数据仍留在旧单元。在 CoW / 日志文件系统(btrfs、ZFS、data=journal 的 ext4)上同样不可靠,且快照会保留旧数据。正确策略是从一开始就全盘加密,销毁时只销毁密钥。
10.18 sync — 刷脏页落盘
全称:sync(flush file system buffers)
作用:write() 返回成功只意味着数据进了 page cache(脏页),实际落盘由内核 writeback 线程异步完成。sync 强制把脏页刷到设备。
| 参数 | 全称 | 作用 |
|---|---|---|
| (无) | — | 刷所有文件系统的脏页 |
-f |
--file-system |
只刷指定文件所在的整个文件系统 |
-d |
--data |
只刷文件数据,不刷元数据(相当于 fdatasync) |
sync # 全局刷盘(关机/拔盘前的老习惯)
sync -f /data/important.db # 只刷这个文件所在文件系统
sync -d /data/wal.log # 只刷数据
# 观察当前有多少脏页待回写
grep -E 'Dirty|Writeback' /proc/meminfo
# Dirty: 234560 kB
# Writeback: 8192 kB
说明:sync 是全局/粗粒度的,程序里保证单个文件持久化应该用 fsync(fd)(数据 + 元数据)或 fdatasync(fd)(只数据,更快)。原子写文件的套路里,rename() 之前必须对临时文件 fsync,否则掉电可能得到「文件名在了但内容是空的」——原因是元数据操作与数据写入走的是两条独立路径,详见第 5.1 节和第 30 篇 文件系统。
11. 面试题
Q:什么是 inode?它里面存了什么、不存什么?
inode(index node,索引节点)是文件系统里一个定长结构体(ext4 默认 256 字节),保存一个文件的全部元数据和数据块索引:文件类型与权限(i_mode)、属主属组的 uid/gid 数字、大小、占用块数、三个时间戳、硬链接数(i_links_count)、以及指向数据块的 extent 树。
关键在于它不存两样东西:① 不存文件名——名字在目录里,目录的内容就是一张「文件名 → inode 号」的映射表;② 不存路径——Linux 里没有「文件的路径」这个概念,只有「能到达它的若干条路径」。
这个「名字与元数据分离」的设计换来三个能力:一份数据可以有多个名字(硬链接)、改名/移动只改目录项因此廉价且原子(rename())、inode 号即数组下标所以能 O(1) 算出磁盘位置。代价是 ext4 的 inode 表在 mkfs 时就固定了,于是有了下一题。
Q:df -h 显示还有 28G 空间,但 touch 报 No space left on device。为什么?
inode 用尽。ext4 在格式化时按「每 16KB 数据配一个 inode」预分配好 inode 表,用完就不能再创建任何文件,哪怕数据块还大量空闲。用 df -i 才能看出来(IUse% 100%)——df -h 完全看不到。
定位是哪个目录耗光的:du --inodes -d1 /data | sort -n | tail(coreutils 8.22+),老版本用 for d in /data/*/; do echo "$(find "$d" -xdev | wc -l) $d"; done | sort -rn | head。典型元凶是缓存目录、session 文件、未清理的小日志。
处理:先删无用小文件救急;根治要么重建文件系统并调大 inode 数(mkfs.ext4 -i 4096 或 -N 20000000,注意格式化后无法在线增加),要么换用动态分配 inode 的 xfs。所以「海量小文件的盘用 xfs」是一条实践经验,不是玄学。
Q:磁盘满了,du 找到一个 10G 的日志文件删掉,df 显示空间没释放。为什么?怎么正确处理?
因为 Linux 里删除文件的系统调用是 unlink,它只从目录里划掉一行并把 inode 的硬链接计数 -1。内核回收数据块的条件是「硬链接数 == 0 且 打开该 inode 的 fd 数 == 0」。应用进程还开着这个日志文件的 fd,所以第二个条件不满足,空间不释放——这类文件叫 deleted but still open,用 lsof +L1 或 ls -l /proc/<pid>/fd | grep deleted 能看到。
处理有两种:① 让进程重开文件(systemctl reload,或 nginx 那样 kill -USR1);② 不要删,直接截断:: > /var/log/app.log 或 truncate -s 0,空间立即释放且进程无感知。运维原则是:清理正在被写入的日志永远用截断,不用 rm。 附加价值点:如果文件是误删的,只要 fd 还开着就能从 /proc/<pid>/fd/<n> 把内容完整拷回来。
Q:硬链接为什么不能跨文件系统?为什么不能给目录建?
不能跨文件系统是因为目录项里存的是 inode 号,而 inode 号只在单个文件系统内唯一。跨设备的硬链接在数据结构上无法表达,内核直接返回 EXDEV。
不能给目录建是因为会把目录树变成有环的图:find、du、rm -r 这些递归工具会陷入死循环,.. 该指向哪个父目录也无法确定,getcwd() 无法唯一还原路径。软链接允许指向目录,是因为内核在解析时能检测循环(超过 40 层返回 ELOOP),代价是可控的。顺带一个考点:空目录的硬链接数是 2(父目录里的名字 + 自己的 .),每多一个子目录 +1,所以 链接数 - 2 = 直接子目录数。
Q:mv 是原子的吗?怎么保证「更新配置文件时服务不会读到半个文件」?
同文件系统内是原子的(一次 rename() 系统调用,只改目录项,20G 文件也是瞬间);跨文件系统不是——rename() 返回 EXDEV 后 mv 退化成「读+写+unlink」,中断会留下不完整的目标文件。
所以标准的原子写套路是:在目标同一个目录下创建临时文件 → 写完 → fsync → rename 覆盖。三个细节都会被追问:① 临时文件必须与目标同文件系统,否则 rename 失败;② 必须 fsync,因为 rename 只保证目录项原子、不保证数据落盘,掉电可能得到一个空文件(这是 ext4 早期一场著名事故);③ 用 mv -T 或 os.Rename,避免目标是目录时被"挪进去"。
Q:能秒级复制一个 10G 文件吗?
能,前提是文件系统支持 reflink(CoW):btrfs、xfs(reflink=1,RHEL 8+ 默认)、bcachefs。用 cp --reflink=always src dst,新文件的 extent 直接指向同一批物理块并标记写时复制,不搬运数据,几毫秒完成,且两个文件共占一份空间,谁写谁才分配新块。生产脚本里推荐 --reflink=auto——支持就快,不支持自动退化成普通复制。ext4 不支持 reflink(cp -l 硬链接也能"秒完成",但那是同一份数据不是副本,改一个另一个也变,语义完全不同)。
Q:ls 一个有 100 万文件的目录卡十几秒,为什么?怎么办?
ls 默认做了两件额外的事:把全部条目读入内存排序(必须读完才能输出第一行),以及为了着色和类型判断对每个文件调一次 stat(百万次系统调用)。
解法是关掉这两件事:ls -U --color=never(-U = 不排序,边读边输出)或直接 ls -f(等价 -aU 且无着色)。只要数量用 find dir -maxdepth 1 -printf '.' | wc -c 而不是 ls | wc -l。删除百万文件用 find -delete 或 rsync -a --delete 空目录/ 目标/,比 rm -rf 快。还有一个反直觉的知识点:ext4 的目录只增不减,曾装过百万文件的空目录本身仍是十几 MB,遍历依然慢,必须 mv 旧 && mkdir 新 重建。
Q:du 和 df 的结果差很多,可能是什么原因?
按概率排:① 已删除但仍被打开的文件(du 遍历目录看不见,df 统计块使用看得见)→ lsof +L1;② 文件系统保留块(ext4 给 root 预留 5%)→ tune2fs -l;③ 被挂载遮盖的目录(/data 下原有文件,后来在 /data 挂了新盘,老文件仍占空间)→ mount --bind / /mnt 后查看;④ 稀疏文件导致表观大小与实际占用不同 → du --apparent-size 对比;⑤ du 没权限跳过了目录 → 用 sudo。另外要先分清是空间还是 inode 用尽(df -i),后者的症状是「空间还有很多但创建文件报 No space left」。
Q:ctime 是创建时间吗?三个时间戳分别什么时候变?
不是,ctime 是 change time(inode 变更时间)。atime 在内容被读时更新,mtime 在内容被改时更新,ctime 在inode 任何字段变化时更新——包括改内容、改权限、改属主、改硬链接数。创建时间是 btime,需要 statx() + ext4/xfs/btrfs 支持,stat 输出里的 Birth 字段。
安全上有个重要推论:touch -d 能任意伪造 atime/mtime,但改完 ctime 必然变成当前时间,所以文件完整性检测(AIDE、tripwire)都盯 ctime——攻击者能把木马的 mtime 改回一年前,但藏不住 ctime。
Q:atime 为什么默认是 relatime 而不是严格按 POSIX 更新?
因为严格 atime 意味着每次读文件都要写一次磁盘(更新 inode)。对读多写少的负载这是巨大的写放大,SSD 上还额外耗寿命。relatime 的折中规则是:只在「atime 比 mtime/ctime 旧」或「atime 超过 24 小时」时才更新,既保留了"这文件最近有没有被读过"的可判断性,又几乎消除了写放大。数据库、日志服务器可以更激进地用 noatime,但要先确认没有依赖 atime 的程序(如 find -atime 的清理脚本、mutt 的已读判断)。
Q:一个文件名叫 -rf,怎么删掉它?为什么 rm -rf 不行?
因为 rm 会把 -rf 解析成选项而不是文件名,命令等价于 rm -r -f 后面没有操作对象。两种解法:rm -- -rf(-- 表示后面都是操作数,POSIX 通用约定)或 rm ./-rf(加路径前缀让它不以 - 开头)。
延伸考点:Linux 的文件名只禁止 / 和 \0,空格、换行、引号、* 全都合法。所以脚本三条铁律:变量一律加双引号、绝不解析 ls 的输出(用 shell glob 或 find -print0 | xargs -0)、变量拼路径时用 ./ 或 -- 隔断选项。
Q:rm -rf "$DIR"/* 这种写法有什么风险?怎么防?
如果 $DIR 因为拼写错误、上游命令失败或未设置而为空,命令会展开成 rm -rf /*——这是最经典的删库姿势,且 --preserve-root 拦不住(它只拦 / 本身,不拦 /*)。防护有几层:脚本开头 set -u;用 ${DIR:?DIR must be set} 让空值直接报错退出;执行前显式校验 [[ -n "$DIR" && -d "$DIR" ]];批量删除改用 find "$DIR" -maxdepth 1 -delete(find 对不存在的路径会报错而不是从根开始);危险命令前先加 echo 看展开结果。另外注意 rm -rf dir/* 不会删隐藏文件(glob 不匹配 . 开头),要连隐藏文件一起得写 dir/{*,.[!.]*} 或直接删整个目录再重建。
Q:cp 和 cp -a 的区别?什么时候必须用 rsync?
默认 cp 只保证内容和执行位,属主、属组、三个时间戳、软链接本身、ACL、扩展属性、稀疏性全部会变——迁移和备份用默认 cp 是事故来源。cp -a = -dR --preserve=all,才是"一模一样搬一份"。
cp 的三个天生短板决定了什么时候换 rsync:没有进度、不能断点续传、不能限速。另外 cp -a 无法跨文件保持硬链接关系(同一 inode 的两个名字会被复制成两份数据),rsync -aH 可以。跨机、大目录、需要可重试、需要 --delete 镜像、需要按内容校验(-c)都用 rsync。用 rsync 要记住两个坑:源路径末尾斜杠决定是否带上目录本身;--delete 在源路径写错或挂载丢失时会清空目标,务必配 --dry-run 或 --max-delete。
小结
这一篇的所有内容都可以压缩成一句话:文件名只是目录这张映射表里的一行,文件的真身是 inode 和它的数据块。
- inode 存元数据与数据块索引,不存名字、不存路径 —— 名字与元数据分离,才有了硬链接和廉价的
rename - ext4 的 inode 表在格式化时固定 —— 所以有「空间还剩 28G 却创建不了文件」
- 删除是
unlink(减引用),不是 delete —— 所以有「删了不释放」,所以正确姿势是截断 - 硬链接是"多一个名字",软链接是"存一条路径" —— 所以前者不能跨设备、不能指向目录
- 同设备
mv是原子的rename()—— 所以有「写临时文件 + fsync + rename」这个原子写套路 cp默认丢元数据,-a才完整;reflink 能秒复制大文件ls慢是因为排序 + 百万次 stat;ext4 目录只增不减- 文件名几乎什么字符都合法 —— 所以脚本必须用
"$var"和-print0
下一篇讲看文件内容:less 的交互技巧、tail -f 与 -F 在日志切割时的关键区别、file 靠 magic bytes 而不是扩展名判断类型、以及怎么处理乱码文件。
系列后续会回到本篇留下的钩子:inode 与目录项在内核里的完整设计(第 20 篇 VFS)、ext4 的 htree 目录索引与日志机制(第 30 篇 文件系统)、
rename与fsync的持久性语义争议(同第 30 篇)。
xingliuhua