怎么获取文件大小、修改时间等元信息?os.stat 有哪些坑?
简化版
文件的「元信息」(大小、时间戳、权限、inode 等)全都来自一次 stat 系统调用,Python 的入口是 os.stat(path) 或 Path(p).stat(),返回一个 os.stat_result 对象。最常用的四个字段:st_size(字节数)、st_mtime(内容最后修改时间,Unix 时间戳浮点数)、st_mode(文件类型 + 权限位)、st_ino/st_dev(inode 号 + 设备号,两者合起来唯一标识一个文件)。三个时间戳里最容易搞错的是 st_ctime:在 Unix 上是「inode 元数据变更时间」(改权限、改名都会更新,不是创建时间!),在 Windows 上才是创建时间;想拿创建时间应该用 st_birthtime(macOS/BSD,Python 3.12 起部分平台可用)。几个必须知道的坑:① 每次 exists()/is_file()/getsize() 都是一次独立的系统调用——遍历大目录时逐个调用会很慢,应该用 os.scandir()(DirEntry 缓存了 stat 结果);② stat() 默认跟随符号链接,要看链接本身用 os.lstat() 或 Path.lstat();③ 时间戳是浮点数、有精度损失,做「文件有没有变」的精确比较要用 st_mtime_ns(整数纳秒);④ 「先检查再操作」(LBYL)有 TOCTOU 竞态——检查完到真正打开之间文件可能已被删除或替换,正确姿势是直接 try: open() except FileNotFoundError。核心记忆:元信息一次 stat 全拿到;ctime 不是创建时间;遍历用 scandir 省 syscall;精确比较用 _ns。
详细版
os.stat_result 常用字段:
| 字段 | 含义 | 备注 |
|---|---|---|
st_size | 字节数 | 目录的 size 没有意义(不是内容总和) |
st_mtime / st_mtime_ns | 内容最后修改时间 | 浮点秒 / 整数纳秒(精确比较用后者) |
st_atime | 最后访问时间 | 多数系统挂载了 relatime,不精确 |
st_ctime | Unix:inode 变更时间;Windows:创建时间 | 不是创建时间(Unix) |
st_birthtime | 创建时间 | macOS/BSD;Linux 上多数文件系统没有 |
st_mode | 文件类型 + 权限位 | 配合 stat.S_ISDIR()、stat.filemode() |
st_ino / st_dev | inode 号 / 设备号 | 两者合起来唯一标识一个文件 |
st_nlink | 硬链接数 | >1 说明有硬链接 |
st_uid / st_gid | 属主 / 属组 | Windows 上恒为 0 |
import os, stat, time
from pathlib import Path
p = Path("report.csv")
st = p.stat() # 等价 os.stat(p),★一次系统调用拿到全部字段★
# ① 常用字段
print(st.st_size) # 1024(字节)
print(time.strftime("%F %T", time.localtime(st.st_mtime))) # 2026-07-31 12:00:00
print(oct(st.st_mode & 0o777)) # 0o644(权限位)
print(stat.filemode(st.st_mode)) # '-rw-r--r--'(ls -l 那种)
print(st.st_ino, st.st_dev) # inode + 设备号
# ② 判断类型:别对每个判断都调一次 stat
print(stat.S_ISDIR(st.st_mode), stat.S_ISREG(st.st_mode), stat.S_ISLNK(st.st_mode))
# pathlib 的封装(★每个都是一次独立的 syscall★)
print(p.is_file(), p.is_dir(), p.is_symlink())
# ③ 符号链接:stat 跟随、lstat 不跟随
os.symlink("report.csv", "link.csv")
print(os.stat("link.csv").st_size) # 1024 ← ★指向的目标的大小★
print(os.lstat("link.csv").st_size) # 11 ← 链接本身(存的是路径字符串长度)
# ④ 精确比较用纳秒(★float 秒会丢精度★)
a, b = Path("a.txt").stat(), Path("b.txt").stat()
print(a.st_mtime == b.st_mtime) # 浮点比较,可能因精度误判
print(a.st_mtime_ns == b.st_mtime_ns) # ✓ 整数比较,精确
# ⑤ 遍历大目录:scandir 复用 stat 结果,比 listdir + stat 快得多
total = 0
with os.scandir("logs") as it:
for e in it: # e 是 DirEntry
if e.is_file(): # ★用的是 readdir 已返回的缓存,多数情况零 syscall★
total += e.stat().st_size # ★DirEntry.stat() 也有缓存★
print(total)
# ⑥ 判断"同一个文件"(软链接、硬链接、不同路径写法都能识破)
print(os.path.samefile("a.txt", "link_to_a.txt")) # True
# 原理就是比较 (st_dev, st_ino)
print((a.st_dev, a.st_ino) == (b.st_dev, b.st_ino))
# ⑦ ★TOCTOU:别先检查再操作★
# ✗ if p.exists(): data = p.read_bytes() # 两次调用之间文件可能被删
# ✓ try:
# data = p.read_bytes()
# except FileNotFoundError:
# data = b""
⚠️ 三个最容易错的点:①
st_ctime在 Linux/macOS 上不是「创建时间」,而是「inode 变更时间(change time)」——改权限(chmod)、改属主、改名、增删硬链接都会更新它,而内容修改也会更新它(因为 size/mtime 变了)。所以它永远 ≥st_mtime,用它当创建时间会得到完全错误的结果。Windows 上st_ctime才是创建时间;macOS/BSD 有真正的st_birthtime;Linux 上大多数场景根本拿不到创建时间(ext4 存了 crtime 但标准接口不暴露,Python 3.12 起部分平台可通过st_birthtime获取)。② 每个exists()/is_file()/is_dir()/getsize()都是一次独立的系统调用:一个 5 万文件的目录,逐个调用四次判断就是 20 万次 syscall(几秒钟),而os.scandir()返回的DirEntry在readdir时就带回了类型信息、并缓存stat()结果,同样的遍历能快 5~10 倍。③ 目录的st_size没有实际意义——它是目录项本身占用的空间(常见 4096),不是里面所有文件的大小之和;要算目录总大小必须递归遍历累加。
完整版教学
一、一次 stat 拿到全部:元信息从哪来
文件系统里,"文件内容"和"文件元信息"是分开存的:
目录项(dentry): 文件名 → inode 号
inode: ★大小、时间戳、权限、属主、链接数、数据块指针★
数据块: 真正的内容
→ 所以 os.stat(path) 做的事是:解析路径 → 找到 inode → 把 inode 里的字段读出来
→ 这是★一次系统调用★,一次拿到全部字段(不是每个字段查一次)
正确姿势:一次 stat,多次用
✗ if p.is_file() and p.stat().st_size > 0 and time.time() - p.stat().st_mtime < 60:
→ 三次 stat 系统调用,而且★三次看到的可能是不同状态★(文件在中间被改了)
✓ st = p.stat() # 一次
if stat.S_ISREG(st.st_mode) and st.st_size > 0 and time.time() - st.st_mtime < 60:
→ 一次 syscall,且是★同一个瞬间的一致快照★
三个入口的关系:
os.stat(path) 跟随符号链接(软链接 → 看目标)
os.lstat(path) ★不跟随★(看链接本身)
os.fstat(fd) ★对已打开的文件描述符★(不受路径被改名/删除影响,最安全)
Path(p).stat() = os.stat;Path(p).lstat() = os.lstat
f.fileno() → os.fstat(f.fileno()) 打开后再 stat,避免 TOCTOU
os.path 的老式封装(本质都是 os.stat 的包装):
os.path.getsize(p) = os.stat(p).st_size
os.path.getmtime(p) = os.stat(p).st_mtime
os.path.exists(p) = 尝试 stat,成功即 True(★注意:断掉的软链接返回 False★)
os.path.lexists(p) = 尝试 lstat(断掉的软链接也返回 True)
理解 stat 的前提是文件系统的结构:文件名存在目录项里,元信息存在 inode 里,内容存在数据块里。os.stat(path) 做的事就是「解析路径 → 找到 inode → 把里面的字段全读出来」,这是一次系统调用、一次拿到全部字段。由此得出最重要的使用准则:一次 stat、多次使用——写成 if p.is_file() and p.stat().st_size > 0 and ... 会触发三次系统调用,而且三次看到的可能是不同时刻的状态(文件在中间被改了),正确写法是先 st = p.stat() 拿到一个一致的快照再判断。三个入口要分清:os.stat 跟随符号链接、os.lstat 不跟随(看链接本身)、os.fstat(fd) 作用于已打开的文件描述符——后者最安全,因为它不受路径被改名或删除的影响。
二、三个时间戳:ctime 到底是什么
Unix 语义(★面试高频★):
st_atime(access) 最后一次★读取★内容的时间
st_mtime(modify) 最后一次★修改内容★的时间 ← 最常用
st_ctime(change) 最后一次★inode 变更★的时间 ← ★不是 create!★
什么会更新 ctime(而不更新 mtime):
chmod / chown / 改名(rename)/ 创建或删除硬链接 / 修改扩展属性
什么会同时更新 mtime 和 ctime:
写入内容(size 变了 → inode 变了)
★ 推论:ctime 永远 >= mtime;且 ctime ★无法被伪造★(os.utime 只能改 atime/mtime)
→ 取证/审计里 ctime 比 mtime 可信
Windows 语义(★完全不同★):
st_ctime = ★创建时间★(creation time)
→ 同一段代码在两个平台上语义不同,写跨平台工具必须注意
创建时间怎么拿:
macOS / FreeBSD:st_birthtime ✓
Windows: st_ctime(或 3.12+ 的 st_birthtime)✓
Linux: ★多数情况拿不到★
(ext4 的 inode 里存了 crtime,但没有标准 syscall 暴露;
较新内核的 statx() 可以拿到,Python 3.12 起部分平台支持 st_birthtime)
→ 跨平台工具★不要依赖创建时间★,改用 mtime 或自己在数据库里记录
atime 的可靠性问题:
更新 atime 意味着"每次读文件都要写一次磁盘" → 性能灾难
→ 现代系统默认挂载选项是 relatime:只在
① atime < mtime/ctime,或 ② atime 超过 24 小时未更新
时才更新
→ ★atime 不能用来精确判断"文件最近被读过"★;有的系统甚至挂 noatime 完全关闭
修改时间戳:
os.utime(path, (atime, mtime)) 秒(float)
os.utime(path, ns=(atime_ns, mtime_ns)) ★纳秒(整数)★
os.utime(path, None) 设为当前时间(touch 的效果)
★ 无法设置 ctime(这是设计上的安全保证)
三个时间戳的语义是这道题最高频的考点。Unix 上:atime 是最后读取时间、mtime 是最后修改内容的时间、ctime 是最后 inode 变更的时间——ctime 里的 c 是 change 不是 create。区分方法是看什么操作会更新它:chmod、chown、改名、增删硬链接只更新 ctime 不更新 mtime;而写入内容会同时更新两者(因为 size 变了)。由此得出两个推论:ctime 永远 ≥ mtime,以及 ctime 无法被伪造(os.utime 只能改 atime/mtime),所以取证和审计场景里 ctime 比 mtime 可信。Windows 上 st_ctime 却是创建时间,同一段代码两个平台语义不同,跨平台工具必须小心。想要创建时间:macOS/BSD 用 st_birthtime、Windows 用 st_ctime,而 Linux 上大多数情况根本拿不到——所以跨平台工具应该改用 mtime 或自己记录。最后 atime 也不可靠:现代系统默认 relatime 挂载(只在特定条件下才更新),有的甚至 noatime 完全关闭。
三、st_mode:类型与权限打包在一个整数里
st_mode 是一个整数,高位存"文件类型"、低 12 位存"权限":
0o100644 = 0o100000(普通文件)| 0o644(rw-r--r--)
↑ 类型位 ↑ setuid/setgid/sticky(3 位)+ 权限(9 位)
文件类型常量(stat 模块):
S_IFREG 0o100000 普通文件 S_ISREG(m)
S_IFDIR 0o040000 目录 S_ISDIR(m)
S_IFLNK 0o120000 符号链接 S_ISLNK(m) ★只有 lstat 才看得到★
S_IFIFO 0o010000 命名管道 S_ISFIFO(m)
S_IFSOCK 0o140000 套接字 S_ISSOCK(m)
S_IFCHR / S_IFBLK 字符/块设备 S_ISCHR / S_ISBLK
取权限:st.st_mode & 0o777 → 0o644
取类型:stat.S_IFMT(st.st_mode) → 0o100000
人类可读:stat.filemode(st.st_mode) → '-rw-r--r--'
三种判断类型的方式,成本不同:
① st = p.stat(); stat.S_ISDIR(st.st_mode) 1 次 syscall(★已有 st 时零成本★)
② p.is_dir() 1 次 syscall(每次都调)
③ entry.is_dir()(scandir 的 DirEntry) ★多数情况 0 次★(readdir 已带回类型)
一个高频误判:符号链接的类型
os.stat("link") → 看到的是★目标★的类型(指向目录就是 S_IFDIR)
os.lstat("link") → 看到的是 S_IFLNK
Path.is_dir() → ★跟随链接★(指向目录的链接返回 True)
Path.is_symlink()→ 内部用 lstat
→ 想区分"真目录"和"指向目录的软链接",必须用 lstat / is_symlink()
Windows 上的差异:
st_mode 的权限位是"模拟"出来的(只有只读位真正生效)
st_uid / st_gid 恒为 0;chmod 只能切换只读属性
→ 权限相关的逻辑写跨平台代码时要做平台分支
st_mode 把文件类型和权限打包在一个整数里:高位是类型(S_IFREG、S_IFDIR、S_IFLNK…),低 12 位是 setuid/setgid/sticky 加九个权限位。常用操作是 st.st_mode & 0o777 取权限、stat.S_IFMT() 取类型、stat.filemode() 生成 -rw-r--r-- 这种人类可读形式。判断类型有三种方式,成本差别很大:已经有 st 时用 stat.S_ISDIR(st.st_mode) 是零额外成本;p.is_dir() 每次都是一次 syscall;而 scandir 的 DirEntry.is_dir() 多数情况下零系统调用(readdir 已经带回了类型信息)。这里有个高频误判:stat 跟随符号链接,所以指向目录的软链接用 is_dir() 会返回 True——想区分「真目录」和「指向目录的软链接」必须用 lstat 或 is_symlink()。Windows 上还要注意权限位是模拟出来的(只有只读位真正生效)、st_uid/st_gid 恒为 0。
四、遍历大目录:scandir 为什么快得多
三种遍历方式的系统调用次数(目录里 50000 个文件,需要"名字 + 是否是文件 + 大小"):
① os.listdir + os.path.isfile + os.path.getsize
1 次 readdir + 50000 × 2 次 stat = ★100001 次 syscall★
② os.scandir + entry.is_file() + entry.stat().st_size
若干次 readdir + ★接近 0 次额外 stat★(Linux 上 readdir 返回了 d_type;
stat 结果被 DirEntry 缓存)
③ pathlib.Path.iterdir() + p.is_file() + p.stat().st_size
和 ① 类似(iterdir 返回的是 Path,★没有缓存★)
实测量级:② 比 ① 快 ★5~10 倍★(网络文件系统 NFS/SMB 上差距更大,可达几十倍)
DirEntry 的缓存规则(要记准):
entry.name / entry.path ★零成本★(readdir 就有)
entry.is_dir() / is_file() Linux 上多数文件系统零成本(d_type);
否则退化成一次 stat,★但结果会被缓存★
entry.stat() 第一次调用触发 stat,★之后复用缓存★
entry.stat(follow_symlinks=False) 和上面是★两份独立的缓存★
★ 缓存意味着"数据可能过期"——长时间遍历时看到的是快照,不是实时状态
os.walk 的现代形态:
os.walk() 内部已经用 scandir 实现(3.5+),所以本身不慢
✗ 但循环里对每个文件再 os.path.getsize(os.path.join(root, name)) → 又变回逐个 stat
✓ 需要元信息时改用 os.scandir 手写递归,或用 os.walk 的同时接受这个成本
算目录总大小的标准写法:
def dir_size(path):
total = 0
with os.scandir(path) as it:
for e in it:
if e.is_file(follow_symlinks=False): # ★不跟随,避免重复统计/循环★
total += e.stat(follow_symlinks=False).st_size
elif e.is_dir(follow_symlinks=False):
total += dir_size(e.path)
return total
★ 注意:st_size 是"逻辑大小",稀疏文件的实际占用要看 st_blocks * 512
遍历性能是这道题最有工程价值的部分。用「50000 个文件的目录、需要名字+类型+大小」做基准:os.listdir + isfile + getsize 要十万次系统调用,而 os.scandir 接近零次额外 stat——因为 Linux 的 readdir 直接返回了文件类型(d_type),而且 DirEntry.stat() 的结果会被缓存。实测能快 5~10 倍,在 NFS/SMB 网络文件系统上差距可达几十倍。DirEntry 的缓存规则要记准:name/path 零成本、is_dir()/is_file() 多数情况零成本、stat() 第一次触发后复用缓存(且 follow_symlinks=True/False 是两份独立缓存)——代价是数据可能过期(长时间遍历看到的是快照)。另外要注意:os.walk 内部已经用 scandir 实现所以本身不慢,但如果你在循环里再对每个文件 getsize(),就又退回逐个 stat 了。算目录总大小的标准写法要用 follow_symlinks=False(避免重复统计和链接成环)。
五、时间精度、比较与「文件变了吗」
精度问题:
st_mtime 是 float(秒)→ 双精度浮点数,在 2026 年的时间戳量级(约 1.8e9)下
能表示的最小间隔约 ★2^-22 秒 ≈ 238 纳秒★,看似够用,但:
- 从 float 转回来做等值比较时可能因舍入误判
- 不同来源(stat vs 数据库里存的字符串)来回转换会累积误差
✓ 精确比较一律用 st_mtime_ns(★整数纳秒★,无精度损失)
文件系统本身的时间粒度(比 Python 的精度更关键):
ext4 纳秒(inode 有 256 字节时)
ext3 ★1 秒★
HFS+(旧 mac)★1 秒★
FAT32 ★2 秒★(mtime),atime 只有日期
NFS 取决于服务端和缓存策略,可能★不单调★
→ 推论:★"1 秒内的两次修改可能有完全相同的 mtime"★
这就是 make/构建系统"改了文件却不重新编译"的经典坑
判断"文件是否变化"的四种方法(可靠性递增):
① mtime 相同就认为没变 最快,★但秒级粒度会漏判★
② (mtime_ns, size) 都相同 ★性价比最高★,绝大多数场景够用
③ (dev, ino, mtime_ns, size) 再加上"是不是同一个文件"(防替换)
④ 内容哈希(sha256) ★最可靠但要读全部内容★
→ 构建工具的常见做法:先用 ② 快速筛,命中可疑再用 ④ 确认
时间戳的其他坑:
- ★时区★:st_mtime 是 Unix 时间戳(UTC 语义),
time.localtime() 按本机时区转换、time.gmtime() 按 UTC
→ 存数据库/传接口时统一转成 aware 的 UTC datetime:
datetime.fromtimestamp(st.st_mtime, tz=timezone.utc)
- ★复制会改 mtime★:shutil.copy 不保留时间戳,shutil.copy2 才保留
- 容器/虚拟机的时钟漂移可能导致 mtime "在未来"
- 跨文件系统移动(mv 到另一个分区)实际是"复制+删除",元信息可能变
判断"是不是同一个文件"(不是内容相同,是同一个 inode):
os.path.samefile(a, b) → 比较 (st_dev, st_ino)
→ 能识破:软链接、硬链接、"./a.txt" 和 "a.txt"、绝对/相对路径混写
★ Windows 上也支持(用文件 ID),但网络盘上可能不可靠
时间比较有两层精度问题。第一层是 Python 的 float:st_mtime 是双精度浮点,在当前时间戳量级下最小可表示间隔约 238 纳秒,来回转换会累积误差——精确比较一律用 st_mtime_ns(整数纳秒)。第二层更关键,是文件系统本身的时间粒度:ext3 和 HFS+ 只有1 秒、FAT32 甚至是 2 秒——这意味着「1 秒内的两次修改可能有完全相同的 mtime」,正是构建系统「改了文件却不重新编译」这个经典 bug 的根源。判断「文件变了吗」有四档可靠性:只比 mtime(会漏判)、比 (mtime_ns, size)(性价比最高)、再加 (dev, ino) 防替换、最后是内容哈希(最可靠但要读全部内容)——构建工具的通行做法是先用第二档快速筛、可疑的再用哈希确认。另外三个易忘点:shutil.copy 不保留时间戳、copy2 才保留;st_mtime 是 UTC 语义的时间戳,存库前应转成 aware 的 UTC datetime;判断「是不是同一个文件」用 os.path.samefile()(本质是比较 (st_dev, st_ino)),能识破软链接、硬链接和各种路径写法。
六、TOCTOU 与实用场景
★ TOCTOU(Time-Of-Check to Time-Of-Use):检查和使用之间存在竞态窗口
✗ LBYL(Look Before You Leap):
if os.path.exists(p):
with open(p) as f: ... # ← 这中间文件可能被删除/替换/换成软链接
→ 轻则 FileNotFoundError(白检查了),重则被攻击者用符号链接指向 /etc/passwd
✓ EAFP(Easier to Ask Forgiveness than Permission):
try:
with open(p) as f: ...
except FileNotFoundError:
...
→ ★打开这个动作本身是原子的★,拿到 fd 后就不再受路径变化影响
安全敏感场景更进一步:
fd = os.open(p, os.O_RDONLY | os.O_NOFOLLOW) # ★拒绝符号链接★
st = os.fstat(fd) # ★对 fd 做 stat,不会被换掉★
if st.st_size > LIMIT: ...
★ os.access() 尤其危险:它按★真实 uid★检查权限(不是有效 uid),
而且检查完到使用之间同样有窗口 → 官方文档明确不建议用它做安全判断
实用场景速查:
① 找出目录里最大的 10 个文件
import heapq, os
files = ((e.stat().st_size, e.path) for e in os.scandir(d) if e.is_file())
print(heapq.nlargest(10, files)) # ★Top-K 用堆,别全排序★
② 清理 N 天前的日志
cutoff = time.time() - 30 * 86400
for e in os.scandir(logdir):
if e.is_file() and e.stat().st_mtime < cutoff:
os.remove(e.path)
★ 用 mtime 不用 atime(atime 因 relatime 不可靠)
③ 判断文件是否"写完了"(另一个进程正在写)
✗ 没有可靠的通用方法(文件系统不提供"正在写"标志)
✓ 约定:写临时文件 → os.replace 原子改名(见"原子写入"专题)
✓ 折中:两次 stat 间隔几秒,size 和 mtime_ns 都不变则认为写完了
④ 人类可读的大小
def human(n):
for unit in ("B","KB","MB","GB","TB"):
if n < 1024: return f"{n:.1f}{unit}"
n /= 1024
★ 注意 KB(1000) vs KiB(1024) 的区别,展示给用户时说清楚
⑤ 逻辑大小 vs 实际占用
st_size ★逻辑大小★(稀疏文件可能报 10GB 但只占 1MB)
st_blocks * 512 ★实际占用的磁盘块★(du 命令看到的就是这个)
→ 统计磁盘用量要用后者
最后是安全和实战。TOCTOU(检查与使用之间的竞态) 是文件操作的通用陷阱:if os.path.exists(p): open(p) 中间存在窗口,文件可能被删除、替换、或换成指向 /etc/passwd 的符号链接——轻则报错,重则被攻击。Python 的推荐范式是 EAFP:直接 try: open() except FileNotFoundError,因为打开这个动作本身是原子的,拿到 fd 后就不再受路径变化影响;安全敏感场景还可以用 os.open(p, O_RDONLY | O_NOFOLLOW) 拒绝符号链接、再对 fd 做 os.fstat。特别提醒:os.access() 按真实 uid 检查、且同样有竞态窗口,官方明确不建议用它做安全判断。实用场景里有两个细节值得记:找最大的 N 个文件用 heapq.nlargest 而不是全排序;统计磁盘用量要用 st_blocks * 512(实际占用)而不是 st_size(逻辑大小)——稀疏文件可能报 10GB 却只占 1MB。至于「判断文件是否写完了」,文件系统根本不提供这个信息,正解是约定用「写临时文件 + 原子改名」。
记忆钩子:「文件元信息全部来自★一次 stat 系统调用★(os.stat / Path.stat),所以准则是『一次 stat、多次用』——写成
p.is_file() and p.stat().st_size>0 and p.stat().st_mtime...是三次 syscall 且三次看到的可能是不同状态。三个入口:stat 跟随软链接、★lstat 不跟随★、fstat 作用于已打开的 fd(最安全)。★最高频考点:st_ctime 的 c 是 change 不是 create★——Unix 上它是『inode 变更时间』(chmod/chown/改名/增删硬链接都会更新,所以 ctime 永远 ≥ mtime,且 os.utime 改不了它,取证时比 mtime 可信);Windows 上它才是创建时间;Linux 上多数情况★根本拿不到创建时间★(macOS/BSD 用 st_birthtime)。atime 也不可靠(现代系统 relatime 挂载)。★遍历大目录必用 os.scandir★:DirEntry 的 name/path 零成本、is_dir/is_file 在 Linux 上靠 readdir 的 d_type 零成本、stat() 结果被缓存,5 万文件比 listdir+isfile+getsize 快 5~10 倍(网络盘几十倍)。★精确比较用 st_mtime_ns(整数纳秒)★,因为 float 有精度损失,更因为文件系统本身粒度可能是 1 秒(ext3/HFS+)甚至 2 秒(FAT32)——『1 秒内的两次修改 mtime 完全相同』就是构建系统漏编译的根源;判断文件是否变化的性价比方案是比 (mtime_ns, size)。★别 LBYL★:if exists(): open()有 TOCTOU 竞态(文件可能被删或换成软链接),要 EAFP 直接 try/except;os.access() 按真实 uid 检查且有窗口,官方不建议用于安全判断。其他易错:目录的 st_size 不是内容总和、稀疏文件要看 st_blocks*512、shutil.copy 不保留时间戳(copy2 才保留)、判断『是不是同一个文件』用 os.path.samefile(比较 dev+ino)。」
七、常见误区与追问
- 误区:
st_ctime是文件的创建时间。 在 Linux/macOS 上它是「inode 变更时间(change time)」——chmod、chown、改名、增删硬链接、修改扩展属性都会更新它,而写入内容因为改变了 size 和 mtime,也会连带更新 ctime。所以ctime永远 ≥mtime,把它当创建时间会得到完全错误的结论(一个 2020 年创建、昨天改过权限的文件,ctime 是昨天)。Windows 上st_ctime才是创建时间,同一段代码跨平台语义不同。真正的创建时间:macOS/BSD 用st_birthtime,Linux 上大多数场景拿不到(ext4 的 inode 里有 crtime,但标准stat()不暴露;较新内核的statx()可以,Python 3.12 起部分平台支持st_birthtime)。跨平台工具应该改用 mtime,或者自己在数据库里记录创建时间。附带一个有用的性质:ctime无法被伪造(os.utime只能改 atime/mtime),所以审计场景里它比 mtime 可信。 - 误区:
Path.exists()、is_file()、getsize()这些调用很轻量,可以随便调。 每一次都是一次独立的系统调用(用户态陷入内核、解析路径、读 inode)。单次确实只有微秒级,但遍历一个 5 万文件的目录、每个文件调四次判断,就是 20 万次系统调用——本地盘要几秒,网络文件系统(NFS/SMB)上可能要几分钟(每次调用都是一次网络往返)。正确做法有两条:① 已经有stat_result时就复用它(用stat.S_ISDIR(st.st_mode)而不是再调p.is_dir());② 遍历目录一律用os.scandir()——DirEntry的name/path零成本,is_dir()/is_file()在 Linux 上靠readdir返回的d_type零成本,stat()结果还会被缓存,整体能快 5~10 倍。 - 误区:
os.stat()得到的就是文件本身的信息。os.stat()默认跟随符号链接——对一个指向 1GB 大文件的软链接调用stat(),st_size会显示 1GB(目标的大小),is_dir()对「指向目录的软链接」也返回True。想看链接本身必须用os.lstat()或Path.lstat()(此时st_size是链接里存的路径字符串长度,类型是S_IFLNK)。这个区别在两类场景里会出真问题:① 递归统计目录大小时不用follow_symlinks=False会重复统计、甚至因链接成环而无限递归;② 安全检查时只看stat()会被攻击者用软链接绕过(检查的是无害的目标,实际操作却落在别处)。os.scandir的is_dir()/stat()同样接受follow_symlinks参数,且两种取值各自维护独立的缓存。 - 误区:比较两个文件的
st_mtime相等,就说明内容没变。 两层问题。① 浮点精度:st_mtime是 float 秒,来回转换(存进数据库再读出来、转成字符串再解析)会累积误差导致误判——精确比较应该用st_mtime_ns(整数纳秒)。② 更关键的是文件系统本身的时间粒度:ext3 和旧版 HFS+ 只有 1 秒精度,FAT32 是 2 秒——也就是说「1 秒内的两次修改会得到完全相同的 mtime」,这正是make这类构建系统「明明改了文件却不重新编译」的经典 bug。可靠性递增的方案是:只比 mtime(会漏判)→ 比(mtime_ns, size)(性价比最高)→ 再加(st_dev, st_ino)防文件被整个替换 → 内容哈希(最可靠但要读全部数据)。 - 误区:操作文件前先用
os.path.exists()或os.access()检查一下更稳妥。 这是 TOCTOU(Time-Of-Check to Time-Of-Use)竞态:检查通过到真正打开之间存在时间窗口,文件可能被删除、被替换、或被换成一个指向/etc/passwd的符号链接——轻则白检查一场仍然抛异常,重则成为安全漏洞。Python 推荐的是 EAFP:直接try: open(p) except FileNotFoundError:,因为打开这个动作本身是原子的,拿到文件描述符后就不再受路径变化影响(后续可以用os.fstat(fd)安全地取元信息)。os.access()尤其危险:它按真实 uid 而非有效 uid 检查权限(在 setuid 程序里会得出错误结论),且同样有竞态窗口,官方文档明确不建议用它做安全判断。安全敏感场景应该用os.open(p, os.O_RDONLY | os.O_NOFOLLOW)直接拒绝符号链接。 - 追问:目录的
st_size是什么?怎么正确计算一个目录占用了多少空间? 目录的st_size是目录本身这个「文件」占用的空间(存放目录项的数据结构大小,ext4 上通常是 4096 的倍数),和里面文件的总大小完全无关——一个装了 10 万个文件的目录st_size可能才几百 KB。要算目录总大小必须递归遍历累加每个文件,标准写法是用os.scandir递归并对每一项加follow_symlinks=False(避免重复统计软链接指向的文件、以及链接成环导致的无限递归)。还有一个更细的区分:st_size是「逻辑大小」,st_blocks * 512才是「实际占用的磁盘块」——稀疏文件(数据库预分配文件、虚拟机镜像)的逻辑大小可能是 10GB 而实际只占 1MB;反过来小文件因为块对齐,实际占用会大于逻辑大小。du命令统计的是后者,ls -l显示的是前者,做磁盘容量告警要用st_blocks。 - 追问:怎么判断一个文件「是不是同一个文件」(而不是内容相同)? 用
os.path.samefile(a, b),它的原理是比较(st_dev, st_ino)——设备号加 inode 号在一台机器上唯一标识一个文件实体。这能识破所有「同一个文件的不同表示」:符号链接和它的目标、互为硬链接的两个路径、./a.txt和a.txt、绝对路径和相对路径、路径里带..的写法。典型用途是防止自我覆盖(cp a a这类操作前先检查源和目标是不是同一个文件)和去重(遍历时用(dev, ino)做集合的键,避免因硬链接重复统计)。注意几个边界:Windows 上samefile也可用(基于文件 ID),但网络盘上 inode 可能不稳定或不唯一;st_ino在文件被删除后可能被复用给新文件,所以不能拿它当持久化的文件标识存进数据库。 - 追问:怎么判断另一个进程有没有把文件写完? 没有可靠的通用方法——文件系统不提供「正在被写入」这样的标志,
stat里也没有这个信息。三条实际路线:① 最正确的做法是改变写入方(约定协议):写入方先写到临时文件、写完后用os.replace()原子改名到最终路径——读取方看到最终路径存在就一定是完整的(这是原子写入的标准模式);或者写完后额外创建一个.done标记文件。② 写入方无法改造时的折中:间隔几秒做两次stat,若size和mtime_ns都没变则推测写完了——这只是启发式,写入方短暂停顿就会误判。③ 平台特定手段:Linux 上可以查/proc/*/fd看有没有进程持有该文件、或用inotify(watchdog库)监听IN_CLOSE_WRITE事件(这个事件才是「写完并关闭」的准确信号);Windows 上可以尝试以独占方式打开,失败说明还被占用。
八、加强记忆
文件的全部元信息来自一次 stat 系统调用(os.stat / Path.stat,返回 os.stat_result),所以第一准则是**「一次 stat、多次用」——if p.is_file() and p.stat().st_size > 0 and p.stat().st_mtime > x 是三次 syscall**,而且三次看到的可能是不同时刻的状态,应该先取一个一致的快照。三个入口要分清:stat 跟随符号链接、lstat 不跟随、fstat(fd) 作用于已打开的描述符(最安全,不受路径改名删除影响)。最高频的考点是 st_ctime 的 c 是 change 不是 create:Unix 上它是「inode 变更时间」(chmod/chown/改名/增删硬链接都会更新,因此永远 ≥ mtime,且 os.utime 改不了它、审计时比 mtime 可信),Windows 上它才是创建时间,Linux 上多数场景根本拿不到创建时间(macOS/BSD 用 st_birthtime);atime 同样不可靠(现代系统 relatime 挂载)。遍历大目录必用 os.scandir:DirEntry 的 name/path 零成本、is_dir()/is_file() 靠 readdir 的 d_type 零成本、stat() 结果被缓存(follow_symlinks 的两种取值各有独立缓存),5 万文件的目录比 listdir + isfile + getsize 快 5~10 倍,网络盘上差几十倍。精确比较用 st_mtime_ns(整数纳秒):float 有精度损失是小问题,文件系统本身的粒度才是大问题(ext3/HFS+ 是 1 秒、FAT32 是 2 秒,「1 秒内的两次修改 mtime 完全相同」正是构建系统漏编译的根源);判断文件是否变化的性价比方案是比 (mtime_ns, size),更严格再加 (dev, ino) 或内容哈希。别 LBYL:if exists(): open() 有 TOCTOU 竞态(文件可能被删或被换成符号链接),要用 EAFP 的 try/except;os.access() 按真实 uid 检查且有竞态窗口,官方不建议用于安全判断,安全场景用 os.open(..., O_NOFOLLOW) + os.fstat(fd)。其余易错点:目录的 st_size 不是内容总和;统计磁盘占用要用 st_blocks * 512 而非 st_size(稀疏文件差异巨大);shutil.copy 不保留时间戳、copy2 才保留;判断「是不是同一个文件」用 os.path.samefile(本质比较 st_dev + st_ino);判断「文件写完没有」没有通用方法,正解是约定「写临时文件 + 原子改名」。