用 trap 与 PID 文件实现 Shell 脚本互斥锁防止并发重入

日常运维里最容易出事的不是磁盘满,而是同一个备份脚本被 crontab 和手动执行同时拉起来两份。两份 rsync 抢同一个目标目录,两份 mysqldump 同时往同一个 .sql 里写,两份日志清理脚本同时 find -delete,结果往往是文件损坏、任务卡死、负载飙高,而排查时又抓不到现场。本文给出一个不依赖 flock 之外的额外软件、只用 She

日常运维里最容易出事的不是磁盘满,而是同一个备份脚本被 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。