2024 年我想给一个重要的日志文件留个"备份"。当时机器磁盘有点紧,我觉得复制一份太占地方,灵机一动用了 ln 命令做了个"链接",心想这样既省空间、又有个副本,挺聪明。几天后线上排查问题,我去翻那个"备份",发现它和原文件一样——是空的。原文件被 logrotate 切割归零了,我的"备份"也跟着归零了。我当时完全懵了:这不是两个文件吗,怎么动一个另一个就跟着变?后来才搞明白,我建的是硬链接,它和原文件根本就是同一份数据,我以为的"备份"从头到尾就是个幻觉。这件事逼着我把软链接、硬链接、inode 这一套彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,想给 /data/app/run.log 做个"备份"
事故现象:
- 我用 ln 建了个"备份":ln /data/app/run.log /data/backup/run.log
- 几天后 logrotate 把原 run.log 切割、清空
- 去看"备份" /data/backup/run.log —— 也是空的!
- 我以为的"两份独立文件",其实自始至终是【一份数据】
现场排查:
# 1. 看这两个文件的 inode 号
$ ls -li /data/app/run.log /data/backup/run.log
1314521 -rw-r--r-- 2 root root 0 ... /data/app/run.log
1314521 -rw-r--r-- 2 root root 0 ... /data/backup/run.log
# ↑ inode 号【完全相同】= 它俩是同一个文件的两个名字!
# ↑ 这个 2 = 链接数,有 2 个名字指向它
# 2. 对比:如果当初用的是软链接会怎样
$ ln -s /data/app/run.log /data/backup/run2.log
$ ls -li /data/backup/run2.log
1314888 lrwxrwxrwx 1 ... run2.log -> /data/app/run.log
# ↑ inode 不同 ↑ 类型是 l(软链接) ↑ 存的是一段【路径】
根因(后来想清楚的):
ln 不加 -s,建的是【硬链接】:
它不是"副本",而是给同一份数据【再起一个名字】。
同一份数据,改一处,所有名字看到的都变。
logrotate 把内容清空,所有指向它的名字自然都空了。
我把"硬链接"错当成了"备份",这是认知层面的根本错误 ——
链接不等于拷贝,链接从来都不是备份。
修复 1:inode——理解链接的前提
# === 想搞懂链接,先得搞懂"文件"在 Linux 里到底是什么 ===
# 一个文件,其实由两部分组成:
# 1. inode:存"元数据" —— 权限、属主、大小、时间戳、
# 以及最关键的:数据在磁盘上的位置。每个 inode 有个号。
# 2. 文件名:存在【目录】里,目录本质是"文件名 -> inode 号"的表。
#
# ★ 关键认知:文件名 ≠ 文件。文件名只是指向 inode 的一个"标签"。
# 真正的文件,是 inode + 它管理的那块数据。
# === 看文件的 inode 号 ===
$ ls -i run.log
1314521 run.log
# 第一列就是 inode 号。
# === ls -li 看得更全 ===
$ ls -li run.log
1314521 -rw-r--r-- 1 root root 2048 May 14 10:00 run.log
# ↑inode ↑权限 ↑链接数 ...
# 那个"链接数"(这里是 1)= 有多少个文件名指向这个 inode。
# === 看 inode 的详细信息 ===
$ stat run.log
File: run.log
Inode: 1314521 Links: 1
...
# Links 就是硬链接数。
# === 一个反直觉的事实:删文件删的是"名字" ===
# 你执行 rm run.log,系统做的是:
# 把目录里 "run.log -> 1314521" 这条记录划掉,
# 然后把 inode 1314521 的链接数 -1。
# 只有当链接数减到 0、且没有进程还打开着它,
# 这个 inode 和它的数据才真正被回收。
# —— 这正是"硬链接"和"误删恢复"的底层原理。
修复 2:硬链接——给同一份数据再起一个名字
# === 硬链接:ln 不加 -s ===
$ ln /data/app/run.log /data/backup/run.log
# 源(已存在) 新建的硬链接
# === 它做了什么 ===
# 在 /data/backup/ 这个目录里,新增了一条记录:
# "run.log -> 1314521"
# 注意:inode 号【和源完全一样】。于是:
$ ls -li /data/app/run.log /data/backup/run.log
1314521 ... 2 ... /data/app/run.log # 链接数变成 2
1314521 ... 2 ... /data/backup/run.log # 同一个 inode
# === 硬链接的本质特性 ===
# 1. 两个名字【完全平等】,没有"谁是源、谁是链接"之分。
# 它们都只是指向同一个 inode 的标签。
# 2. 共享同一份数据 —— 通过任一个名字改内容,另一个全看见。
# 3. 删掉其中一个名字,数据【不会丢】 ——
# 只是链接数 -1,只要还有名字指着它,数据就还在。
$ rm /data/app/run.log # 删一个名字
$ cat /data/backup/run.log # 数据还在!另一个名字照样能读
# === ★ 硬链接的两个硬性限制 ===
# 限制一:不能跨文件系统/分区
$ ln /data/x /mnt/other/x
ln: failed to create hard link: Invalid cross-device link
# 因为 inode 号只在【单个文件系统】内唯一,跨不过去。
# 限制二:普通用户不能给【目录】建硬链接
$ ln /data/somedir /data/dirlink
ln: hard link not allowed for directory
# 目录硬链接会破坏文件系统树结构,系统直接禁止。
# === 硬链接真正适合的场景 ===
# - 同一分区内,想让一个文件在多个位置出现、且严格同步
# - 像 rsync --link-dest 那样做"快照式增量备份"
# (没变的文件用硬链接,不占额外空间)
# ★ 但它【不能】当备份用 —— 内容是共享的,源坏了它一起坏。
修复 3:软链接——存的是一段"路径"
# === 软链接(符号链接):ln -s ===
$ ln -s /data/app/run.log /data/backup/run.log
# ↑-s 源(目标) 新建的软链接
# === 它做了什么 —— 和硬链接完全不同 ===
# 软链接是一个【独立的新文件】,有自己【独立的 inode】。
# 这个新文件的"内容",就是一串文本:目标的路径。
$ ls -li /data/backup/run.log
1314888 lrwxrwxrwx 1 ... run.log -> /data/app/run.log
# ↑独立inode ↑类型是 l ↑ 它的"内容"就是这段路径
# === 访问软链接时发生了什么 ===
# 你 cat 这个软链接,系统发现它是个 l 类型,
# 就读出它里面存的路径,再去访问那个【真正的目标】。
# —— 软链接像一个"路标",自己不存数据,只指路。
# === 软链接的本质特性 ===
# 1. 它和目标是【两个独立文件】,inode 不同。
# 2. ★ 目标被删除/改名/移动,软链接就变成"死链(断链)":
$ rm /data/app/run.log
$ cat /data/backup/run.log
cat: run.log: No such file or directory # 路标还在,但指向空了
# 3. 软链接可以跨文件系统、可以指向目录 —— 没有硬链接的限制。
$ ln -s /mnt/other/data /data/datalink # 跨分区,OK
$ ln -s /var/log /data/loglink # 指向目录,OK
# === ★ 软链接最大的坑:相对路径 ===
# 软链接里存的那段路径,如果是【相对路径】,
# 它是相对【软链接自己所在的目录】来解释的,不是相对你 cd 的地方。
$ cd /data/app && ln -s run.log /data/backup/link.log
# 这个 link.log 里存的是相对路径 "run.log",
# 系统会去 /data/backup/run.log 找 —— 那里根本没有,死链!
# 【稳妥做法】建软链接时,目标尽量写【绝对路径】:
$ ln -s /data/app/run.log /data/backup/link.log
# === 改链接指向 / 看链接指向 ===
$ ln -sfn /new/target /data/mylink # -f 覆盖旧链接 -n 别跟进目录
$ readlink /data/mylink # 看它指向哪
$ readlink -f /data/mylink # 一路解析到最终的真实文件
修复 4:硬链接 vs 软链接——一张表理清
对比项 硬链接(ln) 软链接(ln -s)
=================================================================
本质 同一 inode 的另一名字 独立文件,内容是目标路径
inode 和源【相同】 自己【独立】的 inode
ls -l 类型 普通文件 - l 开头,显示 -> 目标
存的是什么 直接就是数据本身 一段指向目标的路径文本
删掉目标后 数据还在(只要链接数>0) 变死链,访问报 No such file
跨文件系统 ✗ 不行 ✓ 可以
能否指向目录 ✗ 普通用户不行 ✓ 可以
指向不存在的目标 ✗ 建不了(目标须存在) ✓ 可以(允许先建链接)
改内容会互相影响吗 会(本就是同一份数据) 会(因为最终访问的是同一目标)
能当"备份"吗 ✗ 绝对不能 ✗ 绝对不能
# === 用一组命令把差异看清楚 ===
$ echo hello > a.txt
$ ln a.txt hard.txt # 硬链接
$ ln -s a.txt soft.txt # 软链接
$ ls -li a.txt hard.txt soft.txt
1001 -rw-r--r-- 2 ... a.txt # 链接数 2
1001 -rw-r--r-- 2 ... hard.txt # 和 a.txt 同 inode
1002 lrwxrwxrwx 1 ... soft.txt -> a.txt # 独立 inode,l 类型
# === 删掉源文件 a.txt,看两种链接的命运 ===
$ rm a.txt
$ cat hard.txt # hello —— ★ 硬链接里数据还在
$ cat soft.txt # No such file —— ★ 软链接死了
# === 结论:它们都不是"拷贝" ===
# 想要真正独立的备份,只有一个办法:cp 真实复制一份。
$ cp a.txt a.bak # 这才是备份:独立 inode + 独立数据
修复 5:链接的排查与运维
# === 读懂 ls -l 第一个字符,一眼区分链接类型 ===
# - 普通文件(硬链接看起来和普通文件一样,要靠 inode 区分)
# l 软链接
# d 目录
$ ls -l
lrwxrwxrwx 1 ... conf -> /etc/app/conf # l = 软链接,箭头给出目标
-rw-r--r-- 2 ... data.log # 链接数 2 = 它有硬链接
# === 找出一个 inode 的所有硬链接(它到底有几个名字)===
$ ls -i data.log # 先拿到 inode 号
1314521 data.log
$ find / -inum 1314521 2>/dev/null # 全盘找指向这个 inode 的名字
# 或直接:
$ find /data -samefile data.log
# 这能回答"我改的这个文件,还有没有别的名字也指着它"。
# === 找出目录下所有软链接 ===
$ find /data -type l
# 巡检时很有用 —— 软链接多了,容易藏着死链。
# === ★ 找出"死链(断链)"—— 指向已不存在目标的软链接 ===
$ find /data -xtype l
# -xtype l:解析后发现目标不是个正常文件的,即死链。
# 清理死链:
$ find /data -xtype l -print # 先看
$ find /data -xtype l -delete # 确认后再删
# === 查一个软链接到底最终指向哪 ===
$ readlink mylink # 只看它直接写的那一层
$ readlink -f mylink # 递归解析到最终真实路径
$ namei -l /data/backup/run.log # 把路径每一段(含链接)逐层显示
# namei 在排查"这条路径上某一环是软链接、且断了"时特别好用。
# === cp / rsync / tar 对软链接的处理,要心里有数 ===
$ cp a_link dst # 默认:复制链接【指向的文件内容】
$ cp -P a_link dst # -P:把软链接【本身】原样复制
$ cp -L a_link dst # -L:强制跟随,复制目标内容
$ tar -czf x.tar.gz dir/ # tar 默认:把软链接原样存为软链接
$ tar -czhf x.tar.gz dir/ # -h:跟随软链接,存目标的实体内容
# 备份带软链接的目录前,务必想清楚:你要的是"链接"还是"实体"。
修复 6:链接使用纪律
# === 这次事故暴露的认知与操作问题,定几条纪律 ===
# === 1. ★ 第一纪律:链接不是备份,备份只能用 cp/rsync 真实复制 ===
# 硬链接是"同一份数据的另一个名字",软链接是"指向目标的路标",
# 它们都不产生【独立的第二份数据】。要备份,老老实实 cp。
# === 2. 建链接前,先问自己"我到底要哪一种" ===
# 要"同一份数据多个入口、且必须同步" -> 硬链接(限单文件系统)
# 要"一个快捷方式/路标,可跨盘、可指目录" -> 软链接(ln -s)
# 日常运维里,90% 的场景用的是软链接。
# === 3. 建软链接,目标一律写绝对路径 ===
$ ln -s /data/app/conf /etc/app/conf # 对:绝对路径,稳
# 别写相对路径 —— 相对路径是相对【链接所在目录】解释的,极易死链。
# === 4. 改软链接指向,用 ln -sfn,别先 rm 再建 ===
$ ln -sfn /new/target /data/mylink
# -f 覆盖已存在的链接,-n 防止"链接指向目录时被钻进去"。
# 先 rm 再 ln 中间有一小段"链接不存在"的窗口,服务可能正好踩空。
# === 5. 部署/上线用软链接做"原子切换" ===
$ ln -sfn /app/releases/v2 /app/current # current 一步指向新版本
# /app/current 始终是软链接,回滚就是再 ln -sfn 指回老版本 ——
# 这是发布系统的经典手法,切换是"瞬间"且原子的。
# === 6. 定期清理死链,别让它们埋雷 ===
$ find /关键目录 -xtype l # 巡检死链
# 死链平时不声不响,真用到时才报 No such file,要主动清。
# === 7. 删文件 / 改内容前,先看链接数和 inode ===
$ ls -li 文件 # 链接数 >1,说明它还有别的名字,改它会"牵连"
$ find / -samefile 文件 2>/dev/null # 把所有名字找出来再动手
命令速查
需求 命令
=============================================================
建硬链接 ln 源 新链接
建软链接(目标用绝对路径) ln -s /绝对/目标 新链接
原子地改软链接指向 ln -sfn /新目标 链接
看 inode 号和链接数 ls -li 文件
看文件元数据 stat 文件
看软链接指向哪 readlink 链接 / readlink -f 链接
逐层显示路径上的链接 namei -l /某/路径
找指向某 inode 的所有名字 find / -inum 号 / find 路径 -samefile 文件
找目录下所有软链接 find 路径 -type l
★ 找并清理死链 find 路径 -xtype l(确认后 -delete)
要真正的备份 cp 源 副本(不是 ln!)
口诀:链接不是备份 -> 日常优先软链接 + 绝对路径
-> 改指向用 ln -sfn -> 定期清死链
避坑清单
- 链接不是拷贝,硬链接和软链接都不产生独立的第二份数据,不能当备份
- 硬链接是同一 inode 的另一个名字,改内容所有名字都变,源被清空它也空
- 硬链接不能跨文件系统,普通用户也不能给目录建硬链接
- 删文件删的是"名字",inode 链接数减到 0 数据才真正被回收
- 软链接是独立文件,内容是目标路径,目标被删/改名就变死链
- 软链接目标写相对路径极易死链,相对路径是相对链接所在目录解释的
- 建软链接目标一律用绝对路径,改指向用 ln -sfn 而不是先 rm 再建
- ls -l 第一个字符 l 是软链接,链接数大于 1 说明该文件有硬链接
- find -xtype l 能揪出死链,死链平时不报错用到才暴露,要定期清
- cp/tar 对软链接默认存链接本身,要存实体内容得用 cp -L 或 tar -h
总结
这次"备份"凭空蒸发的事故,纠正的不是我某个命令的写法,而是我脑子里一个错得离谱、自己却毫无察觉的概念——我一直以为,用 ln 建一个"链接",就等于多了一份文件。这个误解的代价,是一个我以为存在、实际上从来不存在的备份。复盘到最后我才真正想明白,要理解链接,必须先理解 Linux 里"文件"到底是什么。我过去下意识地把"文件名"和"文件"画了等号,可在 Linux 的世界里,这两者是分开的:真正的文件,是一个叫 inode 的东西,它记录着这个文件的权限、属主、大小、时间戳,以及最关键的——数据究竟存在磁盘的哪个位置;而文件名,只不过是存放在某个目录里的一个"标签",这个标签做的唯一一件事,就是指向某一个 inode 号。想通了这一层,硬链接的真相就再清楚不过了:我用 ln 不加 -s 建的那个所谓"备份",根本不是一份新数据,它只是在另一个目录里,新挂了一个标签,而这个标签指向的 inode,和原文件的 inode 是同一个。同一个 inode,就意味着同一份数据、同一块磁盘空间。我那两个看起来位于不同目录、有着不同名字的文件,自始至终都是同一个东西的两张脸。所以当 logrotate 把原文件的内容清空时,它清的是那个 inode 所管理的数据,而我的"备份"作为指向同一个 inode 的另一个标签,看到的当然也是被清空后的同一份数据——它根本没有"自己的"数据可以保住。软链接则是另一套完全不同的机制:它是一个货真价实的、独立的新文件,有自己独立的 inode,但它这个文件的"内容"很特殊,存的不是真实数据,而是一串文本,也就是它所指向的目标的路径。软链接更像一块"路标",它自己不存任何东西,只负责指路;一旦它指向的那个目标被删除或移走,这块路标就指向了一片虚空,变成所谓的"死链"。把这两者并排理解之后,一个朴素却被我忽视了很久的结论才浮出水面:无论是硬链接还是软链接,它们都不会、也不可能凭空变出"第二份独立的数据"。硬链接是同一份数据的两个名字,软链接是指向同一份数据的一块路标——它们共享的,始终是那唯一的一份数据。所以"链接可以当备份"这个念头,从根上就是错的。真正的备份只有一条路,就是老老实实用 cp 或者 rsync 去做一次实实在在的数据复制,让副本拥有它自己独立的 inode、独立的那块磁盘空间——只有这样,原件出了事,副本才能真正地、独立地幸存下来。这次事故之后,我给自己立下了几条关于链接的纪律:第一条也是最重要的一条,链接永远不是备份,要备份就 cp;第二条,在动手建链接之前,先冷静地问自己一句,我究竟是想要"同一份数据的多个入口",还是想要"一块指路的路标",前者用硬链接、后者用软链接,而日常运维里绝大多数场景要的其实都是软链接;第三条,建软链接时目标一律写绝对路径,因为相对路径是相对链接自己所在的目录来解释的,稍不留神就指空;第四条,要修改一个软链接的指向时,用 ln -sfn 一步原子地覆盖,而不是先 rm 再重建,以免中间那个空窗期被正在运行的服务恰好踩中。这次从一个空荡荡的"备份文件"出发,我最大的收获,是终于把文件名、inode、数据这三样东西在脑子里彻底分了家——而一旦把它们分开了,硬链接、软链接、删除、备份这些原本纠缠不清的概念,全都各归其位、清清楚楚了。
—— 别看了 · 2026