目录

Linux-07 find 与 xargs:表达式求值模型、-exec 的两种形式与 -print0 的必要性

find 是最容易「会用但不理解」的命令:大家都会 find . -name '*.log',但一旦条件复杂起来就开始碰运气。

根本原因是大多数人把 find 的参数当成了「选项」,而它们其实是「表达式」。这个认知差会直接导致误删文件。

先看五个问题:

  1. find . -name '*.tmp' -o -name '*.log' -delete 会删掉什么?(答案可能吓你一跳)
  2. -exec cmd {} \;-exec cmd {} + 差在哪?为什么后者能快几十倍?
  3. find . -name '*.log' | xargs rm 有什么风险?
  4. find /data -mtime +7 找的到底是「7 天前」还是「8 天前」的文件?
  5. 为什么 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 expr2expr1 -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 有什么风险?怎么修正?

两个致命问题:

  1. 文件名里有空格会被拆成多个参数xargs 默认按空白分隔)
  2. 文件名里有换行会被当成多个文件

根因是文件名里除了 /\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 权限。合法的有 passwdsudo,但攻击者常通过提权漏洞植入恶意 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

注意 -rGNU 扩展,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 与关联数组、以及它们各自擅长的场景。