目录

Linux-04 文件与目录操作:从 ls 的每个参数到 inode 引用计数

目录

这一篇假设你已经能用 lscdcpmvrm 完成日常操作。所以我们不从「怎么复制文件」讲起,而是从五个用了很久却答不上来的问题讲起:

  1. 磁盘满了,删掉一个 10G 的日志文件,df 显示空间一点没少。为什么?
  2. mv 同一个 20G 文件,有时一瞬间完成,有时要等好几分钟。区别在哪?
  3. 能不能秒级复制一个 10G 文件?(能,条件是文件系统支持)
  4. ls 一个有 100 万文件的目录会卡住十几秒ls -U 却很快。差在哪?
  5. 一个文件名叫 -rfrm -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.txtb.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,换来三个能力:

  1. 一份数据可以有多个名字(硬链接)。如果名字存在 inode 里,一个文件就只能有一个名字。
  2. 改名和移动极其廉价mv 在同一文件系统内只改目录里的一行,不碰 inode、不碰数据,所以 20G 文件重命名也是瞬间的,而且是原子的(第 5 节详述)。
  3. 元数据定长 + 编号即下标 → 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    <- 创建时间(需要文件系统支持)

这里有三个必须记住的点:

SizeBlocks 可以毫不相干。 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

因为目录构成一棵树,而硬链接会把树变成有环的图。一旦有环:finddurm -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。这也是为什么正规做法还是配 logrotatecopytruncate 或让程序支持重开。

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 的 extundeleteext4magic立即卸载分区的前提下有一定成功率,但生产上「立即卸载根分区」通常不现实。
  • 有一种情况是真能救的:文件被删了但进程还开着(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

回到开篇第三个问题。普通 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 文件内容被catgrep 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 默认做了两件你没要求的事:

  1. 把整个目录读进内存并按名字排序(要读完所有条目才能排序,所以第一个字符都要等到最后才输出)
  2. 为了着色和加类型后缀(-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 对文件名的限制只有两条:

  1. 不能含 /(它是路径分隔符)
  2. 不能含 \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 按版本号自然排序(f2f10 前面)
-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 显式写

全称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) 查询,不遍历任何文件——所以它 ① 极快;② 能看到已删除但仍被打开的文件占的空间;③ 只能按"文件系统"为粒度,无法告诉你是哪个目录占的。

dudf 是互补的df 回答"哪个文件系统满了",du 回答"里面是谁占的"。排查顺序永远是先 dfdu

语法

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)

作用:两个都是不写入实际数据就改变文件大小,区别在于:truncatetruncate(),扩大时产生空洞(稀疏文件,不占磁盘);fallocatefallocate()真正向文件系统预留物理块(不稀疏,且能保证后续写入不会因空间不足失败)。

语法

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)。要确定编码更可靠的方式是 encachardet,或者直接试 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.4mv -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 # 加密盘:销毁密钥即等于销毁数据(最实际的方案)

坑(重要)shredSSD/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 空间,但 touchNo 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 +L1ls -l /proc/<pid>/fd | grep deleted 能看到。

处理有两种:① 让进程重开文件(systemctl reload,或 nginx 那样 kill -USR1);② 不要删,直接截断: > /var/log/app.logtruncate -s 0,空间立即释放且进程无感知。运维原则是:清理正在被写入的日志永远用截断,不用 rm。 附加价值点:如果文件是误删的,只要 fd 还开着就能从 /proc/<pid>/fd/<n> 把内容完整拷回来。

Q:硬链接为什么不能跨文件系统?为什么不能给目录建?

不能跨文件系统是因为目录项里存的是 inode 号,而 inode 号只在单个文件系统内唯一。跨设备的硬链接在数据结构上无法表达,内核直接返回 EXDEV

不能给目录建是因为会把目录树变成有环的图finddurm -r 这些递归工具会陷入死循环,.. 该指向哪个父目录也无法确定,getcwd() 无法唯一还原路径。软链接允许指向目录,是因为内核在解析时能检测循环(超过 40 层返回 ELOOP),代价是可控的。顺带一个考点:空目录的硬链接数是 2(父目录里的名字 + 自己的 .),每多一个子目录 +1,所以 链接数 - 2 = 直接子目录数

Q:mv 是原子的吗?怎么保证「更新配置文件时服务不会读到半个文件」?

同文件系统内是原子的(一次 rename() 系统调用,只改目录项,20G 文件也是瞬间);跨文件系统不是——rename() 返回 EXDEVmv 退化成「读+写+unlink」,中断会留下不完整的目标文件。

所以标准的原子写套路是:在目标同一个目录下创建临时文件 → 写完 → fsyncrename 覆盖。三个细节都会被追问:① 临时文件必须与目标同文件系统,否则 rename 失败;② 必须 fsync,因为 rename 只保证目录项原子、不保证数据落盘,掉电可能得到一个空文件(这是 ext4 早期一场著名事故);③ 用 mv -Tos.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 -deletersync -a --delete 空目录/ 目标/,比 rm -rf 快。还有一个反直觉的知识点:ext4 的目录只增不减,曾装过百万文件的空目录本身仍是十几 MB,遍历依然慢,必须 mv 旧 && mkdir 新 重建。

Q:dudf 的结果差很多,可能是什么原因?

按概率排:① 已删除但仍被打开的文件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 是创建时间吗?三个时间戳分别什么时候变?

不是ctimechange time(inode 变更时间)。atime 在内容被读时更新,mtime 在内容被改时更新,ctimeinode 任何字段变化时更新——包括改内容、改权限、改属主、改硬链接数。创建时间是 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 -deletefind 对不存在的路径会报错而不是从根开始);危险命令前先加 echo 看展开结果。另外注意 rm -rf dir/* 不会删隐藏文件(glob 不匹配 . 开头),要连隐藏文件一起得写 dir/{*,.[!.]*} 或直接删整个目录再重建。

Q:cpcp -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 篇 文件系统)、renamefsync 的持久性语义争议(同第 30 篇)。