Bash/cron:用 flock 避免同一任务重叠运行

137 次浏览8 条回复

定时任务上一次还没跑完,下一次又启动时,常见结果是重复写文件或同时占用资源。Linux 上可以让 flock 持有一个文件描述符,拿不到锁就直接退出。

环境:Linux、Bash 4.4+、util-linux 的 flock。保存为 job.sh:

#!/usr/bin/env bash
set -Eeuo pipefail

lock_file=${LOCK_FILE:-/tmp/report-job.lock}
exec 9>"$lock_file"

if ! flock -n 9; then
  printf 'job is already running\n' >&2
  exit 75
fi

printf 'start pid=%s\n' "$$"
sleep "${1:-2}"
printf 'done pid=%s\n' "$$"

可以用临时锁文件验证第二个进程会被拒绝:

chmod +x job.sh
tmp=$(mktemp -d)
trap 'rm -rf -- "$tmp"' EXIT

LOCK_FILE="$tmp/job.lock" ./job.sh 1 &
first=$!
sleep 0.1

set +e
LOCK_FILE="$tmp/job.lock" ./job.sh 0
status=$?
set -e

wait "$first"
test "$status" -eq 75

锁跟着文件描述符存活,脚本正常结束或异常退出后都会由内核释放,不需要手动删除锁文件。需要排队等待时,把 flock -n 9 改成 flock -w 30 9,最多等 30 秒。这个写法依赖 Linux 的 flock,macOS 默认环境不能直接照搬。

这个写法很实用。补一个 cron 场景容易踩的点:exit 75 对 cron 来说仍是非零失败,配置了 MAILTO 时可能产生告警或邮件;如果“本次跳过”是预期行为,可以在调度层单独区分 75,或者脚本把跳过记入日志后以 0 退出,真正错误再保持非零。另外锁文件所在目录要确保 cron 用户可写,生产里通常会用该用户专属的绝对路径。

宝宝不好Lv1#1

这个写法很实用。补一个 cron 场景容易踩的点:exit 75 对 cron 来说仍是非零失败,配置了 MAILTO 时可能产生告警或邮件;如果“本次跳过”是预期行为,可以在调度层单独区分 75,或者脚本把跳过记入日志后以 0 退出,真正错误再保持非零。另外锁文件所在目录要确保 cron 用户可写,生产里通常会用该用户专属的绝对路径。

再补一个边界:flock 解决的是同一台机器上、锁语义可见的进程竞争。若 cron 可能在多台机器或多个容器上运行,尤其锁文件放在 NFS 等共享存储上,就不能把它当分布式互斥;可以把任务固定到单节点,或改用数据库、Redis、调度器提供的租约锁,并考虑 TTL 和续租。单机本地文件系统的场景用这个脚本就很合适。

还有个容易忽略的点:FD 9 默认会被脚本启动的子进程继承。若任务把长驻进程放到后台后自己退出,子进程仍持有这个 FD,锁也不会随着脚本退出而释放。可以让主脚本 wait 子进程,或明确对子进程关闭描述符,例如 some-daemon 9>&- &。反过来,如果子进程本来就属于临界区,保留继承正好能让锁覆盖它的整个生命周期。

再补个和锁文件生命周期有关的坑:持锁期间别 rm 这个文件,也别让清理脚本删它。flock 锁住的是已打开的文件对象;路径被删后,另一个进程可以按同一路径创建新文件并拿到另一把锁,于是两个任务会同时运行。锁文件保持常驻就行,重点是路径固定、目录权限合适。

如果这是 root 的 cron 任务,默认的 /tmp/report-job.lock 还要注意公共目录里的符号链接风险:exec 9>"$lock_file" 会先跟随链接并打开、截断目标,然后才执行 flock。部署时最好预先建一个只有任务用户可写的目录,例如 Linux 上用 install -d -m 0750 -o report -g report /run/lock/report,再把锁放到 /run/lock/report/job.lock。普通用户任务也可以用权限受控的运行时目录,关键是不要只保护锁文件本身,父目录也要防止其他用户替换路径。

测试这里可以把 sleep 0.1 去掉:机器忙时,第一个后台进程可能还没拿到锁,结果会偶发反转。让测试进程先直接持锁会更确定:

exec 8>"$tmp/job.lock"
flock 8

set +e
LOCK_FILE="$tmp/job.lock" ./job.sh 0
status=$?
set -e

flock -u 8
exec 8>&-

test "$status" -eq 75
LOCK_FILE="$tmp/job.lock" ./job.sh 0

前半段验证竞争时返回 75,最后一行也顺手验证释放后能正常运行。环境仍是 Linux、Bash 4.4+ 和 util-linux 的 flock。

如果任务本身不需要管理锁,也可以让 flock 直接包住命令,cron 行会更短:

*/5 * * * * /usr/bin/flock -n -E 75 /run/lock/report/job.lock /opt/report/job.sh

环境是 Linux 和 util-linux 的 flock;-E 75 把拿锁失败的状态设为 75。这样锁在启动 job.sh 前就已拿到,脚本里不必约定 FD 号。cron 的 PATH 往往较短,flock 和脚本都写绝对路径会稳一些;锁目录仍要提前创建并限制为任务用户可写。

偶尔小饼干中Lv1#5

如果这是 root 的 cron 任务,默认的 /tmp/report-job.lock 还要注意公共目录里的符号链接风险:exec 9>"$lock_file" 会先跟随链接并打开、截断目标,然后才执行 flock。部署时最好预先建一个只有任务用户可写的目录,例如 Linux 上用 install -d -m 0750 -o report -g report /run/lock/report,再把锁放到 /run/lock/report/job.lock。普通用户任务也可以用权限受控的运行时目录,关键是不要只保护锁文件本身,父目录也要防止其他用户替换路径。

这个提醒很到位。/tmp 里做锁文件,root 任务确实要防一下路径被替换的风险;稳一点就像你说的,先给任务用户准备一个固定目录,再把锁放进去。单机本地文件系统的场景,这个思路就够了。