软链接和硬链接有什么区别?Python 里怎么处理符号链接?
简化版
硬链接是「给同一个 inode 再起一个名字」,软链接(符号链接)是「一个内容为路径字符串的特殊文件」——本质区别在于硬链接指向 inode(数据本体),软链接指向路径(一个名字)。由此推出全部差异:硬链接和原文件完全平等(没有「谁是原件」之说),删掉任何一个另一个都还在(inode 的 st_nlink 减一,减到 0 才真正释放数据),不能跨文件系统、不能对目录建;软链接只是存了一个路径字符串,目标被删/改名就变成「断链」(dangling),可以跨文件系统、可以指向目录、也可以指向不存在的路径。Python 的接口:os.link(src, dst) 建硬链接、os.symlink(src, dst) 建软链接、os.readlink(p) 读软链接里存的原始字符串、Path(p).resolve() / os.path.realpath(p) 递归解析到最终真实路径。最关键的一条工程规则:大量文件 API 都有 follow_symlinks 参数——os.stat 默认跟随(看目标)、os.lstat 不跟随(看链接本身),Path.is_dir() 跟随而 is_symlink() 不跟随;处理不可信数据或递归遍历时必须显式指定 follow_symlinks=False,否则会重复统计、无限递归,甚至被符号链接攻击(写到目录外)。另外 Path.resolve() 会解析符号链接,而 os.path.normpath 只做字符串上的 .. 消除——两者对含链接的路径结果可能完全不同。核心记忆:硬链接指 inode、软链接指路径;stat 跟随、lstat 不跟随;安全校验一律用 resolve()。
详细版
软链接 vs 硬链接:
| 维度 | 硬链接 os.link | 软链接 os.symlink |
|---|---|---|
| 指向什么 | inode(数据本体) | 路径字符串 |
| 目标被删 | 数据还在(st_nlink 减一) | 变成断链(读取报错) |
| 跨文件系统 | ❌ 不行(inode 号只在本文件系统内唯一) | ✅ 可以 |
| 对目录 | ❌ 普通用户不允许(会形成环) | ✅ 可以 |
| 指向不存在的路径 | ❌ 不行 | ✅ 可以(先建链接后建目标) |
| 自己的 inode | 和目标共用 | 有自己的 inode |
st_size | 和目标相同 | 路径字符串的长度 |
os.stat 看到 | 就是那个文件(无从区分) | 目标的信息(除非用 lstat) |
| 典型用途 | 去重备份、rsync --link-dest | current -> releases/v3、/usr/bin 里的版本切换 |
import os, shutil
from pathlib import Path
Path("data.txt").write_text("hello")
# ① 硬链接:同一个 inode 的另一个名字
os.link("data.txt", "hard.txt")
print(os.stat("data.txt").st_ino == os.stat("hard.txt").st_ino) # True ← ★同一个 inode★
print(os.stat("data.txt").st_nlink) # 2 ← 链接数变 2
os.remove("data.txt")
print(Path("hard.txt").read_text()) # hello ← ★数据还在★
# ② 软链接:存的是一条路径字符串
os.symlink("hard.txt", "soft.txt")
print(os.readlink("soft.txt")) # 'hard.txt' ← ★原样返回存的字符串★
print(os.stat("soft.txt").st_size) # 5 ← ★跟随★,看到目标的大小
print(os.lstat("soft.txt").st_size) # 8 ← ★不跟随★,'hard.txt' 这 8 个字符
print(Path("soft.txt").is_symlink()) # True(内部用 lstat)
print(Path("soft.txt").is_file()) # True ← ★跟随,看的是目标★
# ③ 断链:目标没了,链接还在
os.remove("hard.txt")
print(Path("soft.txt").is_symlink()) # True ← 链接本身还在
print(Path("soft.txt").exists()) # False ← ★exists 跟随链接,目标不在就是 False★
print(os.path.lexists("soft.txt")) # True ← ★lexists 不跟随★
# Path("soft.txt").read_text() # ✗ FileNotFoundError
# ④ 路径解析:三个函数完全不同
os.makedirs("real/sub", exist_ok=True)
os.symlink("real", "link")
print(os.path.normpath("link/sub/../x")) # 'link/x' ← ★纯字符串处理,不看文件系统★
print(Path("link/sub").resolve()) # /abs/real/sub ← ★解析符号链接 + 绝对化★
print(Path("link/sub").absolute()) # /abs/link/sub ← 只绝对化,★不解析链接★
# ⑤ follow_symlinks 参数(★到处都有,默认值不一致,要查★)
os.stat(p, follow_symlinks=True) # 默认跟随
os.chmod(p, 0o644, follow_symlinks=True) # 默认跟随(改的是目标的权限!)
shutil.copy("soft.txt", "copy.txt", follow_symlinks=True) # 默认复制★内容★
shutil.copy("soft.txt", "copy.txt", follow_symlinks=False) # 复制成一个★新的软链接★
# ⑥ 安全校验:确保路径没跑出基准目录(★标准写法★)
def safe_join(base: Path, user_input: str) -> Path:
base = base.resolve()
target = (base / user_input).resolve() # ★resolve 会解析掉 .. 和符号链接★
if not target.is_relative_to(base): # Python 3.9+
raise ValueError(f"越界: {user_input}")
return target
# ⑦ 原子地切换软链接(部署时的 current -> releases/vN)
os.symlink("releases/v4", "current.tmp")
os.replace("current.tmp", "current") # ★os.replace 对软链接也是原子的★
⚠️ 三个最容易出事的点:①
follow_symlinks的默认值不统一,而且经常不是你想要的。os.stat/os.chmod/os.chown默认跟随——这意味着os.chmod("link", 0o600)改的是目标文件的权限,不是链接的;shutil.copy默认复制内容(把软链接变成实体文件),copytree默认也是展开软链接(symlinks=False)。处理不可信数据、做递归统计、写备份工具时,每一个这类调用都要显式确认follow_symlinks。②Path.exists()对断链返回False(因为它跟随链接去看目标),要判断「链接本身在不在」得用os.path.lexists()或Path.is_symlink()——「文件不存在但创建时报 FileExistsError」这种诡异现象往往就是断链造成的。③os.path.normpath和Path.resolve()对含符号链接的路径结果不同:normpath只做字符串层面的..消除(link/sub/..→link),而真实的文件系统语义是「先进入链接指向的目录、再回退一级」——所以安全校验必须用resolve()(真正查文件系统),用normpath会被符号链接绕过。
完整版教学
一、inode 视角:两种链接的本质
文件系统的三层结构:
目录项(dentry): 文件名 → inode 号
inode: 元信息(大小/时间/权限/★链接计数 st_nlink★)+ 数据块指针
数据块: 真正的内容
硬链接 = 再加一条"目录项 → 同一个 inode"的映射
os.link("a.txt", "b.txt") 之后:
目录项 inode #1234 数据块
a.txt ──┐ ┌──────────────┐ ┌────────┐
├──────► │ st_nlink = 2 │ ──────► │ hello │
b.txt ──┘ └──────────────┘ └────────┘
★ a.txt 和 b.txt 完全平等,没有"谁是原件"的概念★
★ 删除 a.txt = 删掉那条目录项 + st_nlink 减 1(变 1),数据一点没动
★ 只有 st_nlink 减到 0 且没有进程打开它时,数据块才真正被释放
(这也是"删了大文件但磁盘没释放"的原因:还有进程持有 fd)
软链接 = 一个"内容是路径字符串"的特殊文件(有自己的 inode)
os.symlink("a.txt", "s.txt") 之后:
目录项 inode #5678(类型=LNK) 内容
s.txt ─────────► │ st_size = 5 │ ─────────► "a.txt" ← ★就是这 5 个字符★
└─────────────┘
访问 s.txt 时:内核读出 "a.txt" → ★重新走一遍路径解析★ → 找到真正的文件
★ 所以软链接坏掉的三种方式:目标被删、目标改名、链接被移动到别处
(存的是相对路径时,"链接被移到别的目录"就会指向错误的位置)
关键推论(面试必答):
① 硬链接不能跨文件系统 —— inode 号只在★单个文件系统内★唯一,
跨设备的目录项无法指向另一个设备的 inode(报 OSError: Invalid cross-device link)
② 硬链接不能对目录建(普通用户)—— 会形成★目录环★,让 find/rm -r 之类无限递归,
且破坏 ".." 的唯一性(只有 . 和 .. 是内核自己维护的例外)
③ 软链接可以指向不存在的路径 —— 因为它只是存字符串,创建时★不检查目标★
④ 软链接可以跨文件系统、可以指向目录 —— 因为解析是在"路径"层面做的
⑤ 硬链接的 st_size/权限/时间戳和目标★完全一样★(本来就是同一个 inode)
软链接有自己的 inode,st_size 是路径字符串的字节数
理解两种链接只需一句话:硬链接指向 inode(数据本体),软链接指向路径(一个名字)。硬链接就是在目录里再加一条「名字 → 同一个 inode」的映射,所以两个名字完全平等、没有「原件」之说;删除只是删掉目录项并把 st_nlink 减一,减到 0 且无进程持有 fd 时数据才真正释放(这也顺带解释了「删了大文件但磁盘没释放」的经典现象)。软链接则是一个内容为路径字符串的特殊文件,访问时内核读出这个字符串重新走一遍路径解析。全部差异都是这两句话的推论:硬链接不能跨文件系统(inode 号只在单个文件系统内唯一)、不能对目录建(会形成目录环、破坏 .. 的唯一性);软链接可以指向不存在的路径(创建时根本不检查)、可以跨设备、可以指向目录,代价是目标一变就断链。
二、Python API 全景
创建:
os.link(src, dst) 硬链接(3.6+ 支持 dir_fd / follow_symlinks)
os.symlink(src, dst) 软链接
os.symlink(src, dst, target_is_directory=True) ★Windows 上必须指明★
Path(dst).symlink_to(src) ★注意参数顺序和 os.symlink 一致:指向 src★
Path(dst).hardlink_to(src) 3.10+(3.8~3.9 是 link_to,参数顺序相反!)
读取:
os.readlink(p) → 软链接里★存的原始字符串★(可能是相对路径,不做解析)
Path(p).readlink() → 3.9+
os.path.realpath(p) → ★递归解析★所有符号链接后的绝对路径
Path(p).resolve() → 同上(3.6+ 默认 strict=False,路径不存在也不报错)
Path(p).resolve(strict=True) → 路径不存在就抛 FileNotFoundError
判断:
os.path.islink(p) / Path(p).is_symlink() ★用 lstat,只看链接本身★
os.path.exists(p) ★跟随★:断链返回 False
os.path.lexists(p) ★不跟随★:断链返回 True
os.stat(p) vs os.lstat(p) 跟随 / 不跟随
os.path.samefile(a, b) → 比较 (st_dev, st_ino),能识破软链接和硬链接
删除:
os.remove(link) / os.unlink(link) ★删除链接本身,不动目标★(软链接、硬链接都一样)
os.rmdir / shutil.rmtree 对"指向目录的软链接"★会报错★
→ shutil.rmtree("link_to_dir") 抛 NotADirectoryError(这是有意的安全设计)
→ 要删链接本身用 os.remove;rmtree 遇到目录树内部的软链接★默认不跟随★(不会删到外面)
替换(部署时的原子切换):
✗ os.remove("current"); os.symlink(new, "current") # ★中间有一段时间 current 不存在★
✓ os.symlink(new, "current.tmp"); os.replace("current.tmp", "current")
→ os.replace 是原子的,对软链接同样适用(★同一文件系统内★)
Windows 上的差异(★踩坑重灾区★):
- 创建软链接需要★管理员权限★或开启"开发者模式",否则 OSError
- os.symlink 必须正确设置 target_is_directory(目录链接和文件链接是两种类型)
- 还有第三种东西:目录联接(junction),os.path.islink() ★返回 False★
(3.8+ 的 os.readlink 能读 junction,但判断要用 os.path.isjunction()——3.12+)
- 硬链接在 NTFS 上支持,FAT32 不支持
API 层面有几个容易混的点。创建时注意 Path.symlink_to(src) 的语义是「让这个 Path 指向 src」(和 os.symlink(src, dst) 一致),而 3.8~3.9 的 Path.link_to() 参数顺序相反、3.10 起改为 hardlink_to 才统一——写兼容代码时要小心。读取要分清三层:os.readlink() 返回链接里存的原始字符串(不解析、可能是相对路径),os.path.realpath()/Path.resolve() 则递归解析所有链接得到最终绝对路径。判断的关键是 exists() 跟随(断链返回 False)而 lexists() 不跟随。删除时 os.remove(link) 删的是链接本身、绝不会动目标;而 shutil.rmtree("指向目录的软链接") 会直接报错——这是有意的安全设计,防止误删链接指向的真实目录。部署时切换软链接要用 os.symlink 到临时名 + os.replace 原子替换,直接「删了再建」中间会有一段时间 current 不存在。Windows 上则是重灾区:创建软链接需要管理员权限或开发者模式,还有 os.path.islink() 识别不了的目录联接(junction)。
三、follow_symlinks:默认值不统一才是真陷阱
一张必须记住的表(★默认值不一致★):
┌────────────────────────────┬──────────┬────────────────────────────┐
│ 调用 │ 默认 │ 说明 │
├────────────────────────────┼──────────┼────────────────────────────┤
│ os.stat(p) │ 跟随 │ 看目标;os.lstat 才不跟随 │
│ os.chmod / os.chown │ ★跟随★ │ ★改的是目标的权限/属主!★ │
│ os.remove / os.unlink │ 不跟随 │ 永远删链接本身(无参数) │
│ os.rename / os.replace │ 不跟随 │ 移动链接本身 │
│ Path.is_file/is_dir/exists │ 跟随 │ 指向目录的链接 is_dir()=True │
│ Path.is_symlink() │ 不跟随 │ 内部用 lstat │
│ shutil.copy / copy2 │ ★跟随★ │ 复制★内容★,链接变实体文件 │
│ shutil.copytree │ symlinks=False → ★展开★ │ True 才保留为链接 │
│ shutil.rmtree │ 不跟随 │ 不会删到目录树外面(安全设计)│
│ os.walk │ followlinks=False │ ★默认不进入软链接目录★ │
│ os.scandir 的 DirEntry │ 跟随(可传参)│ 两种取值各有独立缓存 │
│ glob / Path.rglob │ 跟随 │ ★可能因链接成环而卡死★ │
└────────────────────────────┴──────────┴────────────────────────────┘
三个真实事故:
① 递归统计目录大小时不传 follow_symlinks=False
→ 软链接指向的大文件被★重复计入★;指向父目录的链接直接★无限递归★
✓ e.stat(follow_symlinks=False) + e.is_dir(follow_symlinks=False)
② os.walk 默认 followlinks=False 是★好事★,改成 True 之前想清楚:
一个指向 / 的软链接会让你遍历整个文件系统;成环会直接卡死
(os.walk 不做环检测,followlinks=True 时要自己用 (st_dev, st_ino) 去重)
③ 备份/同步工具用 shutil.copytree 默认展开软链接
→ 100 个指向同一个 5GB 文件的软链接变成 100 份实体拷贝(500GB)
✓ copytree(src, dst, symlinks=True) 保留链接原样
安全场景(不可信输入):
✗ 只检查"用户给的路径里有没有 .."
✓ resolve() 之后检查是否还在基准目录内:
target = (base / user_path).resolve()
if not target.is_relative_to(base.resolve()): raise
✓ 更严的做法:打开时拒绝符号链接
fd = os.open(path, os.O_RDONLY | os.O_NOFOLLOW) # ★链接则报 ELOOP★
follow_symlinks 的默认值不统一,这才是真正的陷阱源。几个最反直觉的:os.chmod 默认跟随——改的是目标文件的权限而不是链接的;shutil.copy 默认跟随——把软链接复制成了实体文件;shutil.copytree 默认 symlinks=False 即展开链接(100 个指向同一个 5GB 文件的软链接会变成 500GB 实体拷贝,这是备份工具的经典事故);而 os.walk 默认 followlinks=False 反倒是好事——改成 True 之前要想清楚:一个指向 / 的软链接会让你遍历整个文件系统,成环则直接卡死(os.walk 不做环检测)。做递归统计时必须成对地传 follow_symlinks=False 给 is_dir() 和 stat(),否则软链接指向的大文件被重复计入、指向父目录的链接导致无限递归。安全场景下的标准姿势是 resolve() 之后用 is_relative_to() 校验,更严格可以用 os.open(path, O_RDONLY | O_NOFOLLOW) 直接拒绝符号链接。
四、路径解析:normpath / absolute / resolve 的区别
三个函数,三种语义(★结果可能完全不同★):
os.path.normpath(p) / PurePath 的运算
★纯字符串处理★,不碰文件系统
"a/./b/../c" → "a/c"
★不解析符号链接、不检查存在性★
Path(p).absolute()
只是拼上 cwd,★不消除 .. 也不解析链接★
"link/sub" → "/cwd/link/sub"
os.path.realpath(p) / Path(p).resolve()
★真正查文件系统★:逐段解析符号链接、消除 ..、返回绝对路径
"link/sub" → "/abs/real/sub"
★ 为什么"字符串消除 .."是错的(核心陷阱):
假设 /app/link 是一个指向 /var/data 的软链接
normpath("/app/link/..") → "/app" ← ★字符串上退一级★
realpath("/app/link/..") → "/var" ← ★真实语义:先进 /var/data 再退一级★
→ 两者完全不同!用 normpath 做安全校验会被这种构造绕过
★ 这就是所有"路径穿越"防护必须用 resolve() 的原因
resolve 的细节:
Path.resolve() strict=False(默认,3.6+):路径不存在也返回一个解析结果
Path.resolve(strict=True) 不存在则 FileNotFoundError
★ 循环链接:resolve 会抛 OSError(ELOOP,"Too many levels of symbolic links")
内核对解析深度也有上限(Linux 通常 40 层)
★ Windows 上 resolve() 还会把短名(PROGRA~1)展开、修正大小写
比较路径是否相同的正确方式(三档严格性递增):
① str(a) == str(b) ✗ 太脆("a/b" != "./a/b")
② a.resolve() == b.resolve() ✓ 消除了 .. 和符号链接的差异
③ os.path.samefile(a, b) ✓✓ ★比较 (st_dev, st_ino),连硬链接都能识破★
(要求两个路径都存在)
判断"是否在某个目录下":
✓ Path(child).resolve().is_relative_to(Path(parent).resolve()) # 3.9+
✓ os.path.commonpath([parent, child]) == parent # 3.8 及以下
✗ str(child).startswith(str(parent)) ← ★"/data2" 会被误判为在 "/data" 下★
路径解析的三个函数语义完全不同,混用会出安全问题。os.path.normpath 只做字符串层面的处理(消除 .、..,不碰文件系统),absolute() 只是拼上 cwd(不消除 .. 也不解析链接),只有 realpath()/resolve() 会真正查文件系统逐段解析符号链接。核心陷阱在于:如果 /app/link 是指向 /var/data 的软链接,那么 normpath("/app/link/..") 得到 /app(字符串上退一级),而真实语义是「先进入 /var/data、再退一级」得到 /var——两者完全不同,用 normpath 做安全校验会被这种构造绕过,这就是所有路径穿越防护都必须用 resolve() 的原因。比较两个路径是否指向同一个东西也有三档:字符串相等(太脆)、resolve() 后相等(消除了 .. 和链接差异)、os.path.samefile()(比较 st_dev+st_ino,连硬链接都能识破)。判断「是否在某目录下」要用 is_relative_to() 而不能用 startswith(/data2 会被误判为在 /data 下)。
五、危险与边界:环、断链、跨设备
危险 1:符号链接成环
os.symlink("a", "b"); os.symlink("b", "a")
→ Path("a").resolve() 抛 OSError: [Errno 40] Too many levels of symbolic links
→ 更隐蔽的是"目录环":dir/sub/link → dir
os.walk(followlinks=True) 会★无限递归★(它不做环检测)
glob("**/*") 同样可能卡死
✓ 自己遍历时用 (st_dev, st_ino) 集合去重:
key = (st.st_dev, st.st_ino)
if key in seen: continue
seen.add(key)
危险 2:断链(dangling symlink)
软链接指向的目标被删/改名 → 链接还在,但访问就报 FileNotFoundError
★ 症状很迷惑:exists() 说 False,但 os.symlink 创建同名链接却报 FileExistsError
✓ 判断:os.path.lexists(p) and not os.path.exists(p) → 断链
✓ 清理:for e in os.scandir(d): if e.is_symlink() and not e.is_file(): 断链
危险 3:相对路径软链接被移动
os.symlink("../data/x.txt", "cfg/link") # 存的是相对路径
→ 把 cfg/ 整个移到别处,link 就指向了错误的位置(因为★相对于链接自身所在目录★解析)
✓ 需要稳定就存绝对路径;需要可迁移(整个树一起移动)就存相对路径——按场景选
危险 4:跨设备限制
os.link 跨文件系统 → OSError: [Errno 18] Invalid cross-device link
os.rename/os.replace 跨文件系统 → 同样报错!
✓ 跨设备移动用 shutil.move(内部会退化成"复制+删除",★不再是原子操作★)
✓ 判断是否同一设备:os.stat(a).st_dev == os.stat(b).st_dev
危险 5:符号链接攻击(TOCTOU 的经典形态)
攻击者预先在你要写的路径上放一个指向 /etc/passwd 的软链接
→ 你的程序(尤其以高权限运行时)就把内容写进了 /etc/passwd
✓ 创建时:os.open(p, O_CREAT | O_EXCL | O_WRONLY, 0o600) ★O_EXCL 拒绝已存在★
✓ 读取时:os.open(p, O_RDONLY | O_NOFOLLOW) ★遇到链接直接报错★
✓ 临时文件一律用 tempfile(内部已处理这些)
危险 6:解压/复制外来数据时的软链接
归档里可以带软链接 → 解压后再往"链接内"写文件就写到了目录外
✓ tarfile 用 filter="data"(3.12+);zipfile 自己校验成员类型和最终路径
这一节是实战中真会踩的六个坑。成环分两种:直接的 a→b→a 会在 resolve() 时抛 ELOOP,更隐蔽的是目录环——os.walk(followlinks=True) 和 glob("**/*") 都不做环检测,会无限递归,自己遍历时要用 (st_dev, st_ino) 集合去重。断链的症状很迷惑:exists() 返回 False 但创建同名链接却报 FileExistsError,判断方法是 lexists() 为真而 exists() 为假。相对路径软链接在整个目录被移动时会指向错误位置(因为它相对于链接自身所在目录解析)——要稳定就存绝对路径,要可迁移就存相对路径。跨设备限制不只影响 os.link,os.rename/os.replace 跨文件系统同样报错,要用 shutil.move(内部退化成复制+删除,不再原子)。最后是符号链接攻击:攻击者预先在你要写的路径上放一个指向 /etc/passwd 的链接,防御手段是创建用 O_EXCL、读取用 O_NOFOLLOW、临时文件一律用 tempfile。
六、实用场景与选型
场景 1:部署时的版本切换(软链接的经典用途)
releases/v1 releases/v2 releases/v3
current -> releases/v3 ← 一个软链接指向当前版本
发布新版本:
os.symlink("releases/v4", "current.tmp")
os.replace("current.tmp", "current") # ★原子切换,零停机★
回滚:同样两行,指回 v3
★ 注意:长期运行的进程可能已经打开了旧版本的文件(fd 指向 inode,不受改名影响),
所以切换后通常还要 reload/restart
场景 2:增量备份去重(硬链接的经典用途)
今天的备份里,没变化的文件★硬链接★到昨天的备份
→ 100 天的每日全量备份,磁盘只占 1 份 + 变化部分
→ rsync --link-dest、Time Machine 都是这个原理
✓ 前提:同一个文件系统;且要注意"修改任何一份都会影响所有链接"
(所以备份文件应该是只读的)
场景 3:判断两个路径是不是同一个文件
os.path.samefile(a, b) → 比较 (st_dev, st_ino)
→ 识破软链接、硬链接、"./a" vs "a"、绝对/相对混写
典型用途:防止 copy(src, dst) 时 src 和 dst 其实是同一个文件(会清空数据)
场景 4:统计"真实占用"时的去重
一个 inode 有多个硬链接时,du 只算一次
✓ seen = set(); key = (st.st_dev, st.st_ino)
if key not in seen: total += st.st_size; seen.add(key)
场景 5:找出所有断链
for root, dirs, files in os.walk(top):
for name in files + dirs:
p = os.path.join(root, name)
if os.path.islink(p) and not os.path.exists(p):
print("断链:", p, "->", os.readlink(p))
选型速查:
要"同一份数据的另一个名字"、同一设备、不怕删原文件 → 硬链接
要"指向目录"、要跨设备、要能指向不存在的路径 → 软链接
要"版本切换/别名"、要一眼看出指向哪 → 软链接
要"去重节省空间"、要删任意一份都不丢数据 → 硬链接
Windows 上要用 → ★优先考虑不用★(权限限制多)
实战中两种链接各有招牌用途:软链接的经典场景是部署时的版本切换(current -> releases/v3,用 symlink 到临时名 + os.replace 实现原子切换、零停机,回滚同样两行;注意长期运行的进程可能已持有旧版本的 fd,通常还要 reload)。硬链接的经典场景是增量备份去重——今天备份里没变化的文件硬链接到昨天的备份,100 天的每日全量备份磁盘只占一份加上变化部分(rsync --link-dest、Time Machine 都是这个原理),前提是同一文件系统且备份文件应设为只读(改任何一份都会影响所有链接)。另外两个高频小技巧:用 os.path.samefile() 防止 copy(src, dst) 时源和目标其实是同一个文件(会清空数据),以及统计磁盘占用时用 (st_dev, st_ino) 集合对硬链接去重(这正是 du 的做法)。
记忆钩子:「一句话抓住本质:★硬链接指向 inode(数据本体),软链接指向路径(一个名字)★。硬链接就是在目录里再加一条『名字→同一 inode』的映射,所以两个名字完全平等、没有『原件』之说,删除只是 st_nlink 减一、减到 0 且无进程持有 fd 才真正释放数据(这也解释了『删了大文件磁盘没释放』);但它★不能跨文件系统★(inode 号只在单个文件系统内唯一)、★不能对目录建★(会成环并破坏 .. 的唯一性)。软链接是一个『内容为路径字符串』的独立文件,访问时内核读出字符串★重新走一遍路径解析★,所以能跨设备、能指向目录、能指向不存在的路径,代价是目标一删一改名就★断链★(此时 exists() 是 False 但 lexists() 是 True,创建同名链接还会报 FileExistsError)。★真正的陷阱是 follow_symlinks 默认值不统一★:os.stat/os.chmod/shutil.copy ★默认跟随★(chmod 改的是目标的权限、copy 把链接变成实体文件、copytree 默认展开链接会让 100 个指向 5GB 文件的链接变成 500GB),而 os.remove/rename 永远作用于链接本身、os.walk 默认 followlinks=False;递归统计必须成对传 follow_symlinks=False,否则重复计数或无限递归。★路径解析三件套结果可能完全不同★:normpath 只做字符串上的 .. 消除、absolute 只拼 cwd、★只有 realpath/resolve 真正查文件系统解析链接★——若 /app/link 指向 /var/data,normpath(‘/app/link/..’) 得 /app 而 realpath 得 /var,所以★安全校验必须用 resolve() + is_relative_to()★(别用 startswith,/data2 会被误判在 /data 下)。防符号链接攻击:创建用 O_EXCL、读取用 O_NOFOLLOW、临时文件用 tempfile。跨设备时 os.link 和 ★os.rename/os.replace 都会报 EXDEV★,要用 shutil.move(退化成复制+删除、不再原子)。经典用途:软链接做 current->releases/vN 的原子版本切换、硬链接做增量备份去重(rsync —link-dest)。」
七、常见误区与追问
- 误区:删除硬链接的「原文件」会导致另一个链接失效。 硬链接根本没有「原文件」的概念——
os.link("a.txt", "b.txt")之后,两个名字完全平等地指向同一个 inode,文件系统无从区分谁先谁后。删除其中任何一个只是移除那条目录项并把st_nlink减一,数据一点没动,另一个名字照常可读可写。只有当st_nlink减到 0 且没有任何进程持有该文件的 fd 时,数据块才真正被释放——这也顺带解释了运维中的经典现象:rm掉了一个几十 GB 的日志文件但df显示磁盘没释放,因为还有进程打开着它(要重启进程或用truncate清空)。软链接则完全相反:它存的只是路径字符串,目标一删就断链。 - 误区:
os.chmod("link", 0o600)修改的是符号链接本身的权限。os.chmod默认follow_symlinks=True,改的是链接指向的目标文件的权限。这在安全场景里可能造成真实事故:你以为在收紧一个链接的权限,实际上改的是别处的真实文件。顺带一提,符号链接本身的权限位在 Linux 上是没有意义的(恒为0o777,访问控制完全由目标决定),所以 Linux 甚至不提供lchmod。同一类陷阱还有os.chown(默认跟随,但它支持follow_symlinks=False)和shutil.copy(默认跟随,会把软链接复制成实体文件)。写涉及链接的代码时,每一个文件 API 都要主动确认它的follow_symlinks默认值。 - 误区:
Path(p).exists()返回False就说明这个路径上什么都没有。exists()会跟随符号链接去看目标,所以一个断链(目标已被删除的软链接)会让exists()返回False——但链接本身实实在在地占着那个路径。由此产生一个非常迷惑的现象:程序判断「文件不存在」于是去创建,却收到FileExistsError。正确的判断方式是os.path.lexists(p)(不跟随)或Path(p).is_symlink():lexists()为真而exists()为假就是断链。同类的还有os.path.isfile/isdir都跟随链接——想区分「真目录」和「指向目录的软链接」必须用is_symlink()或os.lstat()。 - 误区:用
os.path.normpath()消除路径里的..就能防住路径穿越。normpath是纯字符串处理,它按「一个..抵消前一段」的规则化简,完全不查文件系统。但真实的路径解析语义是逐段进入目录——如果/app/link是指向/var/data的软链接,那么normpath("/app/link/..")得到/app,而实际访问的是/var(先进入/var/data再退一级)。攻击者正是利用这个差异绕过基于字符串的校验。所有安全校验必须用Path.resolve()或os.path.realpath()(它们真正查文件系统、逐段解析符号链接),然后用is_relative_to()判断是否还在基准目录内——也不能用str.startswith(),因为/data2/x会被误判为在/data之下。 - 误区:
shutil.copytree会原样复制目录树里的符号链接。 默认symlinks=False,即「展开」链接——把每个软链接替换成它指向的文件的完整副本。后果可能非常严重:一个包含 100 个指向同一个 5GB 文件的软链接的目录,复制后会变成 500GB 实体数据;如果链接指向目录树外部(甚至指向/),复制范围会失控;链接成环时还会无限递归。要保留链接原样必须显式传symlinks=True。同一族的还有shutil.copy/copy2(默认跟随,可用follow_symlinks=False复制成新链接)。反过来,shutil.rmtree默认不跟随链接(不会删到目录树外面),而且对「指向目录的软链接」直接抛NotADirectoryError——这是有意的安全设计,要删链接本身应该用os.remove()。 - 追问:为什么硬链接不能跨文件系统,也不能对目录创建? 不能跨文件系统是因为目录项存的是 inode 号,而 inode 号只在单个文件系统内唯一——一个设备上的目录项无法引用另一个设备上的 inode(内核会返回
EXDEV: Invalid cross-device link)。软链接没有这个限制,因为它存的是路径字符串,解析发生在路径层面。不能对目录建硬链接(普通用户)有两个原因:① 会形成目录环,使文件系统从「树」变成「有环图」,find、rm -r、备份工具等假设树形结构的程序会无限递归;② 破坏..的唯一性——每个目录的..指向唯一的父目录,一个目录有两个父目录时..就无从确定(.和..本身其实就是内核维护的硬链接,属于唯一的例外,这也是空目录st_nlink为 2 的原因)。顺带记住os.rename/os.replace同样不能跨文件系统,跨设备移动要用shutil.move(内部退化成复制+删除,不再是原子操作)。 - 追问:软链接存绝对路径好还是相对路径好? 取决于「这棵目录树会不会被整体移动」。相对路径(
os.symlink("../data/x.txt", "cfg/link"))是相对于链接自身所在的目录解析的,所以整棵树被打包、复制、移动到别处时链接依然有效——这对项目内部的链接、要打进容器镜像或归档的目录结构是必需的。绝对路径(/srv/app/data/x.txt)则在链接自身被移动时依然指向同一个目标,适合「指向系统固定位置」的场景(如/etc/alternatives里的链接),但一旦整棵树被移动或挂载点变化(容器里尤其常见)就会失效。两个补充:os.readlink()返回的是存进去的原始字符串(不做解析),要得到最终目标应该用os.path.realpath();创建相对链接时要注意当前工作目录不影响解析——解析基准始终是链接文件所在的目录,所以os.symlink("../x", "a/b/link")里的../x是相对于a/b/而不是相对于 cwd。 - 追问:怎么安全地遍历一个可能含符号链接的目录树? 三条规则。① 默认不跟随:
os.walk的followlinks保持默认False;用os.scandir时成对地传follow_symlinks=False给is_dir()和stat()(只传一个仍会出错)。② 必须跟随时要做环检测:os.walk(followlinks=True)不做任何环检测,一个指向祖先目录的软链接就会导致无限递归、一个指向/的链接会让你遍历整个文件系统;自己维护一个visited集合,用(st_dev, st_ino)作为键(不能用路径字符串,因为同一目录可以有无数种路径写法)。③ 边界校验:处理不可信目录时,对每个即将访问的路径做resolve()并用is_relative_to(base)确认没跑出基准目录。另外统计大小时用(st_dev, st_ino)集合对硬链接去重(这正是du的做法),否则同一份数据会被重复计入;glob/Path.rglob的**同样会跟随符号链接且可能因成环卡死,处理不可信目录时要格外小心。
八、加强记忆
一句话抓住本质:硬链接指向 inode(数据本体),软链接指向路径(一个名字)。 硬链接就是在目录里再加一条「名字 → 同一 inode」的映射,所以两个名字完全平等、没有「原件」之说;删除只是 st_nlink 减一,减到 0 且无进程持有 fd 才真正释放数据(这解释了「删了大文件但磁盘没释放」);但它不能跨文件系统(inode 号只在单个文件系统内唯一,报 EXDEV)、不能对目录创建(会成环并破坏 .. 的唯一性)。软链接是一个「内容为路径字符串」的独立文件,访问时内核读出字符串重新走一遍路径解析,因此能跨设备、能指向目录、能指向不存在的路径,代价是目标被删或改名就断链(此时 exists() 为 False 但 lexists() 为 True,创建同名链接还会报 FileExistsError)。真正的陷阱是 follow_symlinks 默认值不统一:os.stat/os.chmod/shutil.copy 默认跟随(chmod 改的是目标的权限、copy 把链接变成实体文件、copytree 默认 symlinks=False 会展开链接——100 个指向 5GB 文件的链接变成 500GB),而 os.remove/rename 永远作用于链接本身、os.walk 默认 followlinks=False;递归统计时必须成对传 follow_symlinks=False,否则重复计数或无限递归。路径解析三件套结果可能完全不同:normpath 只做字符串上的 .. 消除、absolute() 只拼 cwd、只有 realpath/resolve 真正查文件系统解析链接——若 /app/link 指向 /var/data,则 normpath("/app/link/..") 得 /app 而 realpath 得 /var,所以安全校验必须 resolve() + is_relative_to()(别用 startswith,/data2 会被误判在 /data 下)。防符号链接攻击:创建用 O_EXCL、读取用 O_NOFOLLOW、临时文件一律用 tempfile。跨设备时 os.link 和 os.rename/os.replace 都会失败,要用 shutil.move(退化成复制+删除、不再原子)。经典用途:软链接做 current -> releases/vN 的原子版本切换(symlink 到临时名 + os.replace)、硬链接做增量备份去重(rsync --link-dest,同一文件系统内,备份文件应只读)。