目录

Linux-02 文件、链接与归档压缩

前置阅读:Linux-01 认识 Linux 与目录结构

「一切皆文件」是 Linux 最核心的抽象。这一篇从 inode 讲起——理解了 inode,「df 和 du 对不上」「删了文件磁盘没释放」「硬链接为什么不能跨文件系统」这一类问题就全部自洽了,不需要背结论。

后半部分是文件操作和归档压缩的实战,重点在那些「用了很多年但语义其实理解错了」的地方。

1. 一个文件在磁盘上是三层结构

先说结论,这是本篇的地基:

目录项(dirent)             inode                     数据块(block)
+------------------+        +------------------+        +----------+
| 文件名           |        | 权限、uid、gid   |        | 文件内容 |
| inode 号 --------+------->| 大小、时间戳     |        |          |
+------------------+        | 链接数           |        |          |
                            | 数据块指针 ------+------->|          |
「目录」就是一张            +------------------+        +----------+
名字 → inode 号的表          元数据都在这里

三个关键推论,后面所有内容都由此展开:

  1. 文件名不属于文件本身,它存在「目录」这个特殊文件里。所以同一个 inode 可以有多个名字(硬链接)。
  2. 元数据(权限、时间、大小)在 inode 里,不在目录项里。所以 ls -l 要读每个文件的 inode,这就是为什么 ls -lls 慢很多。
  3. inode 号只在单个文件系统内唯一。所以跨文件系统的硬链接从原理上就不可能。

1.1 inode 里到底有什么

ext4 的 inode 结构(内核源码 fs/ext4/ext4.h,节选):

struct ext4_inode {
    __le16  i_mode;         /* 文件类型与权限 */
    __le16  i_uid;          /* 所有者 uid */
    __le32  i_size_lo;      /* 文件大小 */
    __le32  i_atime;        /* access time  最近一次读 */
    __le32  i_ctime;        /* change time  最近一次改 inode */
    __le32  i_mtime;        /* modify time  最近一次改内容 */
    __le32  i_dtime;        /* deletion time */
    __le16  i_gid;          /* 所属组 gid */
    __le16  i_links_count;  /* 硬链接数 <- 删除逻辑的关键 */
    __le32  i_blocks_lo;    /* 占用块数 */
    __le32  i_block[EXT4_N_BLOCKS];  /* 数据块指针 */
    /* ... */
};

注意:里面没有文件名。这是 inode 结构最值得记住的一点。

ls -l 显示的每一列都能对应到上面的字段——权限来自 i_mode、用户来自 i_uid、大小来自 i_size、时间来自 i_mtime、第二列那个常被忽略的数字就是 i_links_count

stat 可以直接看:

stat /etc/hosts
#   File: /etc/hosts
#   Size: 221         Blocks: 8          IO Block: 4096   regular file
# Device: fd01h/64769d    Inode: 1179657     Links: 1
#                                            ^^^^^^^^^^^  ^^^^^^^^^ 链接数
# Access: (0644/-rw-r--r--)  Uid: (0/root)   Gid: (0/root)
# Access: 2026-08-06 20:11:03.000000000 +0800
# Modify: 2026-03-15 09:22:41.000000000 +0800
# Change: 2026-03-15 09:22:41.000000000 +0800

1.2 三个时间:atime / mtime / ctime

这三个极容易混,用一张表说清:

时间 全称 什么操作会改它 举例
atime access time 文件内容 cat fgrep x f
mtime modify time 改文件内容 echo x >> f、编辑保存
ctime change time inode(含改内容) chmodchownmv、改内容

判断口诀:mtime 管内容,ctime 管元数据,atime 管读取

几个推论:

  • 改内容一定会同时改 mtimectime(因为大小和块指针变了,inode 也变了)
  • chmod 只改 ctime,不改 mtime——所以 mtime 能可靠反映「内容何时变过」
  • 没有「创建时间」。ext4 的磁盘格式里其实有 i_crtime 字段,但没有标准系统调用暴露它,stat 显示的 Birth 经常是 -
# 只改权限,观察哪个时间变了
touch f && stat -c "mtime=%y ctime=%z" f
# mtime=2026-08-06 20:15:01  ctime=2026-08-06 20:15:01

chmod 600 f && stat -c "mtime=%y ctime=%z" f
# mtime=2026-08-06 20:15:01  ctime=2026-08-06 20:15:09
#       ^^^ 没变                    ^^^ 变了

坑:atime 在现代发行版上默认不是每次读都更新。挂载选项默认是 relatime——只在「atime 早于 mtime」或「atime 超过 24 小时」时才更新。因为每次读文件都写一次 inode 的开销太大了。所以想靠 atime 判断「这个文件最近有没有被访问」是不可靠的。用 mount | grep relatime 确认。

2. 硬链接与软链接

有了 inode 模型,这两个概念就是一句话的事:

  • 硬链接:给同一个 inode 再加一个名字。目录项不同,inode 号相同。
  • 软链接:一个全新的文件,它的内容是一段路径字符串。inode 号不同。
echo "hello" > a
ln    a a_hard      # 硬链接
ln -s a a_soft      # 软链接

ls -li
# 1179657 -rw-r--r-- 2 lhx lhx 6 Aug  6 20:20 a
# 1179657 -rw-r--r-- 2 lhx lhx 6 Aug  6 20:20 a_hard    <- inode 相同,链接数都是 2
# 1179702 lrwxrwxrwx 1 lhx lhx 1 Aug  6 20:20 a_soft -> a
# ^^^^^^^ inode 不同  ^ 类型是 l    ^ 大小是 1 = 字符串 "a" 的长度

软链接的大小等于目标路径字符串的长度,这是个很直观的验证:ln -s /very/long/path x 之后 x 的大小就是 /very/long/path 的字符数。

2.1 两者的差异对照

维度 硬链接 软链接
inode 与源文件相同 独立的 inode
内容 就是源文件本身 一段路径字符串
文件类型位 -(普通文件) l
能否跨文件系统 不能(inode 号只在本文件系统唯一)
能否指向目录 不能(会形成环,破坏树形结构)
能否指向不存在的目标 不能 (悬空链接,ls 显示红底闪烁)
删掉源文件后 仍可正常访问 失效
占额外空间 几乎不占(只多一个目录项) 一个 inode + 路径字符串

为什么硬链接不能指向目录:如果允许 ln /a/b /a/b/c,文件系统就从树变成了有环图,finddurm -r 全部会无限递归。所以内核直接禁止普通用户这么做。

唯一的例外是 ...——它们本身就是内核自动创建的目录硬链接。这解释了目录的链接数规律:

mkdir -p d/{x,y}
stat -c "%h %n" d
# 4 d
# ^ 链接数 = 4 = d 自己的目录项 + d/. + d/x/.. + d/y/..

**规律:一个目录的链接数 = 2 + 它的子目录个数。**看到一个目录链接数是 2,就知道它没有子目录——find 就是用这个规律做优化的。

2.2 软链接的两个易错点

第一:cd 进软链接目录后,pwd 给的是哪个路径?

ln -s /var/log ll
cd ll

pwd
# /home/lhx/ll            <- Shell 记住的「逻辑路径」

pwd -P
# /var/log                <- 内核认的「物理路径」

cd ..                     # 回到 /home/lhx,不是 /var
cd -P .. 2>/dev/null; pwd -P   # 用物理语义则回到 /var

这在脚本里是真实的坑:脚本自己算 SCRIPT_DIR 时如果被软链接调用,逻辑路径和物理路径不一致会导致找不到同目录的资源文件。稳妥写法:

# 拿到脚本所在的真实目录(解析所有软链接)
SCRIPT_DIR=$(cd "$(dirname "$(readlink -f "$0")")" && pwd)

第二:ln -sf 替换一个指向目录的软链接会出事

mkdir -p rel_v1 rel_v2
ln -s rel_v1 current

# ❌ 想把 current 指向 v2,结果在 v1 里面建了个链接
ln -sf rel_v2 current
ls current/
# rel_v2        <- 灾难:软链接被当成目录,链接建到了里面

# ✅ 加 -n(--no-dereference),把 current 当文件处理
ln -sfn rel_v2 current

-n 在切换发布目录时是必须的,忘了就会在旧版本目录里堆一堆垃圾链接。

3. 删除文件时内核做了什么

rm 调用的系统调用叫 unlink,名字就说明了一切——它删的是「链接」,不是「文件」

unlink("a") 的完整逻辑:

1. 从目录里删掉「a -> inode 1179657」这条目录项
2. inode 的 i_links_count 减 1
3. if (i_links_count == 0 && 没有任何进程打开这个 inode)
       -> 真正释放 inode 和数据块,磁盘空间归还
   else
       -> 什么也不做,数据还在磁盘上

**释放空间需要两个条件同时满足:硬链接数为 0,且打开引用数为 0。**这一条是后面所有现象的根源。

3.1 经典问题:df 说满了,du 加起来对不上

df -h /
# Filesystem      Size  Used Avail Use% Mounted on
# /dev/vda1        40G   39G   0G  100% /          <- 满了

du -sh /* 2>/dev/null | sort -rh | head -5
# 8.2G   /var
# 3.1G   /usr
# ...                                              <- 加起来只有 13G

差出来的 26G 就是**「已被 unlink 但仍被进程持有」的文件**:目录项没了所以 du 数不到,inode 没释放所以 df 仍然算它。

典型成因:日志文件被 rm 或被 logrotate 切走了,但应用进程还持有 fd 在往里写。

# 定位:找出所有被删但仍占空间的文件
lsof +L1
# COMMAND   PID USER  FD  TYPE DEVICE  SIZE/OFF NLINK   NODE NAME
# myapp   12345  app   3w  REG  253,1  27917287168     0 663214 /var/log/app.log (deleted)
#                                                      ^ NLINK=0 就是它

# 或者
lsof | grep deleted

解决办法按优雅程度排序:

# ✅ 最优:让进程重新打开日志文件(不中断服务)
kill -USR1 $(pgrep nginx)        # nginx / 多数服务支持信号触发 reopen
systemctl reload myapp

# ✅ 可接受:重启进程
systemctl restart myapp

# ⚠️ 应急:不重启进程,直接把 fd 指向的内容清空
: > /proc/12345/fd/3
# 空间立刻释放,但进程的写偏移量还在原处,
# 后续写入会产生一个巨大的稀疏文件(看起来又满了,实际不占空间)

规律:dfdu 对不上,先查 lsof +L1;根因几乎总是「删了正在被写的文件」。

3.2 顺带:inode 用完也会「磁盘满」

还有一种「磁盘满」是 inode 耗尽——空间还有,但 inode 分配光了,创建任何新文件都报 No space left on device

df -h /data
# /dev/vdb1       500G  120G  380G  25% /data      <- 空间还有 380G

df -i /data
# Filesystem      Inodes   IUsed  IFree IUse% Mounted on
# /dev/vdb1     32768000 32768000     0  100% /data   <- inode 用完了

# 找出 inode 大户(哪个目录文件数最多)
for d in /data/*; do echo "$(find "$d" 2>/dev/null | wc -l) $d"; done | sort -rn | head

成因几乎总是「海量小文件」:缓存目录、session 文件、按请求切分的日志。ext4 的 inode 数在 mkfs 时就固定了,事后无法扩容,只能删文件或重建文件系统(mkfs.ext4 -i 8192 调密度)。xfs 是动态分配 inode 的,没这个问题——这是选文件系统时值得考虑的一点。

排查磁盘满,df -hdf -i 要一起看。

4. 文件操作里那些隐藏语义

4.1 cp 默认会丢东西

# ❌ cp 默认:不保留权限属主时间、不跟软链接、不递归
cp -r src/ dst/

# ✅ 归档模式,等价于 -dR --preserve=all
cp -a src/ dst/

cp -a 保留权限、属主、时间戳、软链接原样、扩展属性。复制部署目录、备份配置时必须用 -a,否则文件全变成你自己的属主、时间戳全变成现在,出问题时无法判断「这文件是什么时候的」。

还有一个尾斜杠的经典陷阱:

cp -a src  dst/     # dst 存在 -> 结果是 dst/src/
cp -a src/ dst/     # 结果同上(cp 对源的尾斜杠不敏感)

rsync -a src  dst/  # -> dst/src/
rsync -a src/ dst/  # -> 把 src 的【内容】放到 dst/ 下   <- 差别在这

rsync 对源路径的尾斜杠敏感,cp 不敏感。这是数据同步时最常见的事故来源:多一个斜杠,文件就少一层目录,或者反过来。

生产建议:跨机器或大目录同步一律用 rsync 而不是 cp/scp

rsync -avz --delete --progress src/ user@host:/dst/
#      |||  |
#      ||+- 压缩传输
#      |+-- 输出详情
#      +--- 归档模式(等于 -rlptgoD)
#           --delete: 目标端删除源端没有的文件(做「镜像」时才加,很危险)

4.2 mv 在跨文件系统时不是原子的

mv /data/a /data/b       # 同一文件系统 -> rename(2),原子操作,瞬间完成
mv /data/a /mnt/nfs/b    # 跨文件系统 -> 实际是 copy + unlink,慢且非原子

这个区别很重要:只有同文件系统内的 mv 是原子的。利用这一点可以做「原子替换文件」,避免读方读到半个文件:

# ✅ 原子发布配置:先写临时文件,再 mv 覆盖
cat > /etc/myapp/config.json.tmp <<'EOF'
{"key": "value"}
EOF
mv /etc/myapp/config.json.tmp /etc/myapp/config.json
# rename(2) 是原子的,读方要么看到旧的完整版,要么看到新的完整版

# ❌ 直接重定向写:有一瞬间文件是空的或不完整的
cat > /etc/myapp/config.json <<'EOF'
...
EOF

跨文件系统时 mv 中途失败会留下半个文件,所以搬大数据到别的挂载点,用 rsync 校验后再删更安全。

4.3 rm 遇到海量文件会失败

rm -f /var/cache/app/*
# bash: /bin/rm: Argument list too long        <- 参数超过 ~2MB 限制

* 是 Shell 展开的,几十万个文件名拼成的命令行超出了 ARG_MAX。正确做法:

# ✅ 用 find + -delete(最快,不 fork 额外进程)
find /var/cache/app -type f -delete

# ✅ 或 find + xargs 分批
find /var/cache/app -type f -print0 | xargs -0 rm -f

# ✅ 只删 7 天前的
find /var/log/app -name "*.log" -mtime +7 -delete

坑:删几百万个小文件时,磁盘 IO 会打满,iostat%util 到 100%,同机的其他服务会被拖慢。生产上要限速:find ... -delete 换成分批加 sleep,或者用 ionice -c3 find ... 把 IO 优先级降到 idle。

5. find:最该练熟的一个命令

find 的语法是「路径 + 条件 + 动作」,条件之间默认是 and

find /var/log -name "*.log" -size +100M -mtime -7
#    ^路径     ^按名字        ^大于100M   ^7天内修改过

常用条件:

条件 含义 注意
-name "*.log" 按名字(区分大小写) 必须加引号,否则被 Shell 先展开
-iname 同上,不区分大小写
-type f / d / l 普通文件 / 目录 / 软链接
-size +100M 大于 100MB + 大于,- 小于,无符号=精确等于
-mtime -7 7 天修改 -7 是内,+7 是前,单位是天
-mmin -30 30 分钟内修改 分钟粒度用这个
-user app 属主是 app
-perm -4000 有 SUID 位 安全审计常用
-maxdepth 2 只往下找两层 必须写在其他条件前面
-newer f 比文件 f 更新 比记时间戳方便

三个高频实战:

# ① 找出占空间最大的 10 个文件(排查磁盘满)
find /var -type f -size +100M -exec du -h {} + 2>/dev/null | sort -rh | head -10

# ② 清理 30 天前的日志,但保留目录结构
find /var/log/app -type f -name "*.log.*" -mtime +30 -delete

# ③ 找出有 SUID 位的文件(安全审计)
find / -perm -4000 -type f 2>/dev/null

-exec {} +-exec {} \; 的区别值得记住

find . -name "*.txt" -exec grep hello {} \;   # 每个文件 fork 一次 grep,慢
find . -name "*.txt" -exec grep hello {} +    # 攒够一批一次调用,快得多

有一万个文件时,前者 fork 一万次,后者可能只 fork 十几次。默认用 +,只有当命令确实一次只能处理一个文件时才用 \;

注意:-maxdepth 必须写在其他表达式之前,否则 find 会警告 warning: you have specified the global option -maxdepth after the argument,且行为可能不符合预期。

6. 打包与压缩

6.1 先分清打包和压缩

这是本节最重要的概念:

  • 打包(archive):把一堆文件合成一个文件,不减小体积。工具是 tar
  • 压缩(compress):用算法把一个文件变小。工具是 gzip/bzip2/xz/zstd

Linux 的压缩工具绝大多数只能处理单个文件,所以要压缩一个目录,必须先 tar 打包再压缩——这就是 .tar.gz 这个双后缀的由来。

一堆文件  --tar-->  a.tar  --gzip-->  a.tar.gz
                                       +- 也写作 .tgz

zip 是个例外,它自己就能递归打包 + 压缩,这也是它在跨平台场景更常见的原因。

6.2 压缩算法怎么选

这是实际会影响成本的决策,不是背参数:

工具 后缀 tar 参数 压缩率 压缩速度 解压速度 适用场景
gzip .gz -z 默认选择,兼容性最好
bzip2 .bz2 -j 较高 已被 xz/zstd 取代,不推荐新用
xz .xz -J 最高 极慢 发布归档、只压一次下载很多次
zstd .zst --zstd 中高 极快 极快 现代首选,日志归档、备份

生产建议

  • 日常传输、要求兼容性 → gzip
  • 日志归档、数据库备份(每天压一次,压缩时间敏感)→ zstd
  • 发布包、容器镜像层(压一次分发无数次)→ xz
  • 新项目不要再用 bzip2,它在压缩率上不如 xz、速度上不如 zstd,没有存在的理由

zstd 值得特别推荐:它能在接近 gzip 压缩率的前提下快 3~5 倍,而且支持多线程(-T0 用满所有核):

# 压缩一个 10GB 的日志目录,对比一下
tar -czf logs.tar.gz logs/                    # gzip:  约 180s,2.1GB
tar --zstd -cf logs.tar.zst logs/             # zstd:  约  45s,2.0GB
tar -I 'zstd -T0 -19' -cf logs.tar.zst logs/  # zstd 高压缩+多线程:约 60s,1.6GB
tar -cJf logs.tar.xz logs/                    # xz:   约 900s,1.5GB

6.3 tar 的常用参数

tar 的参数记法:必须选一个动作,然后 -f 指定文件名

# 核心动作(互斥,选一个)
# -c  create   打包
# -x  extract  解包
# -t  list     只列出内容,不解开
# -r  append   追加文件到已有包(不能用于压缩过的包)

# 常用修饰
# -f  file     指定包文件名(几乎总是要写)
# -v  verbose  显示处理的文件
# -z/-j/-J     调用 gzip / bzip2 / xz
# -C  dir      切换到指定目录再操作
# 打包并压缩
tar -czf app.tar.gz app/

# 只看内容,不解开(拿到陌生压缩包必做这一步)
tar -tzf app.tar.gz | head
# app/
# app/bin/
# app/bin/myapp        <- 确认有没有顶层目录

# 解压到指定目录
tar -xzf app.tar.gz -C /opt/

# 只解压其中一个文件
tar -xzf app.tar.gz app/config.yaml

# 打包时排除
tar -czf app.tar.gz --exclude='*.log' --exclude='node_modules' app/

现代 tar 可以自动识别压缩格式,解压时其实不需要指定 -z/-J

tar -xf whatever.tar.gz      # 自动认出是 gzip
tar -xf whatever.tar.xz      # 自动认出是 xz
tar -xf whatever.tar.zst     # 自动认出是 zstd

**记住 tar -xf 一个命令就够了。**打包时才需要指定用哪个算法。

坑:解压前一定先 tar -tf 看一眼。有些包(尤其是别人手工打的)没有顶层目录,直接解压会把几百个文件散落到当前目录,清理起来很痛苦。安全做法是永远 -C 到一个空目录:mkdir tmp && tar -xf x.tar.gz -C tmp

6.4 单文件压缩工具的一个共同陷阱

gzip a.txt
ls
# a.txt.gz          <- 源文件 a.txt 没了!

# 用 -k 保留源文件
gzip -k a.txt
ls
# a.txt  a.txt.gz

gzip/bzip2/xz 默认删除源文件zip 默认保留。这个不一致坑过很多人。都支持 -k(keep)保留。

另外它们压缩目录时是「逐个压缩目录里的每个文件」,而不是压成一个包

ls dir/
# a  b
gzip -r dir/
ls dir/
# a.gz  b.gz        <- 变成两个独立的 .gz,不是一个 dir.gz

这再次说明为什么必须先 tar

流式压缩才是这些工具真正的用法——不落地临时文件:

# 数据库备份直接压缩,不产生中间的巨大 .sql 文件
mysqldump mydb | zstd -T0 > mydb.sql.zst

# 边解压边处理,不占磁盘
zstdcat mydb.sql.zst | mysql mydb

# 压缩包里搜索,不解压
zgrep "ERROR" app.log.gz
zcat app.log.gz | awk '{print $1}' | sort | uniq -c

zcat/zgrep/zless 这一组是查历史日志的必备工具(xz 对应 xzcat/xzgrepzstd 对应 zstdcat/zstdgrep)。

7. 实战:用软链接做零停机发布

把本篇的知识串起来。这是一个经典的发布目录布局:

/opt/myapp/
+-- releases/
|   +-- 20260801_143022/     <- 历史版本
|   +-- 20260805_091544/
|   +-- 20260806_201133/     <- 新版本
+-- current -> releases/20260806_201133    <- 软链接
+-- shared/
    +-- config/              <- 各版本共用的配置
    +-- logs/

服务的 systemd unit 里 WorkingDirectory=/opt/myapp/current,发布时只需要切换这个软链接。

#!/bin/bash
set -euo pipefail

APP_DIR=/opt/myapp
REL="$APP_DIR/releases/$(date +%Y%m%d_%H%M%S)"

# ① 解包新版本到独立目录(此时线上流量还在旧版本)
mkdir -p "$REL"
tar -xf /tmp/app.tar.zst -C "$REL"

# ② 软链接引用共享的配置和日志目录
ln -sfn "$APP_DIR/shared/config" "$REL/config"
ln -sfn "$APP_DIR/shared/logs"   "$REL/logs"

# ③ 原子切换:先建临时链接,再用 mv 覆盖
#    mv 底层是 rename(2),同文件系统内是原子的,不存在「链接不存在」的瞬间
ln -sfn "$REL" "$APP_DIR/current.tmp"
mv -Tf "$APP_DIR/current.tmp" "$APP_DIR/current"

# ④ 重启服务
systemctl reload myapp

# ⑤ 只保留最近 5 个版本
ls -1dt "$APP_DIR"/releases/*/ | tail -n +6 | xargs -r rm -rf

三个关键点都用到了本篇的原理:

  • ln -sfn-n:不加会把链接建到旧版本目录里(§2.2)
  • mv -Tf 而不是 ln -sfn 直接覆盖ln -sf 是「unlink 再 symlink」两步,中间有一瞬间 current 不存在;mv 是单次 rename(2),原子(§4.2)。-T 强制把目标当成普通文件而不是目录
  • 回滚只需要把链接指回去,秒级完成,不需要重新解包
# 回滚到上一个版本
PREV=$(ls -1dt /opt/myapp/releases/*/ | sed -n 2p)
ln -sfn "$PREV" /opt/myapp/current.tmp
mv -Tf /opt/myapp/current.tmp /opt/myapp/current
systemctl reload myapp

**规律:需要「原子替换」时,用「写临时文件 + rename」,永远不要就地修改。**这个模式在配置发布、缓存文件更新、日志切割上都适用。

8. 面试题

Q:硬链接和软链接的区别?为什么硬链接不能跨文件系统、不能指向目录?

硬链接是给同一个 inode 增加一个目录项,inode 号与源文件相同;软链接是一个独立的新文件,内容是一段目标路径字符串,inode 号不同、文件类型是 l。不能跨文件系统是因为 inode 号只在单个文件系统内唯一,跨文件系统的目录项无法引用另一个文件系统的 inode。不能指向目录是因为那会让文件系统从树变成有环图,finddurm -r 这些依赖树形结构的遍历会无限循环——... 是内核自己维护的例外。

Q:rm 一个文件之后,磁盘空间一定会释放吗?

不一定。rm 调用的是 unlink,只删除目录项并把 inode 的链接数减 1。只有当链接数为 0 且没有任何进程打开该 inode 时,数据块才真正释放。所以删掉一个正在被写入的日志文件,df 显示的已用空间不会下降,而 du 已经数不到它——这就是「df 和 du 对不上」的典型原因。用 lsof +L1 能列出所有这类文件,让持有它的进程 reload 或重启即可释放。

Q:atimemtimectime 分别在什么时候变?有创建时间吗?

mtime 在文件内容改变时更新;ctimeinode 改变时更新(包括改内容、chmodchownmv);atime 在读取内容时更新。所以 chmod 只改 ctime 不改 mtime,而改内容会同时改 mtimectime没有标准的创建时间——ext4 磁盘格式里有 i_crtime 字段,但没有 POSIX 系统调用暴露它。另外 atime 默认是 relatime 挂载模式,不是每次读都更新,不能用它可靠判断访问情况。

Q:磁盘空间还有很多,为什么创建文件报 No space left on device

大概率是 inode 耗尽。df -h 看空间、df -i 看 inode,两者要一起看。成因通常是海量小文件(缓存、session、按请求切分的日志)。ext4 的 inode 总数在 mkfs 时固定,事后无法扩容,只能删文件或重建文件系统时用 -i 调整密度;xfs 是动态分配 inode 的,没有这个限制。另一个可能是磁盘配额(quota)打满,或者有进程持有大量已删除文件。

Q:targzip 是什么关系?为什么会有 .tar.gz 这种双后缀?

tar 只负责打包——把多个文件合成一个,不减小体积;gzip 只负责压缩单个文件,不能把多个文件压成一个包。所以压缩目录必须两步:先 tar 成一个 .tar,再 gzip.tar.gztar -z 参数只是帮你自动串起这两步。zip 不同,它自己同时做打包和压缩,所以只有单后缀。

Q:gzip、xz、zstd 怎么选?

看「压一次读几次」。日常传输和要兼容性用 gzip;日志归档、数据库备份这类每天压一次、压缩耗时敏感的场景用 zstd(速度是 gzip 的 3~5 倍,压缩率接近,还支持 -T0 多线程);发布包、容器镜像层这类压一次分发无数次的用 xz(压缩率最高但极慢)。bzip2 已经没有使用理由——压缩率不如 xz,速度不如 zstd。

Q:为什么 rm -f dir/* 会报 Argument list too long?怎么解决?

因为 *Shell 展开的,不是 rm 的功能。几十万个文件名拼成的命令行超过了内核的 ARG_MAX 限制(约 2MB)。用 find dir -type f -delete 最好(不 fork 额外进程),或者 find dir -type f -print0 | xargs -0 rm -f 分批处理。注意:删海量小文件会打满磁盘 IO 拖慢同机其他服务,生产上要用 ionice -c3 降低 IO 优先级或分批加 sleep。

Q:怎么原子地替换一个配置文件或发布目录?

利用「同一文件系统内的 rename(2) 是原子的」这个性质:先把新内容写到同目录下的临时文件,再用 mv 覆盖目标。读方要么看到完整的旧版本、要么看到完整的新版本,不会读到半个文件。直接 > file 重定向不行,因为文件会先被截断为空。替换软链接则要用 mv -Tf tmp_link current,而不是 ln -sfn——后者是 unlink + symlink 两步,中间存在链接不存在的瞬间。注意:跨文件系统的 mv 退化为 copy + unlink,不再原子。


上一篇:Linux-01 认识 Linux 与目录结构 | 下一篇:Linux-03 用户、权限与安全模型