2024 年我写了个清理脚本,想把临时目录里超过 7 天的 .tmp 文件删掉。脚本里那行 find 我自认为写得很标准,跑完也确实清出去一批文件。可第二天有同事来问:他放在那个目录里的一个配置文件不见了——那文件明明才建了两天。我把命令拆开一查,后背发凉:我写的是 find /data/tmp -mtime 7 -delete,我以为 -mtime 7 是"7 天以上",可它实际的意思是"恰好第 7 个 24 小时";更要命的是,我还漏了 -name '*.tmp' 这个限定,于是它把目录里所有"年龄"撞上那个区间的文件,不分类型地全删了。这件事逼着我把 find 这个看似简单、实则全是细节坑的命令彻底理清了。本文复盘这次实战。
问题背景
环境:CentOS 7,一个清理临时文件的定时脚本
事故现象:
- 脚本意图:删 /data/tmp 下超过 7 天的 .tmp 文件
- 实际结果:删掉了一个才建 2 天的、别人的配置文件
- 脚本"跑成功"了,没报任何错 —— 最危险的那种"成功"
现场排查:
# 1. 看脚本里那行 find
$ cat clean.sh
find /data/tmp -mtime 7 -delete
# ↑↑↑↑↑↑↑↑
# 问题一:-mtime 7(没加 +),意思是"恰好第 7 天",不是"7 天以上"
# 问题二:整行没有 -name '*.tmp' —— 它对【所有文件】生效!
# 2. 验证 -mtime 的真实语义
$ find /data/tmp -mtime 7 # 恰好第 7 个 24h 区间内修改的
$ find /data/tmp -mtime +7 # 【超过】7 天(我真正想要的)
$ find /data/tmp -mtime -7 # 【最近】7 天内
# 3. 那个被误删的配置文件,为什么会中招
$ stat someconf.yml # (从备份里恢复后查)
# 它的 mtime 恰好落在脚本判定的区间里,
# 而脚本又没限定 -name,于是它和 .tmp 一起被删了。
根因(后来想清楚的):
1. -mtime N 不加符号 = "恰好第 N 天",+N 才是"N 天以上"。
2. find 的条件之间默认是"与",漏写一个条件,
筛选范围就【放大】了 —— 这次漏了 -name。
3. -delete 是即时生效、不可撤销的动作,
我却从没先用 -print 预览过它要删什么。
修复 1:find 的基本结构——路径 + 条件 + 动作
# === find 的命令结构,固定是三段 ===
$ find [起始路径] [筛选条件] [对结果做的动作]
# 从哪找 找什么 找到后干嘛
# === 最简单的形态:只给路径 ===
$ find /data/tmp
# 把 /data/tmp 下面所有文件/目录,递归地全列出来。
# === 加条件:在路径基础上"筛" ===
$ find /data/tmp -name '*.tmp'
# 只列出名字匹配 *.tmp 的。
# === 加动作:对筛出来的结果做点什么 ===
$ find /data/tmp -name '*.tmp' -print # 打印(默认动作)
$ find /data/tmp -name '*.tmp' -delete # 删除(危险!)
$ find /data/tmp -name '*.tmp' -ls # 像 ls -l 那样详细列出
# === ★ 关键认知:多个条件之间默认是"与(AND)" ===
$ find /data -name '*.log' -mtime +7
# = 名字是 *.log 【并且】 超过 7 天 —— 两个都满足才算
# 反过来想:你少写一个条件,筛选就少一道关卡,
# 命中的文件就【变多】。这次的祸根就在这。
# === 显式的逻辑组合 ===
$ find /data \( -name '*.log' -o -name '*.tmp' \) # -o 是"或"
$ find /data -name '*.log' ! -name 'keep.log' # ! 是"非"
# 注意 \( \) 要转义,且和条件之间要有空格。
# === 控制递归深度 ===
$ find /data -maxdepth 1 -name '*.conf' # 只找当前层,不钻子目录
$ find /data -mindepth 2 ... # 至少钻进 2 层才开始算
# 清理脚本里加 -maxdepth,能防止它意外地深入到不该动的子目录。
修复 2:按名字找——通配符一定要加引号
# === -name:按文件名匹配(区分大小写)===
$ find /data -name 'access.log' # 精确名字
$ find /data -name '*.log' # 通配符
# === ★ 通配符必须加引号 —— 一个极其常见的坑 ===
# 【错误】不加引号:
$ find /data -name *.log
# 如果【当前目录】恰好有个 a.log,shell 会先把 *.log
# 展开成 a.log,你的命令就变成了 find /data -name a.log,
# 只能找到名叫 a.log 的 —— 行为完全不是你要的,还时灵时不灵。
# 【正确】加引号,让 * 原样交给 find 自己去解释:
$ find /data -name '*.log'
# === -iname:忽略大小写 ===
$ find /data -iname '*.LOG' # README.log / readme.LOG 都能匹配
# === -name 只匹配"文件名",-path 匹配"整个路径" ===
$ find /data -name '*.log' # 只看最后那段文件名
$ find /data -path '*/cache/*.log' # 路径里包含 /cache/ 的
$ find /data -regex '.*/v[0-9]+/.*' # -regex 用正则匹配整个路径
# === 排除某个目录(清理时非常实用)===
$ find /data -path '/data/keep' -prune -o -name '*.tmp' -print
# -prune:遇到 /data/keep 就【剪枝】,不进去。
# 整句:跳过 keep 目录,在其余地方找 *.tmp。
# === 按 inode、按是否空文件找 ===
$ find /data -empty # 空文件 / 空目录
$ find /data -inum 123456 # 按 inode 号找(配合排查硬链接)
$ find /data -samefile a.txt # 找出和 a.txt 是同一个 inode 的
修复 3:按时间找——+N / -N / N 的语义坑
# === 这次事故的正中靶心:时间条件的符号语义 ===
# === 三个时间维度 ===
# -mtime 内容最后【修改】时间(modify)—— 最常用
# -atime 最后【访问】时间(access)
# -ctime inode 状态最后【改变】时间(change,如改权限)
# === ★ +N / -N / N 到底什么意思 ===
# 单位是"24 小时"。假设今天用 -mtime:
# -mtime N 恰好在第 N 个 24 小时区间(N≤age
修复 4:按大小、类型、权限找
# === -type:按文件类型筛(清理脚本里强烈建议带上)===
# f 普通文件 d 目录 l 软链接 s 套接字 p 管道
$ find /data -type f -name '*.tmp' # 只删【文件】,不碰目录
$ find /data -type d -empty # 找空目录
# ★ 这次如果我当初带了 -type f,误伤范围至少能小一些。
# === -size:按大小筛 ===
# 单位:c=字节 k=KB M=MB G=GB
$ find /data -type f -size +500M # 大于 500MB 的大文件
$ find /data -type f -size -10k # 小于 10KB 的小文件
$ find /data -type f -size +100M -exec ls -lh {} \; # 列出来看看
# 排查"磁盘被谁吃满了",这条非常顺手。
# === -perm:按权限筛(安全巡检常用)===
$ find /data -type f -perm 0777 # 权限恰好是 777 的(危险文件)
$ find / -type f -perm -4000 2>/dev/null # 找所有有 SUID 位的文件
# -4000 前面的减号:含有这些位即可(不要求精确相等)。
# === 按属主、属组找 ===
$ find /data -user www # 属主是 www 的
$ find /data -group dev # 属组是 dev 的
$ find /data ! -user root # 属主【不是】 root 的
$ find /home -nouser -o -nogroup # 属主/属组已不存在的"孤儿文件"
# === 组合出一条实用的"大文件体检" ===
$ find /var -type f -size +200M -mtime +30 -exec ls -lh {} \;
# = 在 /var 下,大于 200M、且 30 天没动过的文件 —— 清理候选名单。
# === find 配合 du / sort 找"最占地方"的 ===
$ find /data -type f -printf '%s %p\n' | sort -rn | head
# -printf '%s %p' 打印 [字节数] [路径],排序后看 Top N。
修复 5:-delete 与 -exec——最危险的两个动作
# === ★ 铁律:任何带删除/修改的 find,先用 -print 预览 ===
# 第一步:把动作换成 -print,肉眼核对要操作的是不是这些
$ find /data/tmp -type f -name '*.tmp' -mtime +7 -print
# 第二步:确认输出无误,再把 -print 换成 -delete
$ find /data/tmp -type f -name '*.tmp' -mtime +7 -delete
# === -delete 的几个隐藏脾气 ===
# 1. -delete 会【即时】删,不进回收站,不可撤销。
# 2. -delete 隐含了 -depth(先处理深层),能删空目录。
# 3. ★ -delete 要写在【所有条件之后】。位置写错语义全变:
$ find /data -delete -name '*.tmp' # 【灾难】先 -delete 再判断 ->
# 几乎等于把 /data 删空!
$ find /data -name '*.tmp' -delete # 正确:先筛,后删
# === -exec:对每个结果执行一条命令 ===
# 写法一:-exec cmd {} \; —— 每个文件【单独】跑一次命令
$ find /data -name '*.log' -exec gzip {} \;
# {} 是占位符,代表当前文件;\; 表示命令结束(分号要转义)。
# 写法二:-exec cmd {} + —— 把【多个文件】拼到一条命令里
$ find /data -name '*.log' -exec rm {} +
# {} + 一次传一批,执行次数少、快得多 —— 能用 + 就别用 \;
# === -exec 删除前,同样先换成 echo / ls 预览 ===
$ find /data -name '*.tmp' -exec echo rm {} \; # 先 echo 看会删啥
$ find /data -name '*.tmp' -exec rm {} + # 确认后再真删
# === 文件名带空格/特殊字符:用 -print0 配 xargs -0 ===
$ find /data -name '*.tmp' -print0 | xargs -0 rm
# -print0 用 \0 分隔,xargs -0 按 \0 切 —— 空格文件名也不会被切错。
# 普通的 find ... | xargs 遇到带空格的文件名会出错,务必用 -0。
# === 给 find 本身加一道保险 ===
$ find /data/tmp -maxdepth 1 -type f -name '*.tmp' -mtime +7 -delete
# -maxdepth 限制深度、-type f 限定类型、-name 限定名字 ——
# 每多一个限定,误删的可能就少一分。
修复 6:find 使用纪律
# === 这次事故暴露了用 find 的随意,定几条纪律 ===
# === 1. ★ 任何破坏性 find,先 -print 预览,再换动作 ===
# 这是第一纪律,没有例外。-delete / -exec rm 之前,
# 永远先把动作换成 -print 跑一遍,肉眼数一数、看一看。
# === 2. 条件能多限定就多限定,别让范围"放大" ===
# -type f 限类型、-name 限名字、-maxdepth 限深度。
# find 条件默认是"与",每加一个,误伤面就缩小一圈。
# === 3. 时间条件想清楚 +N / -N / N ===
# 删"N 天以上" -> +N(别忘了那个加号!)
# 删"最近 N 天" -> -N
# 拿不准就先 -mtime +N -print 出来,看日期对不对。
# === 4. 通配符永远加引号 ===
$ find /data -name '*.log' # 对
# 不加引号会被 shell 提前展开,行为诡异且不稳定。
# === 5. -delete 必须放在所有条件之后 ===
# 放前面 = 先删后判断 = 灾难。养成"条件在前,动作在后"。
# === 6. 一个稳妥的清理脚本骨架 ===
#!/bin/bash
set -euo pipefail
DIR=/data/tmp
DAYS=7
# 先 dry-run:把要删的列出来,记进日志
echo "=== 将要清理的文件 ($(date)) ==="
find "$DIR" -maxdepth 2 -type f -name '*.tmp' -mtime +"$DAYS" -print
# 真正执行(确认上面的输出无误后,这行才放开)
find "$DIR" -maxdepth 2 -type f -name '*.tmp' -mtime +"$DAYS" -delete
echo "清理完成"
# === 7. 在 / 或大目录上跑 find,加 2>/dev/null + 限范围 ===
$ find / -name '*.conf' 2>/dev/null
# 根目录下 find 会撞上一堆"权限不足"的报错,2>/dev/null 滤掉。
# 但更好的是别在 / 上裸跑,先想清楚到底该从哪个目录开始找。
命令速查
需求 命令
=============================================================
按名字找(通配符加引号) find 路径 -name '*.log'
忽略大小写找 find 路径 -iname '*.LOG'
只找文件 / 只找目录 find 路径 -type f / -type d
超过 N 天没改的 find 路径 -mtime +N
最近 N 天内改的 find 路径 -mtime -N
最近 N 分钟内改的 find 路径 -mmin -N
大于 N 的大文件 find 路径 -type f -size +NM
限制递归深度 find 路径 -maxdepth 1 ...
排除某目录 find 路径 -path 排除目录 -prune -o ... -print
★ 删除前先预览 find ... -print(确认后换 -delete)
对每个结果执行命令 find ... -exec cmd {} +
空格文件名安全删除 find ... -print0 | xargs -0 rm
口诀:破坏性操作先 -print 预览 -> 条件尽量限定全
-> 时间想清 +N/-N -> -delete 放在所有条件之后
避坑清单
- find 多个条件之间默认是"与",漏写一个条件筛选范围就会放大
- -mtime N 不加符号是"恰好第 N 天",+N 才是"N 天以上",-N 是"最近 N 天"
- -name 的通配符必须加引号,否则会被 shell 提前展开导致行为诡异
- 清理脚本务必带 -type f 限定类型,避免误伤目录或软链接
- -maxdepth 限制递归深度,防止 find 意外钻进不该动的子目录
- -delete 即时生效不可撤销,任何破坏性 find 都先用 -print 预览
- -delete 必须写在所有条件之后,写在前面等于先删后判断会删空目录
- -exec 用 {} + 比 {} \; 快得多,能用加号就别用分号
- 文件名可能带空格时用 -print0 配 xargs -0,普通管道会切错
- 在 / 等大目录跑 find 加 2>/dev/null 滤权限报错,更应先想清从哪个目录起
总结
这次清理脚本误删文件的事故,把我对 find 这个命令的认识,从"我天天用、肯定很熟"狠狠地拽回到了"我其实从没真正搞懂它"。最让我心惊的地方在于,这次出事,我没有敲错任何一个字母,脚本也没有抛出任何一行错误,它平静地、"成功地"跑完了,然后悄无声息地删掉了一个不该删的文件——这种不报错的"成功",恰恰是最危险的失败。复盘下来,真正的元凶有两个,而它们都指向同一个根本问题:我对 find 的"筛选逻辑"想得太想当然了。第一个元凶,是时间条件那个我从没认真琢磨过的符号语义。我写下 -mtime 7 的时候,脑子里想的是"7 天以上",可 find 对这个不带任何符号的 7 的真实解释,是"年龄恰好落在第 7 个 24 小时区间里"——它表达的是一个精确的、孤立的"区间",而不是我以为的那个"以上"的范围。要表达"7 天以上",必须写成带加号的 +7;要表达"最近 7 天以内",则要写成带减号的 -7。这个加号减号,看上去微不足道,却彻底决定了筛选的方向和范围。第二个元凶更隐蔽,也更值得记一辈子:我那行命令里,根本就漏掉了 -name '*.tmp' 这个对文件名的限定。我后来才真正想透,find 的多个筛选条件之间,默认是用"与"的逻辑串起来的——这意味着,每多写一个条件,就等于多设了一道关卡,能够通过层层关卡、最终被命中的文件就越少;反过来,我每漏写一个条件,就等于少设了一道关卡,筛选的范围就会悄悄地、危险地"放大"。我漏掉了对文件名的限定,于是这条命令就从"删 7 天前的 .tmp 文件",失控地变成了"删那个时间区间里的所有文件,不管它是什么"。理解了这两个元凶,我才真正明白,用好 find 的核心,根本不在于记住多少个花哨的选项,而在于建立两个最朴素的纪律。第一个纪律,是"能多限定就多限定"。既然条件之间是"与",既然每个条件都是一道收紧范围的关卡,那我就该在每一条破坏性的 find 命令里,把能想到的限定都老老实实写上:用 -type f 把范围锁死在普通文件上,绝不误伤目录;用 -name 把文件名钉死;用 -maxdepth 把递归的深度限制住,不让它钻进我没预料到的子目录。每多一道关卡,误伤的概率就小一分。第二个纪律,也是最重要、绝无例外的一条,是"破坏性操作之前,永远先 -print 预览"。find 的 -delete 是即时生效、且完全不可撤销的,它不会把文件丢进任何回收站,删了就是删了。既然如此,我凭什么敢在没有亲眼看过"它到底要删哪些文件"的情况下,就直接把 -delete 交给它?正确的流程永远是两步走:第一步,把命令末尾的动作换成 -print,让它老老实实把所有会被命中的文件路径打印出来,然后我用自己的眼睛去逐一核对——数量对不对?日期对不对?有没有混进不该出现的东西?第二步,只有在确认这份预览清单百分之百符合我的预期之后,我才有资格把 -print 换回 -delete,真正地执行删除。这次从一个被误删的配置文件出发,我最大的收获,是终于不再把 find 当成一个"输入命令、得到结果"的简单黑盒,而是把它看成一套需要我清醒地、逐个条件地去推敲的筛选逻辑——而驾驭这套逻辑的全部要诀,就浓缩在那句朴素到极点、却能挡下绝大多数事故的话里:删之前,先用 -print 看清楚。
—— 别看了 · 2026