1. find 的核心模型
一句话总结:
find不是「按名字找文件」,而是「对目录树做深度优先遍历,对每个节点求值一个布尔表达式」,理解这一点才能写出可预测的命令。
很多人把 find 当成增强版 ls,于是写出「先找再删」却删错了目录的事故。正确的心理模型是:find 从给定起点出发,对每一个目录项求值一个由谓词和逻辑运算符构成的表达式;表达式为真的项才被「命中」,命中后要么打印,要么执行动作。
# 最小可用形式:列出当前目录树下所有普通文件
find . -type f
# 限制深度,避免在巨型目录树上浪费时间
find . -maxdepth 2 -type f -name '*.log'
1.1 表达式求值与短路
一句话总结: 相邻谓词之间是隐式 AND,
-o是 OR,括号必须转义,而动作一旦执行就会短路掉后面的动作。
表达式的求值顺序是从左到右,且具有短路语义:-o 左边为真就不再求值右边。动作(-print、-exec)本身也返回真值,所以多个动作并列时会互相影响。
# 隐式 AND:两个条件都满足才命中
find . -type f -name '*.log'
# 显式 OR 与分组,括号要转义,否则会被 shell 吃掉
find . \( -name '*.log' -o -name '*.txt' \) -type f
# 取反:排除临时文件
find . -type f ! -name '*.tmp'
# 危险:-o 左侧命中后不再求值右侧,删除范围可能比预期小
find . -name '*.bak' -o -name '*.tmp' -delete
1.2 常用谓词速查
一句话总结: 按大小、时间、权限、类型四类谓词覆盖九成场景,其中
-mtime与-newer的时间语义最容易踩坑。
find . -type f -size +10M # 大于 10M
find . -type f -size -1k # 小于 1K
find . -type f -mtime -7 # 7 天内修改过
find . -type f -mtime +30 # 30 天前修改过
find . -type f -newer baseline.txt # 比参考文件更新
find . -type f -perm -u+x # 属主具备可执行位
find . -type d -empty # 空目录
find . -type l -xtype f # 指向普通文件的符号链接
2. -exec 与 xargs 的取舍
一句话总结:
-exec把路径当成参数直接传给程序,天然免疫空格与换行;xargs更灵活也更快,但必须用-0配合-print0才安全。
两者不是替代关系:-exec 的语义是「对每个命中项调用一次程序」,xargs 的语义是「把命中项拼成参数列表批量调用」。前者的安全边界由内核保证,后者由你保证。
# -exec 逐个调用,路径原样传入,含空格也安全
find . -type f -name '*.log' -exec gzip {} \;
# 用 + 结尾:尽可能多地把路径拼进一次调用,接近 xargs 的效率
find . -type f -name '*.log' -exec gzip {} +
# xargs 批量调用,必须用 -0 配合 -print0
find . -type f -name '*.log' -print0 | xargs -0 gzip
2.1 终止符与占位符
一句话总结:
\;每个文件一次调用、+批量调用、{}是路径占位符,位置可以放在参数中间而不是末尾。
{} 不一定要在最后,这让你能构造出「文件放中间」的调用,比如 -exec cp {} /backup/ \; 或者 -exec mv {} {}.bak \;。
# 占位符可以出现在参数中间
find . -name '*.conf' -exec cp {} /etc/backup/ \;
# 用 shell 做更复杂的单文件处理,注意 $1 而不是 $0
find . -name '*.md' -exec bash -c 'wc -l "$1"' _ {} \;
# 批量模式:一次调用处理多个文件,减少 fork 开销
find . -name '*.png' -exec optipng -quiet {} +
2.2 xargs 的并行与分批
一句话总结:
-P控制并发度、-n控制每批参数个数、-I提供占位符但会退化为逐个调用。
# 8 路并行压缩,参数为空的输入用 -r 避免空跑
find . -type f -name '*.log' -print0 \
| xargs -0 -r -n 50 -P 8 gzip
# -I 占位符模式:每次只处理一个,但可以自由摆放位置
find . -type f -name '*.jpg' -print0 \
| xargs -0 -I{} sh -c 'convert "$1" "${1%.jpg}.webp"' _ {}
3. 路径安全与特殊文件名
一句话总结: Unix 文件名除了
/和 NUL 之外几乎可以是任何字节,包括空格、换行、前导-,所以「用换行分隔」本身就是漏洞。
只要脚本里出现 for f in $(find ...) 或 find ... | while read f,就已经不安全了。前者会被空格切分,后者会被换行切分,攻击者只要在目录里放一个名字带换行的文件,就能让脚本操作到非预期的路径。
# 反例一:命令替换会被空格切分
for f in $(find . -name '*.log'); do echo "$f"; done
# 反例二:默认 read 会被换行切分
find . -name '*.log' | while read -r f; do echo "$f"; done
# 正例:NUL 分隔 + read -d ''
while IFS= read -r -d '' f; do
printf '%s\n' "$f"
done < <(find . -name '*.log' -print0)
3.1 前导横线与选项注入
一句话总结: 文件名以
-开头会被目标程序当成选项,用--显式终止选项解析,或给路径加上./前缀。
# 危险:名为 -rf 的文件会被 rm 当成选项
find . -type f -name '-*' -exec rm {} \;
# 安全:-- 终止选项解析
find . -type f -name '-*' -exec rm -- {} \;
# 另一条路:find 输出带 ./ 前缀,天然避开选项歧义
find . -type f -exec rm -f {} +
3.2 去重与硬链接
一句话总结:
-samefile与-inum用来识别硬链接,sort -u或sort -z -u用来对结果去重,跨多个起点时尤其必要。
# 找出同一 inode 的所有硬链接
find . -type f -inum "$(stat -c %i target.txt 2>/dev/null || stat -f %i target.txt)"
# 多起点结果去重(NUL 安全)
{ find /data -name '*.csv' -print0; find /backup -name '*.csv' -print0; } \
| sort -z -u | xargs -0 -r ls -l
# 只保留每个文件的第一个硬链接
find . -type f -links +1 -printf '%i %p\n' | sort -k1,1 -u
4. fd 与现代替代工具
一句话总结:
fd用正则、默认忽略.gitignore、默认大小写不敏感,把最常见的查找需求压缩成一行;但它的输出仍然要遵守路径安全规则。
find 的语法是四十年前的产物,fd 用更符合直觉的参数重做了它。代价是 fd 不是 POSIX 工具,脚本里用它要显式检查依赖是否存在。
# 等价对照:找 .log 文件
find . -type f -name '*.log'
fd -e log -t f
# fd 默认忽略 .gitignore 与隐藏文件,需要时显式打开
fd --hidden --no-ignore -e log
# fd 支持正则与大小写智能匹配
fd '^test_.*\.py$'
4.1 fd 与 find 互操作
一句话总结:
fd -0输出 NUL 分隔、-x内置并行执行、-X批量执行,可以直接替代xargs组合。
# fd 内置并行执行,-j 控制并发
fd -e png -x optipng -quiet {}
# 批量模式:一次性传入所有结果
fd -e log -X gzip
# 需要给 find 用时,输出 NUL 分隔
fd -0 -e log | xargs -0 -r gzip
4.2 ripgrep 的 -l 与文件列表
一句话总结:
rg -l列出含匹配的文件名、rg --files列出所有被追踪文件,二者都默认尊重.gitignore,适合先筛内容再批量操作。
# 列出所有含 TODO 的文件,再交给批量处理
rg -l 'TODO' --type py | xargs -r sed -i '' 's/TODO/FIXME/'
# 列出所有文件(尊重 .gitignore),替代 find . -type f
rg --files -g '!node_modules' | head -20
5. 批量操作模式
一句话总结: 批量重命名、批量删除、批量归档三种模式覆盖绝大多数需求,共同要求是「先列出、再执行、留回滚」。
批量操作的第一原则不是效率,而是可逆。任何删除或覆盖前,先跑一遍只打印的版本,确认清单无误再换成真正的动作。
# 模式一:先预演,只打印将被删除的路径
find . -type f -name '*.tmp' -mtime +7 -print
# 确认后再执行
find . -type f -name '*.tmp' -mtime +7 -delete
# 模式二:批量重命名,用参数扩展而不是 sed 改路径
find . -type f -name '*.jpeg' -exec bash -c '
for f; do mv -- "$f" "${f%.jpeg}.jpg"; done
' _ {} +
5.1 批量归档与压缩
一句话总结: 用
tar --files-from或-T -接收文件列表,避免参数过长与路径转义问题。
# 把 30 天前的日志打包归档,再删除原件
find /var/log -type f -name '*.log' -mtime +30 -print0 \
| tar --null --files-from=- -czf "archive-$(date +%F).tar.gz"
# 确认归档内容无误后再清理
find /var/log -type f -name '*.log' -mtime +30 -delete
5.2 批量权限与属主修正
一句话总结: 用
-perm精确定位异常权限,用-exec chmod修正,注意目录与文件要分别处理。
# 找出全局可写的普通文件
find . -type f -perm -o+w -print
# 修正为 644,目录单独修正为 755
find . -type f -perm -o+w -exec chmod 644 {} +
find . -type d -perm -o+w -exec chmod 755 {} +
6. 性能与深度控制
一句话总结: 剪枝(
-prune)和限制深度(-maxdepth)比并行更有效,因为省下的是真实的遍历与 stat 系统调用。
find 慢通常不是因为 CPU,而是因为遍历了不需要的子树。先把 node_modules、.git、venv 剪掉,往往能快一个数量级。
# 剪枝:命中目录后不再深入,-o -print 保证其它项仍被打印
find . -name node_modules -prune -o -type f -name '*.js' -print
# 同时剪多个目录
find . \( -name .git -o -name node_modules -o -name .venv \) -prune \
-o -type f -print
# 限制深度,最直接的性能开关
find . -maxdepth 3 -type f -name '*.conf'
6.1 用 -xdev 避免跨文件系统
一句话总结:
-xdev让 find 停留在同一设备上,防止在挂载点上误入网络存储或另一块磁盘。
# 只在本文件系统内查找,避免进入挂载点
find / -xdev -type f -name '*.so' 2>/dev/null
# 结合 -mount 的等价写法(GNU find 中 -xdev 与 -mount 同义)
find /var -mount -type f -size +1G
6.2 并行化的正确姿势
一句话总结:
find本身单线程,并行要交给下游的xargs -P或fd -j,且只在每个任务都是独立重活时才划算。
# 并行校验文件哈希,每个任务独立且耗时
find . -type f -size +1M -print0 \
| xargs -0 -r -P "$(nproc)" -n 1 sha256sum > checksums.txt
# 用 fd 的并发更简洁
fd -t f -S +1M -x sha256sum {} > checksums.txt
7. 实战:可预演的批量清理流水线
一句话总结: 把「扫描 → 生成清单 → 人工确认 → 执行 → 记录日志」串成一条流水线,删除类操作就具备了审计与回滚能力。
下面这段脚本先扫描候选文件写入清单,打印摘要,等到人工确认后才真正删除,并把删除记录追加到日志。任何一步失败都通过 set -euo pipefail 中断。
#!/usr/bin/env bash
set -euo pipefail
ROOT="${1:?用法: cleanup.sh <目录> [天数]}"
DAYS="${2:-30}"
STAMP="$(date +%Y%m%d-%H%M%S)"
LIST="/tmp/cleanup-$STAMP.list"
LOG="/var/log/cleanup-$STAMP.log"
# 扫描:剪掉版本控制与依赖目录,收集陈旧临时文件
find "$ROOT" \
\( -name .git -o -name node_modules -o -name .venv \) -prune \
-o -type f \( -name '*.tmp' -o -name '*.bak' -o -name 'core.*' \) \
-mtime "+$DAYS" -print0 > "$LIST"
count=$(tr -cd '\0' < "$LIST" | wc -c | tr -d ' ')
size=$(xargs -0 -r du -ch < "$LIST" 2>/dev/null | tail -1 | cut -f1)
printf '候选 %s 个文件,合计 %s\n' "$count" "${size:-0}"
7.1 确认与执行
一句话总结: 用「回车确认」把人类判断插进流水线,避免自动化在无人值守时造成不可逆损失。
# 人工确认环节:非交互环境可用 --yes 跳过
if [[ "${CLEANUP_YES:-0}" != "1" ]]; then
read -r -p '确认删除?输入 yes 继续: ' ans
[[ "$ans" == "yes" ]] || { echo '已取消'; exit 1; }
fi
# 逐条删除并记录,NUL 分隔保证路径安全
while IFS= read -r -d '' f; do
rm -f -- "$f" && printf '%s\n' "$f" >> "$LOG"
done < "$LIST"
printf '已删除 %s 个文件,日志: %s\n' "$count" "$LOG"
7.2 定时化与告警
一句话总结: 流水线交给 systemd timer 或 cron 定期执行,并把摘要写进日志,异常时由监控系统捕获。
# 只扫描不删除,用于每日巡检
CLEANUP_YES=0 /usr/local/bin/cleanup.sh /data 30 || true
# 记录到系统日志,便于 journalctl 检索
logger -t cleanup "清理完成: $count 个文件, 日志 $LOG"
8. 总结
| 环节 | 要点 |
|---|---|
| 心智模型 | find 是对目录树逐节点求值布尔表达式 |
| 表达式 | 隐式 AND,-o 为 OR,括号须转义,动作会短路 |
| 执行 | -exec {} + 效率高,xargs -0 灵活但须配 -print0 |
| 路径安全 | 除 / 与 NUL 外皆是合法文件名字节,用 NUL 分隔 |
| 选项注入 | 用 -- 终止选项解析,或用 ./ 前缀规避 |
| 去重 | sort -z -u 处理多起点,-inum 处理硬链接 |
| 现代工具 | fd 与 rg 默认尊重 gitignore,脚本中须检查依赖 |
| 性能 | 先剪枝再并行,-xdev 防跨文件系统 |
文件查找与批量操作的本质,是把「不可信的目录内容」和「不可逆的批量动作」隔离开:先枚举成清单、再人工确认、最后执行并留痕。把这三步固化成模板,清理脚本就不会成为事故源头。文件找出来之后,下一步常常要按时间维度筛选与归档,这就轮到日期处理登场了。
延伸阅读
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。