Linux-07 find 与 xargs:表达式求值模型、-exec 的两种形式与 -print0 的必要性
find 是最容易「会用但不理解」的命令:大家都会 find . -name '*.log',但一旦条件复杂起来就开始碰运气。
根本原因是大多数人把 find 的参数当成了「选项」,而它们其实是「表达式」。这个认知差会直接导致误删文件。
先看五个问题:
find . -name '*.tmp' -o -name '*.log' -delete会删掉什么?(答案可能吓你一跳)-exec cmd {} \;和-exec cmd {} +差在哪?为什么后者能快几十倍?find . -name '*.log' | xargs rm有什么风险?find /data -mtime +7找的到底是「7 天前」还是「8 天前」的文件?- 为什么
find . -type f -name '*.go'比find . -name '*.go' -type f快?
1. find 的参数是表达式,不是选项
1.1 三类参数
find [起始路径...] [全局选项] [表达式]
^^^^^^^^^^^^ ^^^^^^^^^^ ^^^^^^^^
| 类别 | 例子 | 特点 |
|---|---|---|
| 全局选项 | -maxdepth、-mindepth、-follow、-xdev |
影响整体行为,必须写在表达式前面 |
| 测试(test) | -name、-type、-size、-mtime、-user |
返回真/假 |
| 动作(action) | -print、-delete、-exec、-ls、-printf |
执行操作,并且自身也返回真/假 |
| 运算符 | -a(与)、-o(或)、!(非)、( )(分组) |
组合前两者 |
关键在于:find 对每个文件,从左到右求值整个表达式。这不是「传参数」,是「跑一个布尔表达式」。
find . -name '*.log' -print
# └─测试─────┘ └动作┘
# 含义:对每个文件,先判断名字是否匹配 *.log,为真则执行 -print
1.2 隐式的 -a 和默认的 -print
两条省略规则解释了 find 的大部分「魔法」:
# 规则一:相邻的两个表达式之间默认是 -a(AND)
find . -type f -name '*.log'
find . -type f -a -name '*.log' # 完全等价
# 规则二:整个表达式里【没有任何动作】时,自动在最外层加 -print
find . -name '*.log'
find . -name '*.log' -print # 完全等价
第二条规则是所有误删事故的根源,下一节展开。
1.3 短路求值:为什么顺序影响性能
开篇第五问。-a 和 -o 都是短路的,和 C 语言的 &&、|| 一样:
# -a 短路:左边为假,右边根本不执行
find . -type f -name '*.go'
# ^^^^^^^ 先判断类型(读 inode 里已有的字段,几乎免费)
# ^^^^^^^^^^^^ 类型不是文件就跳过,不用做字符串匹配
find . -name '*.go' -type f
# ^^^^^^^^^^^^ 对【每一个】条目都做字符串匹配(包括所有目录)
# 然后才判断类型
# 差异在大目录树上是可测量的:先用便宜的测试过滤掉大部分候选
性能排序原则(从便宜到昂贵):
-maxdepth / -xdev 限制搜索范围,最便宜
↓
-type / -user / -perm 读 inode 里的字段(find 遍历时本来就要 stat)
↓
-name / -path 字符串匹配
↓
-size / -mtime 也是 inode 字段,但常需要比较运算
↓
-regex 正则匹配
↓
-exec grep ... 【最贵】要起进程、读文件内容
# ❌ 慢:对每个文件都起一个 grep 进程
find / -exec grep -l "pattern" {} \; 2>/dev/null
# ✅ 快:先用便宜的条件把候选集缩小几个数量级
find /var/log -maxdepth 2 -type f -name '*.log' -size -100M \
-exec grep -l "pattern" {} +
2. 运算符与最危险的陷阱
2.1 优先级
! 或 -not 最高(非)
-a 或 -and 中(与,可省略)
-o 或 -or 最低(或)
( ) 改变优先级(在 shell 里必须转义成 \( \))
-a 优先级高于 -o,和数学里「乘法先于加法」一样。这就是陷阱的来源。
2.2 开篇第一问:-o 与 -delete 混用
find . -name '*.tmp' -o -name '*.log' -delete
很多人以为它删的是「所有 .tmp 和 .log」。实际上它只删 .log,而且把所有 .tmp 打印出来。
按优先级加上括号看:
# 实际的求值结构(-a 优先级高于 -o):
find . ( -name '*.tmp' ) -o ( -name '*.log' -a -delete )
# ^^^^^^^^^^^^^^^ ^^^^^^^^^^^^^^^^^^^^^^^^^^
# 左分支:只是个测试 右分支:测试 AND 删除
#
# 又因为整个表达式里【有动作】(-delete),所以不会自动加 -print?
# —— 不对!-delete 只在右分支里,左分支为真时短路,
# 此时 find 认为该文件"匹配了表达式",而 GNU find 的规则是:
# 只要表达式里存在动作,就不加隐式 -print。
# 所以 .tmp 文件既不被删也不被打印,看起来"什么都没发生"。
实际验证一下:
mkdir -p /tmp/ftest && cd /tmp/ftest
touch a.tmp b.tmp c.log d.log
find . -name '*.tmp' -o -name '*.log' -delete
ls
# a.tmp b.tmp <- .log 被删了,.tmp 还在!
正确写法:用括号明确分组。
touch c.log d.log # 重新造两个
find . \( -name '*.tmp' -o -name '*.log' \) -delete
# ^^ ^^
# 括号在 shell 里是特殊字符,必须转义(或用引号 '(' ')')
ls
# (空) <- 全部删掉了
这个陷阱同样适用于 -exec、-print、-ls:
# ❌ 只对 .log 执行 ls
find . -name '*.tmp' -o -name '*.log' -exec ls -l {} \;
# ✅
find . \( -name '*.tmp' -o -name '*.log' \) -exec ls -l {} \;
# 排查技巧:写危险命令前,先把动作换成 -print 看看命中了什么
find . \( -name '*.tmp' -o -name '*.log' \) -print
# 确认无误后再换成 -delete
养成习惯:只要用了 -o,就一定加括号。
2.3 其他运算符用法
# 非
find . ! -name '*.go' # 不是 .go 的
find . -not -name '*.go' # 等价
find . -type f ! -path '*/node_modules/*' # 排除某个目录下的文件
# 复杂组合:找出(.go 或 .py)且大于 1M 且不在 vendor 里的文件
find . \( -name '*.go' -o -name '*.py' \) -a -size +1M -a ! -path '*/vendor/*'
# 排除多个目录(-prune 更高效,见 2.4)
find . -type d \( -name node_modules -o -name .git \) -prune -o -type f -print
2.4 -prune:真正的「跳过目录」
用 ! -path 排除只是过滤掉结果,find 仍然会进入那个目录遍历。-prune 才是「根本不进去」:
# ❌ 慢:仍然会递归进 node_modules 里的几万个文件,只是不输出
find . -type f ! -path '*/node_modules/*'
# ✅ 快:遇到 node_modules 目录直接剪枝,不进入
find . -name node_modules -prune -o -type f -print
# ^^^^^^^^^^^^^^^^^^^^^^^^^ ^^ ^^^^^^^^^^^^^^^
# 匹配到就剪枝(-prune 返回真) 或者:是文件就打印
-prune 的固定句式必须记住(三个部分缺一不可):
find 路径 <排除条件> -prune -o <真正的条件> -print
# ^^ ^^^^^^
# 必须有 -o 必须显式写 -print
为什么必须显式写 -print?因为 -prune 是动作,表达式里有动作后 find 就不会自动加 -print 了,右边分支不写的话什么都不输出。
# 排除多个目录
find . \( -name node_modules -o -name .git -o -name vendor \) -prune -o -type f -print
# 只排除目录但保留同名文件(-type d 更精确)
find . -type d -name .git -prune -o -type f -name '*.go' -print
# 常用封装
alias ff='find . \( -name node_modules -o -name .git -o -name .venv \) -prune -o -type f'
ff -name '*.go' -print
3. 常用测试条件
3.1 按名字
find . -name '*.log' # 名字匹配(glob 通配,【必须加引号】)
find . -iname '*.LOG' # i = 忽略大小写
find . -path '*/test/*.go' # 匹配【完整路径】而不只是文件名
find . -ipath '*/TEST/*' # 忽略大小写的路径匹配
find . -regex '.*/[0-9]+\.log' # 整个路径匹配正则(默认是 emacs 正则)
find . -regextype posix-extended -regex '.*/[0-9]+\.log' # 换成扩展正则
find . -lname '*/target' # 软链接的【目标】匹配
引号为什么必须加?
# ❌ 没引号时,通配符先被 shell 展开了
find . -name *.log
# 如果当前目录恰好有 a.log b.log,shell 会展开成:
# find . -name a.log b.log -> find: paths must precede expression: 'b.log'
# 如果当前目录【没有】.log 文件,shell 展开失败保持原样,反而"碰巧能用"
# —— 这就是为什么它"有时候能跑",这种不确定性最危险
# ✅ 加引号,让 find 自己处理通配符
find . -name '*.log'
find . -name "*.log"
注意 -name 的 glob 和 shell 的 glob 有个区别:
find . -name '*.log' # 能匹配隐藏文件 .hidden.log(find 不特殊对待点开头)
ls *.log # shell 的 glob 不匹配 . 开头的
3.2 按类型
find . -type f # f = file,普通文件
find . -type d # d = directory
find . -type l # l = symbolic link
find . -type s # s = socket
find . -type p # p = named pipe (FIFO)
find . -type b # b = block device
find . -type c # c = character device
# 组合
find /dev -type c -name 'tty*'
find . -xtype l # 【悬空软链接】:链接指向的目标不存在 ← 部署检查常用
3.3 按大小
find . -size +100M # 大于 100MB
find . -size -1k # 小于 1KB
find . -size 0 # 恰好为 0(也可以用 -empty)
find . -empty # 空文件【或】空目录
find . -type f -empty # 只要空文件
# 单位(大小写敏感!)
# c = 字节(byte) ← 最直观
# k = KiB (1024)
# M = MiB
# G = GiB
# b = 512 字节块(【默认单位,容易踩坑】)
# w = 2 字节字
find . -size +1000 # ⚠️ 这是 1000 个【512 字节块】= 500KB,不是 1000 字节!
find . -size +1000c # ✅ 这才是 1000 字节
# ⚠️ 另一个坑:-size 是【向上取整】的
# 一个 1 字节的文件,-size 1k 会匹配(因为它占用了 1 个 k 单位)
find . -size -1M # 实际是"占用不足 1M 个单位",即小于 1MB 的
3.4 按时间(重点)
# 三种时间(对应 04 篇讲的三个时间戳)
-mtime / -mmin # 内容修改时间 ← 最常用
-atime / -amin # 访问时间
-ctime / -cmin # inode 变更时间(权限、属主、链接数改变也会更新)
# 数字的三种形式
find . -mtime +7 # 【超过】7 天
find . -mtime -7 # 【不到】7 天(7 天内)
find . -mtime 7 # 【恰好】第 7 天那一天(很少用)
开篇第四问:-mtime +7 到底是几天?
find 内部的算法是:
age = (当前时间 - 文件 mtime) / 86400 然后【向下取整】成整数
+7 的判断是: age > 7
所以一个「7.9 天前」修改的文件,age = 7(取整后),不满足 > 7,不会被匹配。
-mtime +7 实际含义是「超过 8 天」(准确说是 age ≥ 8)。
# 验证
touch -d '7 days ago 12:00' /tmp/f7
find /tmp -name f7 -mtime +7 # (无输出)
find /tmp -name f7 -mtime +6 # /tmp/f7
# ✅ 想精确表达「7 天前及更早」,用 -newermt 取反
find /var/log -type f ! -newermt '7 days ago' -delete
# ^^^^^^^^^^^^^^^^^^^^^^^ 不比"7天前那个时刻"新 = 7 天前或更早
# 精确到秒,不做取整
-newer 系列更直观,推荐优先使用:
find . -newer ref.txt # 比 ref.txt 的 mtime 新
find . -newermt '2026-08-01' # 比这个时刻新(mt = mtime)
find . -newermt '2 hours ago' # 支持自然语言
find . ! -newermt '2026-08-01' # 早于这个时刻
find . -newerct '1 day ago' # ct = ctime
find . -newerat '1 hour ago' # at = atime
# 时间区间:找 8 月 1 日到 8 月 5 日之间修改的
find . -newermt '2026-08-01' ! -newermt '2026-08-06'
# 分钟粒度(排查刚发生的事故时用)
find /var/log -mmin -30 # 30 分钟内修改过的
find /etc -mmin -60 -type f # 一小时内被改过的配置 ← 事故排查常用
3.5 按权限与属主
find . -user lhx # 属主是 lhx
find . -uid 1000 # 按 uid(用户被删后只剩数字时用)
find . -group docker
find . -nouser # 属主的 uid 在 /etc/passwd 里不存在(用户被删了)
find . -nogroup
# 权限匹配的三种模式(最容易混)
find . -perm 644 # 权限【恰好】是 644
find . -perm -644 # 【至少】包含这些位(可以有更多权限)
find . -perm /644 # 【任意一位】匹配即可(u+r 或 g+r 或 o+r)
# 安全审计常用
find / -perm -4000 -type f 2>/dev/null # 所有 SUID 程序 ← 必查
find / -perm -2000 -type f 2>/dev/null # 所有 SGID 程序
find / -perm -002 -type f 2>/dev/null # 所有【其他人可写】的文件 ← 高危
find /home -perm -o+w -type d # 其他人可写的目录
find ~/.ssh -type f ! -perm 600 # ssh 密钥权限不对的(ssh 会拒绝使用)
3.6 其他常用
find . -inum 2621441 # 按 inode 号找(找一个文件的所有硬链接)
find . -samefile /path/to/f # 同上,更直观:找出与该文件同 inode 的所有名字
find . -links +1 -type f # 硬链接数大于 1 的文件
find . -maxdepth 2 # 最多向下 2 层 ← 全局选项,写在最前面
find . -mindepth 1 # 至少 1 层(排除起始目录本身)
find . -xdev # 不跨文件系统(等价 -mount) ← 扫 / 时必加
find . -follow # 跟随软链接(默认不跟)
find . -readable -writable # 当前用户可读/可写的
find . -executable -type f
4. 动作:-exec、-delete、-printf
4.1 -exec 的两种形式(开篇第二问)
find . -name '*.log' -exec gzip {} \; # 形式一:分号结尾
find . -name '*.log' -exec gzip {} + # 形式二:加号结尾
\; |
+ |
|
|---|---|---|
| 执行方式 | 每个文件起一个进程 | 攒够一批,一次传多个参数 |
| 等价于 | gzip a.log; gzip b.log; gzip c.log |
gzip a.log b.log c.log |
{} 位置 |
可以在任意位置、可出现多次 | 必须在命令末尾,只能出现一次 |
| 性能 | 1000 个文件 = 1000 次 fork+exec | 1000 个文件 ≈ 1~2 次 |
| 退出码 | 每次都检查,非 0 会影响 find 的返回值 | 只看最后一批 |
性能差距实测:
mkdir -p /tmp/bench && cd /tmp/bench && touch f{1..5000}
time find . -type f -exec touch {} \;
# real 0m8.234s <- 5000 次 fork + exec
time find . -type f -exec touch {} +
# real 0m0.061s <- 几次调用搞定,快 130 倍
默认用 +,只有下面几种情况才用 \;:
# ① {} 需要出现在命令中间或多次
find . -name '*.txt' -exec mv {} {}.bak \;
# ^^ ^^^^^^ 两个 {},只能用 \;
# ② 命令一次只能处理一个文件
find . -name '*.tar.gz' -exec tar -xzf {} -C /tmp/out \;
# ③ 需要用 shell 特性(管道、重定向、变量)
find . -name '*.log' -exec sh -c 'gzip -c "$1" > "$1.gz"' _ {} \;
# ^^ _ 是占位的 $0
# 用 + 的版本(更快,但脚本要自己遍历参数)
find . -name '*.log' -exec sh -c 'for f; do gzip -c "$f" > "$f.gz"; done' _ {} +
# ^^^^ for f 默认遍历 "$@"
\; 为什么要转义? 因为 ; 在 shell 里是命令分隔符,不转义的话 shell 会把 find ... -exec gzip {} 和后面的内容拆成两条命令。写成 \;、';'、";" 都可以。
4.2 -execdir:更安全的变体
find . -name '*.log' -execdir gzip {} \;
# ^^^^^^^^ 在【文件所在的目录】里执行命令,{} 是 ./文件名
两个优势:
# ① 避免路径中的空格、特殊字符引发的问题({} 只是 ./basename)
# ② 防御【竞态攻击】:
# 用 -exec 时,find 传的是完整路径,从 find 找到文件到命令执行之间,
# 攻击者可能把路径中的某个目录换成软链接,导致命令作用到别的文件上。
# -execdir 先 chdir 到目标目录再执行,规避了这个窗口。
# ⚠️ 注意 -execdir 也支持 + 形式,但会按目录分批
find . -name '*.log' -execdir gzip {} +
4.3 -delete
find /tmp/cache -type f -mtime +7 -delete
比 -exec rm {} + 更好的地方:不用起进程(find 直接调 unlink()),且不受参数长度限制。
四个注意点:
# ① -delete 隐含了 -depth(先处理子目录再处理父目录),
# 所以它能删非空目录树,但也意味着遍历顺序变了,
# 与 -prune 一起用时行为可能不符合预期
find . -name node_modules -prune -o -name '*.tmp' -delete # ⚠️ -prune 会失效
# ② 用了 -o 必须加括号(第 2.2 节的陷阱)
find . \( -name '*.a' -o -name '*.b' \) -delete
# ③ 删目录时目录必须为空(除非它自己也被匹配到并按 -depth 顺序处理)
find . -type d -empty -delete # 删空目录
find . -depth -type d -empty -delete # 递归删多层空目录
# ④ 【永远先用 -print 演练一遍】
find /data -type f -mtime +30 -print | head -20 # 先看命中什么
find /data -type f -mtime +30 -delete # 确认后再删
4.4 -printf:自定义输出
比 -ls 灵活得多,做统计和脚本时非常有用:
| 格式符 | 含义 | 格式符 | 含义 |
|---|---|---|---|
%p |
完整路径 | %f |
文件名(basename) |
%h |
目录部分(dirname) | %P |
去掉起始路径的相对路径 |
%s |
大小(字节) | %k / %b |
占用的 KB / 512B 块 |
%m |
权限(八进制) | %M |
权限(符号,如 -rw-r--r--) |
%u / %U |
属主名 / uid | %g / %G |
属组名 / gid |
%i |
inode 号 | %n |
硬链接数 |
%y |
文件类型(f/d/l…) | %Y |
同上但解引用软链接 |
%t / %Tx |
mtime | %TY-%Tm-%Td |
年-月-日 |
%a / %c |
atime / ctime | %T@ |
mtime 的 Unix 时间戳(排序用) |
%l |
软链接的目标 | %d |
相对起始点的深度 |
\n \t \0 |
换行 / 制表符 / NUL |
# 找出最大的 10 个文件(比 ls -lS 强:能递归)
find . -type f -printf '%s\t%p\n' | sort -rn | head -10
# 找出最新修改的 10 个文件
find . -type f -printf '%T@ %p\n' | sort -rn | head -10 | cut -d' ' -f2-
# ^^^ Unix 时间戳,才能正确按数值排序
# 统计各扩展名的文件数
find . -type f -printf '%f\n' | grep -oP '\.[^.]+$' | sort | uniq -c | sort -rn
# 统计总大小(不起 du)
find . -name '*.log' -printf '%s\n' | awk '{s+=$1} END{print s/1024/1024 " MB"}'
# 生成一份带权限的清单
find /etc -maxdepth 1 -printf '%M %u:%g %10s %TY-%Tm-%Td %p\n'
# 快速数文件个数(比 | wc -l 快,不用构造完整输出)
find . -type f -printf '.' | wc -c
# 找出软链接及其目标
find /etc -type l -printf '%p -> %l\n'
4.5 其他动作
find . -name '*.log' -ls # 类似 ls -dils 的详细输出
find . -name '*.log' -print # 输出路径 + 换行
find . -name '*.log' -print0 # 输出路径 + \0 ← 配合 xargs -0
find . -name '*.log' -fprint out.txt # 输出到文件
find . -name '*.log' -quit # 找到第一个就退出(判断"是否存在"时高效)
# 判断某类文件是否存在(不需要遍历完)
if find /data -name '*.lock' -print -quit | grep -q .; then echo "有锁文件"; fi
5. xargs:把标准输入变成参数
5.1 为什么需要 xargs
很多命令不接受标准输入作为参数:
echo "/tmp/a.txt" | rm
# rm: missing operand <- rm 不读 stdin,它只看命令行参数
echo "/tmp/a.txt" | xargs rm
# ^^^^^ 把 stdin 的内容转成命令行参数:rm /tmp/a.txt
xargs 的另一个价值是自动分批,绕开 ARG_MAX 限制:
rm /data/logs/*.log
# bash: /usr/bin/rm: Argument list too long
# ^^ shell 展开出几十万个参数,超过内核的 ARG_MAX
getconf ARG_MAX # 2097152 字节
find /data/logs -name '*.log' -print0 | xargs -0 rm
# ^^^^^ 自动按 ARG_MAX 分批调用 rm
5.2 -print0 与 -0:唯一安全的管道写法
开篇第三问。
find . -name '*.log' | xargs rm
这个写法有两个致命问题:
# 造一个"合法但恶劣"的文件名
mkdir -p /tmp/xtest && cd /tmp/xtest
touch 'important file.log' # 名字里有空格
touch $'weird\nname.log' # 名字里有换行!
find . -name '*.log' | xargs rm
# rm: cannot remove './important': No such file or directory
# rm: cannot remove 'file.log': No such file or directory
# ^^ xargs 默认按【空白】分隔,一个文件名被拆成了两个参数
# 换行的情况更糟:可能删掉完全不相干的文件
根因:find 默认用换行分隔输出,而 xargs 默认按空白(空格、tab、换行)分隔输入。但文件名里除了 / 和 \0 什么字符都合法(04 篇第 8 节)。
唯一安全的方案:用 \0(NUL)分隔 —— 因为它是文件名里唯一不可能出现的字符。
find . -name '*.log' -print0 | xargs -0 rm
# ^^^^^^^ ^^
# 用 \0 分隔 按 \0 切分
# 记忆:find 的 -print0 与 xargs 的 -0 永远成对出现
其他产生 \0 输出的命令:
grep -rlZ 'pattern' . | xargs -0 sed -i 's/a/b/g'
# ^ Z = --null,输出文件名用 \0 分隔
sort -z / uniq -z / cut -z / tr -d '\0'
readlink -z / realpath -z / du -0 / stat --printf='%n\0'
如果不想用 xargs,-exec 本身就是安全的(它不经过 shell 解析):
find . -name '*.log' -exec rm {} + # ✅ 同样安全,且更简单
5.3 xargs 的常用参数
| 短 | 长 | 作用 |
|---|---|---|
-0 |
--null |
输入按 \0 分隔(配合 -print0) |
-d C |
--delimiter |
自定义分隔符(如 -d '\n' 只按换行分隔) |
-n N |
--max-args |
每次最多传 N 个参数 |
-L N |
--max-lines |
每次最多用 N 行输入 |
-s N |
--max-chars |
每次命令行最多 N 字符 |
-P N |
--max-procs |
并行跑 N 个进程(-P 0 = 尽可能多) |
-I {} |
--replace |
用 {} 占位,每行作为一个整体参数(隐含 -L 1) |
-r |
--no-run-if-empty |
输入为空时不执行命令(GNU 特有,很重要) |
-t |
--verbose |
执行前打印命令(调试用) |
-p |
--interactive |
每条命令执行前询问 |
-a FILE |
--arg-file |
从文件读参数而不是 stdin |
-E EOF |
--eof |
遇到指定字符串就停止读取 |
5.4 三个重要用法
① -r:防止空输入导致误执行
# ❌ 危险:如果 find 没找到任何文件,xargs 仍会执行一次 rm(无参数)
find /tmp -name '*.nonexistent' | xargs rm
# rm: missing operand <- 这次只是报错,但换成别的命令可能出事
echo "" | xargs ls
# (列出了当前目录!因为 ls 无参数时列出 .)
# ✅ 加 -r
find /tmp -name '*.nonexistent' -print0 | xargs -0 -r rm
# ⚠️ -r 是 GNU 扩展,BSD/macOS 没有(macOS 的 xargs 默认就不执行空输入)
② -I {}:需要把参数放在中间时
# 默认 xargs 把参数追加到命令末尾
find . -name '*.log' -print0 | xargs -0 cp -t /backup/ # ✅ 用 -t 指定目标
# 参数要放在中间时用 -I
find . -name '*.log' -print0 | xargs -0 -I {} cp {} /backup/
# ^^^^^ 用 {} 占位
find . -name '*.txt' -print0 | xargs -0 -I {} mv {} {}.bak
# ⚠️ -I 隐含 -L 1,即【每次只处理一个】,会退化成一个文件一个进程(慢)
# 所以能用 -t 或 -exec ... + 的话优先用它们
# ⚠️ -I 与 -0 一起用时,某些版本行为有差异,建议改用 -exec 或 while read
③ -P N:并行加速
# 串行压缩 1000 个日志(单核跑满)
find . -name '*.log' -print0 | xargs -0 -n1 gzip
# 并行 8 个进程(CPU 密集型任务能快接近 8 倍)
find . -name '*.log' -print0 | xargs -0 -P 8 -n 1 gzip
# ^^^^ 并发数
find . -name '*.log' -print0 | xargs -0 -P "$(nproc)" -n 1 gzip
# 并行删除百万小文件(IO 密集,并发数不宜太高)
find /cache -type f -print0 | xargs -0 -P 4 -n 1000 rm -f
# 并行 ping 一批主机
cat hosts.txt | xargs -P 20 -I {} sh -c 'ping -c1 -W1 {} >/dev/null && echo "{} up"'
# ⚠️ 并行时多个进程的输出会交错混在一起
# 要保证输出完整性,用 GNU parallel(它会缓冲每个任务的输出)
find . -name '*.log' | parallel -j8 gzip {}
5.5 三种批处理方式对比
| 方式 | 安全性 | 性能 | 何时用 |
|---|---|---|---|
-exec cmd {} + |
✅ 安全 | ✅ 快(批量) | 默认选它 |
-exec cmd {} \; |
✅ 安全 | ❌ 慢(一个一进程) | {} 需在中间/多次出现时 |
-print0 | xargs -0 |
✅ 安全 | ✅ 快,且能 -P 并行 |
需要并行、或需要管道中间处理时 |
| xargs(无 -0) |
❌ 不安全 | — | 永远不要用 |
for f in $(find ...) |
❌ 不安全 | ❌ 慢 | 永远不要用 |
# ✅ 处理任意文件名的 while 循环(需要复杂逻辑时)
while IFS= read -r -d '' f; do
echo "处理: $f"
# 复杂的多步逻辑
done < <(find . -name '*.log' -print0)
# ^^^^ IFS= 防止首尾空白被吃
# -r 防止反斜杠被转义
# -d '' 以 \0 为分隔符
6. 现代替代工具
6.1 fd:用户友好的 find
# 安装:多数发行版已收录
sudo apt install fd-find # Debian/Ubuntu(命令是 fdfind 或 fd)
sudo dnf install fd-find # Fedora
brew install fd # macOS
# 语法比 find 直观得多
fd '\.log$' # 正则默认启用(不需要 -regex)
fd -e log # 按扩展名(-e = --extension)
fd -t f -t l # 按类型(可多个,f=file l=link d=directory)
fd -s pattern # 大小写敏感(默认智能:全小写则不敏感)
fd -H hidden # 搜索隐藏文件(-H = --hidden)
fd -I node_modules # 搜索忽略的目录(-I = --no-ignore)
fd -E node_modules pattern # -E 排除(可多个)
# 默认彩色输出、自动 .gitignore、速度快(用 Rust 写的)
# 但不如 find 强大:没有 -mtime、-perm、-user 这些
6.2 ripgrep (rg):按内容搜索
rg 'pattern' /path # 递归搜索内容(比 grep -r 快很多)
rg -t go 'func main' # 只搜 .go 文件
rg -g '*.{yml,yaml}' key # 按 glob 筛选
rg --files-with-matches ERROR # 只输出文件名(等价 grep -rl)
rg -C 3 pattern # 上下文各 3 行
# 自动跳过 .git、node_modules、二进制文件,速度极快
# 服务器上排查日志时比 find + grep 的组合快得多
6.3 locate / updatedb:预建索引
# 原理:updatedb 预先扫描整个文件系统建索引(通常由 cron 每天跑一次)
# locate 查询这个索引(毫秒级)
sudo updatedb # 手动更新索引
locate nginx.conf # 瞬间找到
locate -i nginx # 忽略大小写
locate -c '*.log' # 只统计数量
locate -r '/var/log/.*\.log$' # 用正则
# 优势:极快,适合「这个文件在系统的哪个角落」这种问题
# 劣势:① 索引可能过期(刚创建的文件找不到)
# ② 看得到所有文件的路径(包括你无权访问的)—— 信息泄漏
# ③ 可能被 /etc/updatedb.conf 排除掉某些目录
cat /etc/updatedb.conf # 看哪些目录被排除了
# macOS 用的是 mdfind(Spotlight 的 CLI)
mdfind -name nginx.conf
6.4 选择建议
| 场景 | 推荐工具 |
|---|---|
| 按名字找文件 | 日常:fd > locate > find |
| 按内容找文件 | rg > grep -r |
| 复杂条件(时间、权限、大小) | 只能用 find |
需要 -exec / -delete |
只能用 find |
| 脚本里可移植性 | 只能用 find(POSIX 保证存在) |
| 审计、安全扫描 | find(精确控制) |
# 现代组合:fd 找文件 + rg 搜内容
fd -e go -x rg 'TODO' # -x = --exec,类似 find 的 -exec {} +
fd -e log -x gzip # 并行压缩
# 仍然需要 find 的场景
find / -perm -4000 -type f # SUID 审计
find /var/log -mtime +30 -delete # 按时间清理
find . -newer ref -exec ... # 比某个时刻新的文件
7. 实战案例
7.1 清理日志与临时文件
# 删除 30 天前的压缩日志
find /var/log -name '*.gz' -mtime +30 -delete
# 删除 7 天内没被访问的缓存(atime 不可靠,见注意)
find /var/cache/myapp -type f -atime +7 -delete
# ⚠️ atime 在很多文件系统上默认不更新(relatime),不可靠
# 改用 mtime(修改时间)或 ctime(状态变更时间)更准确
# 递归删除所有空目录
find /data -type d -empty -delete
# 删除多层嵌套的空目录
find /data -depth -type d -empty -delete
# ^^^^^^ -depth 先处理子目录
# 清理临时构建产物(并行删除加速)
find . -name node_modules -o -name .venv -o -name target | \
xargs -P 4 -I {} rm -rf {}
# 只删文件、保留目录结构
find /data/cache -type f -delete
7.2 批量重命名
# 给所有 .txt 加上日期前缀
find . -name '*.txt' -type f -print0 | \
xargs -0 -I {} sh -c 'mv "$1" "$(dirname "$1")/$(date +%Y%m%d)_$(basename "$1")"' _ {}
# 转小写(需要 rename 命令,Perl 版)
find . -name '*.JPG' -print0 | xargs -0 rename 's/\.JPG$/.jpg/'
# 批量去掉空格
find . -name '* *' -print0 | \
while IFS= read -r -d '' f; do mv "$f" "${f// /_}"; done
# 扁平化:把子目录里的文件全部移到当前目录
find . -mindepth 2 -type f -exec mv {} . \;
7.3 统计与分析
# 统计各类型文件的总大小
find . -type f -printf '%f\n' | \
awk -F. '{ext=$NF} {size[ext]+=$1} END {for(e in size) print e, size[e]}'
# 更准确的版本(用 -printf '%s')
find . -type f -printf '%s %f\n' | \
awk -F. '{ext=$(NF-1)"."$NF; size[ext]+=$1} END {for(e in size) printf "%-20s %10.2f MB\n", e, size[e]/1024/1024}'
# 找出重复文件(按内容 md5)
find . -type f -exec md5sum {} + | sort | uniq -w32 -d --all-repeated=separate
# 找出最近一小时改过的配置
find /etc -type f -mmin -60 -ls
# 找出大于 1GB 的文件并按大小排序
find / -type f -size +1G -printf '%s %p\n' 2>/dev/null | sort -rn | head -20
# 统计文件数(比 ls | wc 准确且快)
find . -type f -printf . | wc -c
# 统计各目录深度的文件数
find . -printf '%d\n' | sort -n | uniq -c
7.4 权限与安全审计
# 找出所有 SUID/SGID 程序(定期审计)
find / -type f \( -perm -4000 -o -perm -2000 \) -ls 2>/dev/null
# 找出所有人可写的文件(高危)
find / -type f -perm -002 ! -type l -ls 2>/dev/null
# 找出不属于任何已知用户的文件(用户被删后残留)
find /home -nouser -ls
# 修复 ~/.ssh 的权限
find ~/.ssh -type f -exec chmod 600 {} +
find ~/.ssh -type d -exec chmod 700 {} +
# 递归改属主
find /var/www -exec chown www-data:www-data {} +
# 找出最近被改过权限的文件(ctime)
find /etc -cmin -60 -ls
7.5 与 git 结合
# 只处理已跟踪的文件
git ls-files -z | xargs -0 grep 'TODO'
# 处理已跟踪 + 未跟踪但不在 .gitignore 里的
git ls-files --others --exclude-standard -z | xargs -0 ...
# 找出所有不在 git 里的文件(排查遗漏)
comm -23 <(find . -type f | sort) <(git ls-files | sort)
# 删除所有不在 git 里的文件(危险!先 -n 演练)
git clean -fdxn # -n 演练
git clean -fdx # 真删
7.6 备份与同步
# 增量备份:只复制修改过的文件
find /data -newer /backup/last_backup_timestamp -print0 | \
rsync -av --files-from=- --from0 / /backup/
# 硬链接快照(Time Machine 风格)
cp -al /backup/last /backup/$(date +%Y%m%d)
find /data -newer /backup/last -print0 | \
xargs -0 -I {} cp --parents {} /backup/$(date +%Y%m%d)/
# 查找差异
find dir1 dir2 -printf '%P\n' | sort | uniq -u # 只在一边存在的文件
8. 知识点扩展
8.1 find 命令结构
find [路径...] [全局选项] [表达式]
全局选项(必须写在表达式之前):
| 选项 | 作用 |
|---|---|
-maxdepth N |
最多向下 N 层 |
-mindepth N |
至少 N 层(排除起始目录本身用 -mindepth 1) |
-depth |
深度优先(先子后父) |
-xdev / -mount |
不跨文件系统 |
-follow |
跟随符号链接(默认不跟) |
-daystart |
时间从今天 0 点算(影响 -mtime 等) |
-regextype TYPE |
正则类型(emacs/posix-basic/posix-extended) |
-warn / -nowarn |
是否显示警告(默认显示) |
-O N |
优化级别(0~3,默认 1) |
测试(返回真/假):
| 测试 | 作用 |
|---|---|
| 名字 | |
-name PAT |
文件名匹配(glob) |
-iname PAT |
忽略大小写 |
-path PAT |
完整路径匹配 |
-regex PAT |
正则匹配(默认 emacs 风格) |
-lname PAT |
软链接目标匹配 |
| 类型 | |
-type f/d/l/s/p/b/c |
文件类型 |
-xtype |
解引用后的类型(找悬空链接用 -xtype l) |
| 时间 | |
-mtime N / -mmin N |
修改时间(天/分钟) |
-atime / -amin |
访问时间 |
-ctime / -cmin |
状态变更时间 |
-newer FILE |
比 FILE 的 mtime 新 |
-newermt TIME |
比时刻 TIME 新(支持 2 days ago) |
| 大小 | |
-size +N / -N / N |
大于/小于/等于 N 单位(c/k/M/G;无后缀=512B块) |
-empty |
空文件或空目录 |
| 权限属主 | |
-perm MODE |
权限恰好是 |
-perm -MODE |
至少包含这些位 |
-perm /MODE |
任意一位匹配 |
-user NAME / -uid N |
属主 |
-group / -gid |
属组 |
-nouser / -nogroup |
属主/属组不存在 |
| 其他 | |
-inum N |
inode 号 |
-links N |
硬链接数 |
-samefile F |
与 F 同 inode |
-readable / -writable / -executable |
当前用户可读/写/执行 |
动作(执行操作,并返回真/假):
| 动作 | 作用 |
|---|---|
-print |
打印路径 + 换行(默认动作) |
-print0 |
打印路径 + \0 |
-printf FMT |
格式化输出 |
-ls |
类似 ls -dils |
-fls FILE |
同上但输出到文件 |
-exec CMD \; |
每个文件执行一次 |
-exec CMD + |
批量执行 |
-execdir CMD |
在文件所在目录执行 |
-delete |
删除 |
-quit |
找到就退出 |
-prune |
跳过当前目录(剪枝) |
运算符:
| 运算符 | 作用 |
|---|---|
expr1 expr2 或 expr1 -a expr2 |
AND(默认) |
expr1 -o expr2 |
OR |
! expr 或 -not expr |
NOT |
\( expr \) |
分组 |
expr1 , expr2 |
逗号:expr1 和 expr2 都执行,返回 expr2 的值 |
8.2 xargs 完整参数
| 短 | 长 | 作用 |
|---|---|---|
-0 |
--null |
输入以 \0 分隔 |
-d C |
--delimiter=C |
自定义分隔符 |
-n N |
--max-args=N |
每次最多 N 个参数 |
-L N |
--max-lines=N |
每次最多 N 行 |
-s N |
--max-chars=N |
每次命令行最多 N 字符 |
-P N |
--max-procs=N |
并行 N 个进程 |
-I STR |
--replace=STR |
用 STR 占位(默认 {}) |
-i |
— | 等价 -I {} |
-r |
--no-run-if-empty |
输入为空不执行(GNU 扩展) |
-t |
--verbose |
执行前打印命令 |
-p |
--interactive |
每条命令执行前询问 |
-a FILE |
--arg-file=FILE |
从文件读参数 |
-E EOF |
--eof=EOF |
遇到字符串 EOF 停止 |
-x |
--exit |
参数过长时退出而不是截断 |
-o |
--open-tty |
重新打开 /dev/tty(给交互式命令用,如 vim) |
8.3 时间计算细节
find 对 -mtime N 的算法:
文件年龄(秒)= 当前时间 - 文件 mtime
天数 = floor(年龄 / 86400) 向下取整
-mtime +N -> 天数 > N
-mtime -N -> 天数 < N
-mtime N -> 天数 == N
# 恰好第 7 天那一天(7*86400 ~ 8*86400-1 秒之间)
find . -mtime 7
# 【超过】8 天(age >= 8,即 > 7 取整后)
find . -mtime +7
# 7 天内(age < 7)
find . -mtime -7
-daystart 会改变计算基准:
# 默认:从当前时刻往回算
find . -mtime +7 # 此刻前 7*86400 秒之前
# -daystart:从今天 0 点往回算
find . -daystart -mtime +7 # 今天 0 点前的第 7 个完整日
# 实用性:-daystart 语义更符合"日志按天归档"的直觉
-newer 系列更精确(到秒):
-newer FILE 比 FILE 的 mtime 新
-anewer FILE 比 FILE 的 atime 新
-cnewer FILE 比 FILE 的 ctime 新
-newerXY REF X=a/c/m/t,Y=a/c/m/t(X是被比较的时间,Y是参考的时间)
t = 直接用时间字符串
find . -newermt '2026-08-01 15:30:00'
find . -newermt '1 week ago'
find . -newermt @1722499200 # Unix 时间戳
find . ! -newermt '7 days ago' # 7 天前及更早
8.4 -printf 格式符完整列表
见 4.4 节的表格。补充两个技巧:
# 条件格式化:只有非空时才输出
find . -type l -printf '%p -> %l\n' # 软链接加箭头
find . -printf '%M %u %10s %p\n' # 类似 ls -l
# 配合 NUL 做安全的管道(比 -print0 更灵活)
find . -name '*.log' -printf '%p\0' | while IFS= read -r -d '' f; do ...; done
8.5 性能调优
# ① 限制深度(最便宜的优化)
find /var/log -maxdepth 2 -name '*.log'
# ② 先用便宜的条件过滤
find . -type f -name '*.go' ... # 比倒过来快
# ③ 不跨文件系统(跳过挂载点)
find / -xdev ...
# ④ 剪枝而不是过滤
find . -name node_modules -prune -o ...
# ⑤ 用 -quit 找到就退出(判断存在时)
if find /data -name '*.lock' -print -quit | grep -q .; then ...
# ⑥ 用 + 而不是 \;
find . -exec cmd {} +
# ⑦ 并行(xargs -P)
find . -print0 | xargs -0 -P $(nproc) cmd
# ⑧ 避免 -exec 里跑 shell(每次都 fork bash)
find . -name '*.txt' -exec sh -c '...' _ {} \; # 慢
find . -name '*.txt' -exec mycommand {} + # 快
# ⑨ 大量文件时,ls + grep 可能比 find 快(目录已在 dentry cache 里)
ls -1 | grep '.log$' # 单层目录
9. 面试题
Q:find . -name '*.tmp' -o -name '*.log' -delete 会删掉什么?为什么?
只删 .log,.tmp 不会被删。原因是 -a 优先级高于 -o,实际求值结构是:
( -name '*.tmp' ) -o ( -name '*.log' -a -delete )
左分支为真时短路,不执行右分支,所以 .tmp 文件既不被删也不被打印。只有 .log 同时匹配了测试和 -delete。
正确写法:用括号明确分组。
find . \( -name '*.tmp' -o -name '*.log' \) -delete
这个陷阱同样适用于 -exec、-print、-ls 等所有动作。养成习惯:只要用了 -o,就一定加括号。 危险命令执行前先把动作换成 -print 演练一遍。
Q:-exec cmd {} \; 和 -exec cmd {} + 有什么区别?为什么后者快几十倍?
\;(分号):每个文件起一个进程,等价于cmd a.log; cmd b.log; cmd c.log+(加号):攒够一批,一次传多个参数,等价于cmd a.log b.log c.log
1000 个文件时,前者要 1000 次 fork + exec,后者只要 1~2 次。实测快 100 倍以上。
默认用 +,只有以下情况才用 \;:
{}需要出现在命令中间或多次(如mv {} {}.bak)- 命令一次只能处理一个文件
- 需要 shell 特性(管道、重定向),此时写成
-exec sh -c '...' _ {} \;(用+时脚本要自己遍历参数)
Q:find . -name '*.log' | xargs rm 有什么风险?怎么修正?
两个致命问题:
- 文件名里有空格会被拆成多个参数(
xargs默认按空白分隔) - 文件名里有换行会被当成多个文件
根因是文件名里除了 / 和 \0 什么字符都合法(包括空格、换行、tab),而 find 默认用换行分隔、xargs 默认按空白分隔。
唯一安全的方案:用 \0 分隔(它是文件名里唯一不可能出现的字符)。
find . -name '*.log' -print0 | xargs -0 rm
或者直接用 -exec 更简单:
find . -name '*.log' -exec rm {} +
| xargs(不加 -0)永远不要用。
Q:find /data -mtime +7 找的到底是「7 天前」还是「8 天前」的文件?
超过 8 天(准确说是 age ≥ 8)。
find 内部的算法:age = floor((now - mtime) / 86400),+7 的判断是 age > 7。所以一个「7.9 天前」的文件 age = 7(取整),不满足 > 7。
想精确表达「7 天前及更早」,用 -newermt 取反(它精确到秒,不做取整):
find /var/log -type f ! -newermt '7 days ago' -delete
-mtime 7(不带符号)是「恰好第 7 天那一天」,很少用。
Q:find . -type f -name '*.go' 和 find . -name '*.go' -type f 哪个快?
前者快。因为 find 的表达式是短路求值,-a(AND)左边为假就不执行右边。
- 第一种:先判断
-type f(读 inode 字段,几乎免费),类型不是文件就跳过,不用做字符串匹配 - 第二种:对每一个条目(包括所有目录)都做字符串匹配,然后才判断类型
性能排序原则(从便宜到贵):-maxdepth / -type / -name / -size / -mtime / -regex / -exec grep。把便宜的测试放前面。
Q:怎么排除 node_modules、.git 这些目录?! -path 和 -prune 有什么区别?
! -path 只是过滤结果,find 仍会进入那个目录遍历(慢);-prune 是剪枝,根本不进去(快)。
# ❌ 慢:仍递归进 node_modules
find . -type f ! -path '*/node_modules/*'
# ✅ 快:遇到就剪枝
find . -name node_modules -prune -o -type f -print
-prune 的固定句式(三部分缺一不可):
find 路径 <排除条件> -prune -o <真正的条件> -print
为什么必须显式写 -print?因为 -prune 是动作,表达式里有动作后 find 不会自动加 -print,右边不写就什么都不输出。
Q:为什么服务器审计要定期查 SUID 程序和所有人可写的文件?
SUID 程序(-perm -4000)以文件属主的权限执行,如果属主是 root,任何用户执行它都能拿到 root 权限。合法的有 passwd、sudo,但攻击者常通过提权漏洞植入恶意 SUID 程序作为后门。
find / -perm -4000 -type f -ls 2>/dev/null
# 定期审计,发现可疑的立即排查
所有人可写的文件(-perm -002)意味着任何用户都能改,攻击者可以篡改内容、注入代码、或替换成恶意程序。系统关键文件(/etc/passwd、/etc/shadow、/bin/*)如果是 o+w,系统已失守。
find / -type f -perm -002 ! -type l -ls 2>/dev/null
# ! -type l 排除软链接(软链接自身权限永远是 777 但无意义)
这两类是入侵检测的基本检查项,应该定期(如每周)跑一次并对比基线。
Q:为什么 xargs 需要 -r 参数?它解决了什么问题?
-r = --no-run-if-empty(GNU 扩展),防止输入为空时仍执行一次命令。
# ❌ 危险:find 没找到任何文件,xargs 仍会执行一次 rm(无参数)
find /tmp -name '*.nonexistent' | xargs rm
# rm: missing operand <- 只是报错,但换成别的命令可能出事
echo "" | xargs ls
# (列出了当前目录!)
# ✅ 加 -r
find /tmp -name '*.nonexistent' -print0 | xargs -0 -r rm
注意 -r 是 GNU 扩展,BSD/macOS 的 xargs 没有这个选项(但它们默认行为就是不执行空输入)。可移植脚本里避免依赖它,改用 find ... -exec ... + 或先判断输出是否为空。
小结
find的参数是表达式,不是选项 —— 它对每个文件从左到右求值。-a和-o有优先级(-a高于-o),混用时必须加括号-o与-delete混用会误删:find . -name a -o -name b -delete只删 b。养成习惯:用了-o就加括号,危险命令前先用-print演练-exec {} +比\;快几十倍 —— 前者批量传参,后者每个文件一个进程。默认用+,只有{}需在中间/多次出现时才用\;- 管道必须用
-print0 | xargs -0—— 文件名里除了/和\0什么字符都合法,只有\0分隔是安全的。| xargs(不加-0)永远不要用 -mtime +7实际是「超过 8 天」 —— 因为取整。精确到秒用-newermt- 性能优化:便宜的测试放前面 ——
-type f -name '*.go'比反过来快,因为短路求值 -prune是剪枝,! -path是过滤 —— 前者不进目录,后者仍遍历- 现代替代:
fd(找文件)+rg(搜内容) —— 但复杂条件、-exec、-delete、可移植性仍然只能用find
下一篇讲 grep、sed、awk 三剑客:grep 的零宽断言与 -P/-E/-F 的区别、sed 的寻址与保持空间、awk 的 BEGIN/END 与关联数组、以及它们各自擅长的场景。
xingliuhua