日常运维里最容易出事的不是磁盘满,而是同一个备份脚本被 crontab 和手动执行同时拉起来两份。两份 rsync 抢同一个目标目录,两份 mysqldump 同时往同一个 .sql 里写,两份日志清理脚本同时 find -delete,结果往往是文件损坏、任务卡死、负载飙高,而排查时又抓不到现场。本文给出一个不依赖 flock 之外的额外软件、只用 Shell 内建能力(trap + PID 文件)的互斥方案,包含原子加锁、僵尸锁识别、异常退出清理、超时退避,并且每一步都给出可直接落地的代码与真实的报错信息。
为什么简单的 PID 文件锁会失效
很多人第一版互斥是这样写的:
#!/bin/bash
PIDFILE=/var/run/mytask.pid
if [ -f "$PIDFILE" ]; then
echo "already running: $(cat $PIDFILE)"
exit 0
fi
echo $$ > "$PIDFILE"
# ... 业务逻辑 ...
rm -f "$PIDFILE"
这段代码在单机低并发下能跑很久,但它有三个致命缺陷,一旦触发就是“锁永远不释放”或者“锁形同虚设”:
- 检查与写入不是原子操作:两个进程可以同时通过
[ -f "$PIDFILE" ]判断,然后各自写入 PID,后写的覆盖先写的,双方都认为自己持锁。 - 异常退出不清理:脚本被
kill -9、被 OOM Killer 干掉、因set -e触发中间某条命令失败退出时,rm -f "$PIDFILE"根本执行不到,PID 文件残留,之后所有执行都被判定为“已在运行”,任务从此静默停摆。 - PID 复用导致误判:残留的 PID 可能已被系统分配给无关进程,用
kill -0探测会返回成功,脚本误以为前一个实例还活着,继续阻塞。
典型的现象是:ps aux | grep backup.sh 明明什么都没查到,但脚本日志里只剩一行 already running: 28311,而 cat /proc/28311/comm 显示的是 systemd 或者 sshd。
结论:PID 文件方案要可用,必须满足三个条件——加锁原子化、退出必清理、持锁者身份可校验。前两个分别靠
noclobber(或mkdir)和trap解决,第三个靠写入命令行指纹或改用flock内核锁解决。
用 noclobber 实现原子创建 PID 文件
Bash 的 set -o noclobber(等同 set -C)会让重定向 > 在目标文件已存在时失败,而不是覆盖。配合 >| 可以显式强制覆盖。这就给了我们一个原子的“创建即占位”语义:
set -o noclobber
if ( : > "$PIDFILE" ) 2>/dev/null; then
echo "lock acquired"
else
echo "lock busy"
fi
注意这里用了子 shell ( ... ) 包住重定向,原因是重定向失败会导致整个脚本在该行直接退出(非交互 shell 下 noclobber 违反属于 redirection error,行为等同于命令失败),如果放在主 shell 里,失败分支根本走不到。子 shell 隔离了这次重定向失败。
也可以用 mkdir 实现,因为 mkdir 在目录已存在时返回非零,天然原子:
if mkdir "$LOCKDIR" 2>/dev/null; then
echo $$ > "$LOCKDIR/pid"
else
echo "lock busy"
fi
mkdir 版本的好处是“目录存在 = 有人持锁”这个信号不会被 echo 写入过程打断;坏处是多一个目录,清理时要 rm -rf。下面以 PID 文件为主,目录锁作为可选变体。
用 trap 保证任何退出路径都释放锁
trap 可以把信号和 EXIT 伪信号绑定到清理函数。EXIT 的特殊之处是:无论脚本是正常结束、exit N、set -e 中途失败,还是被 SIGTERM/SIGINT/SIGHUP 杀死(只要进程还能捕获信号),都会触发一次。
第一个必须知道的事实:trap 无法捕获 SIGKILL(信号 9)和 SIGSTOP。所以“被 kill -9 后锁残留”是设计上不可避免的,必须由下一次执行时的“老锁探测”来兜底,这正是下一节要解决的问题。
第二个事实:trap 处理函数里执行 exit 不会再次触发 EXIT trap(Bash 有防护),但如果处理函数内部本身出错且你开了 set -e,会出现“trap 里触发 trap”的混乱,所以清理函数里要尽量避免失败命令,或统一加 || true。
第三个事实:脚本里如果有 sleep、wait、read 等阻塞调用,收到信号后必须先处理完当前前台命令才轮到 trap。这也是为什么清理函数要短。
信号转成 unwinding:把 TERM 变成 exit
默认情况下脚本收到 SIGTERM 会直接终止,不会执行 rm。要让清理逻辑跑到,必须显式捕获:
trap 'echo "got SIGTERM, exiting"; exit 143' TERM
trap 'echo "got SIGINT, exiting"; exit 130' INT
trap 'echo "got SIGHUP, exiting"; exit 129' HUP
用 exit 128+N 是 shell 的惯例(143 = 128+15 对应 SIGTERM,130 = 128+2 对应 SIGINT),这样调用方可以据此判断是被信号打断的。而清理动作统一挂在 trap ... EXIT 上,无需在每个信号处理里重复写。
完整可运行实现:互斥锁函数库
把锁逻辑封装成函数库 lock.sh,业务脚本 source 它即可。核心流程:老锁探测 → 原子创建 → 写入 PID 与指纹 → 注册 EXIT trap 清理。
#!/bin/bash
# lock.sh —— 基于 PID 文件的互斥锁,供业务脚本 source
# 用法:
# source /usr/local/lib/lock.sh
# lock_acquire /var/run/mytask.pid || exit 1
# ... 业务逻辑 ...
# # 结束时会由 EXIT trap 自动释放
LOCKFILE=""
lock_is_running() {
# $1 = 记录在 PID 文件里的 pid
local pid="$1"
[ -n "$pid" ] || return 1
kill -0 "$pid" 2>/dev/null || return 1
# PID 可能被复用:进一步比对命令行指纹
local comm
comm=$(tr '\0' ' ' < "/proc/$pid/cmdline" 2>/dev/null)
case "$comm" in
*"$LOCK_TAG"*) return 0 ;;
*) return 1 ;;
esac
}
lock_acquire() {
local pidfile="$1"
LOCKFILE="$pidfile"
LOCK_TAG="${2:-$(basename "$0")}"
export LOCK_TAG
# 第一步:老锁探测(处理 kill -9 留下的僵尸锁)
if [ -f "$pidfile" ]; then
local old
old=$(head -n1 "$pidfile" 2>/dev/null | tr -dc '0-9')
if lock_is_running "$old"; then
echo "[lock] already running pid=$old ($LOCK_TAG)" >&2
return 1
fi
echo "[lock] stale pidfile found, removing: $pidfile (pid=$old)" >&2
rm -f "$pidfile" || return 1
fi
# 第二步:原子创建
set -o noclobber
if ! ( printf '%s\n' "$$" > "$pidfile" ) 2>/dev/null; then
echo "[lock] lost race to another instance: $pidfile" >&2
return 1
fi
set +o noclobber
# 第三步:注册清理
# shellcheck disable=SC2064
trap "lock_release" EXIT TERM INT HUP
return 0
}
lock_release() {
[ -n "$LOCKFILE" ] && [ -f "$LOCKFILE" ] || return 0
# 只删除确实属于自己的锁,避免误删后继持锁者的文件
if [ "$(head -n1 "$LOCKFILE" 2>/dev/null | tr -dc '0-9')" = "$$" ]; then
rm -f "$LOCKFILE"
fi
LOCKFILE=""
}
业务脚本写法:
#!/bin/bash
set -euo pipefail
source /usr/local/lib/lock.sh
lock_acquire /var/run/mysql-backup.pid "mysql-backup.sh" || {
echo "skip: another backup is in progress" >> /var/log/backup.log
exit 0
}
echo "$(date '+%F %T') backup start" >> /var/log/backup.log
mysqldump --single-transaction mydb | gzip > /backup/mydb.sql.gz
echo "$(date '+%F %T') backup done" >> /var/log/backup.log
# 不需要手动删锁,EXIT trap 会处理
这里有三个容易被忽略的细节:
- trap 里用双引号包裹
lock_release,是为了让 trap 字符串在注册时就展开函数名。如果你写成trap 'lock_release' EXIT也没问题,但不要用trap 'lock_release "$LOCKFILE"' EXIT这种单引号里引用变量的写法——它会在触发时求值,而那时变量可能已被修改。 lock_release里校验$$:极端情况下(你手工删了 PID 文件,别的实例重新创建了它),直接rm会删掉别人的锁。校验 “文件里的 PID 是不是我” 是廉价的保险。LOCK_TAG用export:因为lock_is_running可能在子 shell 里被调用,虽然本例没有,但保持一致更安全。
验证效果
开两个终端分别执行:
# 终端 A
$ ./backup.sh &
[1] 41021
# 终端 B
$ ./backup.sh
[lock] already running pid=41021 (mysql-backup.sh)
然后模拟进程被强杀留下的僵尸锁:
$ echo 999999 > /var/run/mysql-backup.pid # 一个不存在的 PID
$ ./backup.sh
[lock] stale pidfile found, removing: /var/run/mysql-backup.pid (pid=999999)
[lock] acquired
验证 PID 复用场景:找一个确实存在但不是我们的进程,比如把当前 shell 的 PID 写进去——因为 cmdline 里不含 mysql-backup.sh,指纹比对失败,仍会被判定为僵尸锁并清理。
仍然存在的坑与加固手段
1. NFS 上的 noclobber 不可靠
如果 PID 文件放在 NFS 挂载点(比如多台机器共享的 /shared/run),noclobber 的原子性依赖 NFS 版本与 nfsvers=4.1 以上的 open 语义,老版本会退化为“先 stat 再 create”,出现竞态。跨机互斥请改用 flock -n 或者分布式锁,不要用 PID 文件。
2. 容器里 PID 命名空间不同
容器内 $$ 是容器命名空间内的 PID(通常是 1 或很小),在宿主机上看是另一个数字。如果 PID 文件挂载到宿主机共享,kill -0 探测会得到错误结论。单容器内使用没问题,跨容器共享时必须换方案。
3. 老脚本的兼容问题:只读文件系统
把 PID 文件写在 /var/run 而服务以只读根文件系统运行时,会看到:
/usr/local/lib/lock.sh: line 34: /var/run/mytask.pid: Read-only file system
因为 noclobber 重定向失败被 2>/dev/null 吞掉,脚本只会打印 [lock] lost race to another instance,非常误导。建议在 lock_acquire 里先做一次可写性检查:
if [ ! -w "$(dirname "$pidfile")" ]; then
echo "[lock] pidfile dir not writable: $(dirname "$pidfile")" >&2
return 1
fi
4. 锁超时:不要无限期阻塞
有些场景希望“排队等待”而不是“直接跳过”。但基于 PID 文件的锁无法安全实现 FIFO 排队,简单 while sleep 1 轮询会引入活锁风险。稳妥做法是给业务脚本设一个整体超时,由上层(systemd TimeoutStartSec 或 cron 包装脚本 timeout 3600 ./backup.sh)控制,锁本身保持“拿不到就退出”。如果确实需要等待,最多轮询有限次:
for i in $(seq 1 30); do
lock_acquire /var/run/mytask.pid && break
[ "$i" -eq 30 ] && { echo "timeout waiting lock" >&2; exit 1; }
sleep 2
done
5. 别忘了 set -e 与 trap 的相互作用
在 set -euo pipefail 下,函数返回非零会让整个脚本退出。上面的 lock_acquire ... || { ...; exit 0; } 写法就是为了绕过 set -e。如果你直接写 lock_acquire "$pid" 而不处理返回值,脚本会在锁冲突时静默退出(退出码 1),cron 邮件里只能看到“Command exited with status 1”,没有任何线索。推荐显式处理返回值,并用 logger -t backup 或写日志记录原因。
6. 更彻底的方案:直接用 flock
如果 util-linux 可用(绝大多数发行版默认有),flock 基于内核 flock(2),进程死亡时内核自动释放,不存在僵尸锁:
exec 200>/var/run/mytask.lock
flock -n 200 || { echo "already running" >&2; exit 0; }
# 后续业务逻辑,退出时 fd 关闭自动释放
那为什么还要学 PID 文件方案?因为很多嵌入式环境、精简镜像(部分 Alpine 变体、BusyBox 系统)没有 flock,而 PID 文件方案只需要 Bash 内建能力。另外 PID 文件便于人工排查——“现在谁在跑”看一眼文件就知道,flock 则需要 fuser 或 lsof 辅助。
小结
一个可靠的 Shell 互斥锁需要同时解决四件事:原子占位(用 set -o noclobber 子 shell 重定向或 mkdir)、异常清理(用 trap ... EXIT 并显式捕获 TERM/INT/HUP)、僵尸锁兜底(探测 PID 存活 + /proc/$pid/cmdline 指纹比对)、误删防护(释放前校验文件中的 PID 等于 $$)。kill -9 永远绕过 trap,所以第四步不是可选项。有 flock 就用 flock,没有就按本文的库来,别再用 [ -f ] 加 echo $$ 那种纸糊的锁。
FAQ
Q1:为什么 trap '' EXIT 之后脚本还是执行了清理?
trap '' EXIT 是“忽略”,但 Bash 对 EXIT 的处理与普通信号有差异:设置为空字符串会取消之前的 trap 注册。如果你在脚本中途想让清理不执行,应使用 trap - EXIT(恢复默认),而不是依赖空字符串。实测在 Bash 5.x 下 trap '' EXIT; exit 0 不执行清理函数,但不同发行版 Bash 4.2 曾有差异,建议统一用 trap - EXIT。
Q2:trap 里写的 rm -f "$PIDFILE" 报 PIDFILE: unbound variable 怎么解决?
开启了 set -u,而 PIDFILE 只在某个分支里赋值。要么在脚本顶层初始化 PIDFILE="",要么在清理函数里用 ${PIDFILE:-}。本文的 lock_release 先判断 [ -n "$LOCKFILE" ] 就是这个原因。
Q3:脚本被 kill -9 后,PID 文件残留但 kill -0 却说进程存在,怎么办?
说明 PID 被系统复用了。这正是要加 /proc/$pid/cmdline 指纹比对的原因。kill -0 只能回答“这个 PID 上有没有进程”,不能回答“是不是我那个进程”。也可以用 /proc/$pid/stat 里的启动时间来辅助判断,但指纹比对更直接。
Q4:两个脚本用同一个 PID 文件路径会互相干扰吗?
会。PID 文件路径要按任务唯一化,例如 /var/run/mysql-backup.pid、/var/run/log-rotate.pid。千万不要把不同任务塞进同一个锁文件,除非你确实想串行化它们。另外注意不要把锁文件放在 /tmp 下随意命名,容易被同机其他用户抢占或清理(systemd-tmpfiles 会定期清理 /tmp)。
Q5:为什么我的 trap 在 sleep 60 期间收到 SIGTERM 后没有立刻执行清理?
Bash 只有在当前前台命令返回后才会处理 trap(wait 除外,某些版本会更早中断)。sleep 是外部命令,Bash 会先等它结束。若需要及时响应,把 sleep 换成 sleep 60 & wait $! 形式,或把长任务拆成短片段轮询一个标志变量(在 trap 里置位)。
Q6:systemd 服务里用这套锁,Restart=on-failure 会不会因为锁残留一直失败?
正常退出路径下 PID 文件会被清理。但如果服务被 systemctl kill -s SIGKILL 或宿主 OOM 杀掉,PID 文件会残留,下一次启动时靠“僵尸锁探测”自动清掉,通常不会卡住。ExecStartPre 里加一条 rm -f /var/run/mytask.pid 反而危险——它会删掉正在运行实例的锁,破坏互斥。正确做法是信任探测逻辑,或者改用 flock。