← 返回题目列表

怎么获取文件大小、修改时间等元信息?os.stat 有哪些坑?

中等 第 17 / 27 题 更新于 2026/07/31
os.stat文件元信息mtime文件系统

简化版

文件的「元信息」(大小、时间戳、权限、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_ctimeUnix:inode 变更时间;Windows:创建时间不是创建时间(Unix)
st_birthtime创建时间macOS/BSD;Linux 上多数文件系统没有
st_mode文件类型 + 权限位配合 stat.S_ISDIR()stat.filemode()
st_ino / st_devinode 号 / 设备号两者合起来唯一标识一个文件
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_birthtimeLinux 上大多数场景根本拿不到创建时间(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。区分方法是看什么操作会更新它:chmodchown、改名、增删硬链接只更新 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_IFREGS_IFDIRS_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;而 scandirDirEntry.is_dir() 多数情况下零系统调用readdir 已经带回了类型信息)。这里有个高频误判:stat 跟随符号链接,所以指向目录的软链接用 is_dir() 会返回 True——想区分「真目录」和「指向目录的软链接」必须用 lstatis_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 的 floatst_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) 拒绝符号链接、再对 fdos.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)」——chmodchown、改名、增删硬链接、修改扩展属性都会更新它,而写入内容因为改变了 size 和 mtime,也会连带更新 ctime。所以 ctime 永远 ≥ mtime,把它当创建时间会得到完全错误的结论(一个 2020 年创建、昨天改过权限的文件,ctime 是昨天)。Windows 上 st_ctime 才是创建时间,同一段代码跨平台语义不同。真正的创建时间:macOS/BSD 用 st_birthtimeLinux 上大多数场景拿不到(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()——DirEntryname/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.scandiris_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.txta.txt、绝对路径和相对路径、路径里带 .. 的写法。典型用途是防止自我覆盖cp a a 这类操作前先检查源和目标是不是同一个文件)和去重(遍历时用 (dev, ino) 做集合的键,避免因硬链接重复统计)。注意几个边界:Windows 上 samefile 也可用(基于文件 ID),但网络盘上 inode 可能不稳定或不唯一st_ino 在文件被删除后可能被复用给新文件,所以不能拿它当持久化的文件标识存进数据库。
  • 追问:怎么判断另一个进程有没有把文件写完? 没有可靠的通用方法——文件系统不提供「正在被写入」这样的标志,stat 里也没有这个信息。三条实际路线:① 最正确的做法是改变写入方(约定协议):写入方先写到临时文件、写完后用 os.replace() 原子改名到最终路径——读取方看到最终路径存在就一定是完整的(这是原子写入的标准模式);或者写完后额外创建一个 .done 标记文件。② 写入方无法改造时的折中:间隔几秒做两次 stat,若 sizemtime_ns 都没变则推测写完了——这只是启发式,写入方短暂停顿就会误判。③ 平台特定手段:Linux 上可以查 /proc/*/fd 看有没有进程持有该文件、或用 inotifywatchdog 库)监听 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.scandirDirEntryname/path 零成本、is_dir()/is_file()readdird_type 零成本、stat() 结果被缓存(follow_symlinks 的两种取值各有独立缓存),5 万文件的目录比 listdir + isfile + getsize5~10 倍,网络盘上差几十倍。精确比较用 st_mtime_ns(整数纳秒):float 有精度损失是小问题,文件系统本身的粒度才是大问题(ext3/HFS+ 是 1 秒、FAT32 是 2 秒,「1 秒内的两次修改 mtime 完全相同」正是构建系统漏编译的根源);判断文件是否变化的性价比方案是比 (mtime_ns, size),更严格再加 (dev, ino) 或内容哈希。别 LBYLif exists(): open()TOCTOU 竞态(文件可能被删或被换成符号链接),要用 EAFP 的 try/exceptos.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);判断「文件写完没有」没有通用方法,正解是约定「写临时文件 + 原子改名」。