用 flock 防止 crontab 定时任务重叠执行:包装脚本与边界说明
用 flock 防止 crontab 定时任务重叠执行
问题场景
crontab 按固定时间点触发命令。如果某次任务执行时间超过调度间隔,下一次仍会启动,导致同一任务并发运行。比如每 5 分钟执行一次数据同步,某次同步因数据量增大跑了 12 分钟,就会出现两三个进程同时写同一张表、同一份文件或同一个队列。并发可能带来数据竞争、重复处理、资源耗尽,甚至把磁盘或连接池打满。
crontab 本身不提供“上一次未结束则跳过”的语义。要避免重叠,需要在任务入口加互斥。文件锁是常用做法:所有实例先抢同一把锁,抢到才执行,抢不到直接退出或等待。
为什么用 flock
Linux 上 util-linux 提供的 flock 命令可以对文件或文件描述符加建议锁。它使用 flock(2) 系统调用,锁与打开的文件描述符关联,进程退出后锁自动释放。相比自己创建 pid 文件,flock 不需要处理进程崩溃后的残留锁文件,也不依赖进程名判断。只要锁文件路径固定,所有任务实例都遵守同一约定,就能实现互斥。
需要注意,flock 是建议锁,不是强制锁。所有参与方都必须主动调用 flock 才能互斥;如果某个脚本绕过锁直接运行,仍可能并发。
最小包装脚本
假设有一个耗时任务,原始命令是 /opt/app/bin/sync_data.sh。我们不把原始命令直接写进 crontab,而是写一个包装脚本 /opt/app/bin/run_with_lock.sh:
#!/usr/bin/env bash
set -euo pipefail
LOCK_FILE=/var/lock/app-sync.lock
LOG_PREFIX='[app-sync]'
# 以读写方式打开锁文件,文件描述符 9
exec 9>"$LOCK_FILE"
# -n 表示非阻塞:拿不到锁立即退出,不等待
if ! flock -n 9; then
echo "$(date '+%F %T') $LOG_PREFIX 已有实例在运行,本次跳过" >&2
exit 0
fi
echo "$(date '+%F %T') $LOG_PREFIX 开始执行" >&2
# 在这里调用真正的任务
/opt/app/bin/sync_data.sh
echo "$(date '+%F %T') $LOG_PREFIX 执行结束" >&2
注意:exec 9>"$LOCK_FILE" 会创建或打开锁文件。flock -n 9 尝试对 fd 9 加排他锁。如果失败,说明已有进程持有锁。抢到锁后,后续命令在同一 shell 进程中运行,fd 9 一直保持打开,直到脚本退出,锁自动释放。子进程也会继承 fd 9,因此即使 sync_data.sh 启动后台子进程,只要包装脚本没有退出,锁仍然有效。但若 sync_data.sh 自己 daemonize 并让包装脚本退出,锁会释放,互斥范围就只覆盖到包装脚本退出为止。这是边界之一。
crontab 配置
将 crontab 中原来的任务替换为包装脚本。例如每 5 分钟执行一次:
*/5 * * * * /opt/app/bin/run_with_lock.sh >> /var/log/app-sync.log 2>&1
如果不想单独写包装脚本,也可以直接用 flock 命令:
*/5 * * * * /usr/bin/flock -n /var/lock/app-sync.lock -c '/opt/app/bin/sync_data.sh >> /var/log/app-sync.log 2>&1'
这种写法适合简单命令。注意 -c 后面的整条命令要作为一个参数,内部有引号时要正确转义。更复杂的逻辑建议用包装脚本,便于维护和测试。
运行方法与验证
- 给包装脚本执行权限:
chmod +x /opt/app/bin/run_with_lock.sh
- 先手动运行一次,确认能正常执行:
/opt/app/bin/run_with_lock.sh
- 模拟并发:打开两个终端,先运行一个长时间任务,再运行另一个。例如让任务 sleep 30:
/opt/app/bin/run_with_lock.sh
# 在另一个终端
/opt/app/bin/run_with_lock.sh
第二个实例应输出“已有实例在运行,本次跳过”并退出。也可以查看锁文件是否被占用:
lsof /var/lock/app-sync.lock
或
fuser -v /var/lock/app-sync.lock
- 安装到 crontab:
crontab -e
加入上面的定时行,保存后可用 crontab -l 查看。
边界与限制
锁文件位置:不要放在 /tmp 等可能被系统清理的目录,也不要用会被任务自己删除的路径。建议使用 /var/lock 或 /run/lock 下按应用命名的文件。运行用户需要对锁文件有读写权限,否则打开失败会导致任务无法启动。
-n 与等待:-n 表示拿不到锁立即退出,适合“跳过重叠”的语义。如果希望排队等待,可以用 -w 秒数,例如 flock -w 10 9,等待最多 10 秒;超时后仍拿不到锁再退出。等待会占用一个进程,若任务间隔短、执行久,可能堆积等待进程,需要评估。
退出码:flock -n 拿不到锁时返回非 0。包装脚本里用 if ! flock -n 9; then ... exit 0 可以避免 cron 发送失败邮件,或按需改成 exit 75 等自定义码。若任务本身失败,应保留其退出码并向上传递,例如:
if ! /opt/app/bin/sync_data.sh; then
echo '任务失败' >&2
exit 1
fi
这样 cron 能通过邮件或日志感知失败。
锁的继承与子进程:flock 锁与打开的文件描述符关联。如果包装脚本执行任务时启动后台进程,并且包装脚本很快退出,锁会随 fd 关闭而释放,后台进程不再受保护。若任务内部会 fork 长时间运行的后台进程,应确保互斥范围覆盖整个业务生命周期,或让包装脚本等待后台进程结束。
网络文件系统:flock 在本地文件系统上行为明确,但在 NFS 等网络文件系统上的支持取决于实现和挂载选项。如果多台机器需要互斥,不能简单假设 flock 跨主机可靠,应使用分布式锁或数据库锁。
容器与 cron:容器内可能没有 flock 命令,需要安装 util-linux 或改用其他语言实现文件锁。容器重启后 /var/lock 可能丢失,但锁文件丢失本身不是问题,重新创建即可;如果目录不存在,脚本会失败。可以在脚本开头 mkdir -p 锁文件所在目录。
锁文件残留:flock 的锁在进程退出后自动释放,锁文件可以长期保留,这是正常设计。不要在任务开始时删除锁文件,否则会破坏互斥:一个进程可能持有旧 inode 的锁,另一个进程创建新文件并加锁,两者会同时运行。
crontab 环境:cron 的环境变量很少,PATH 可能不包含命令路径。脚本中最好使用绝对路径,或显式设置 PATH。重定向日志时也要确保目录存在且可写。
最后,flock 只解决“同一任务不重叠”的问题。如果任务本身不是幂等的,或者多个不同任务会修改同一资源,还需要在业务层做幂等、事务或更细粒度的锁。把锁、日志、退出码和告警一起考虑,定时任务才更可靠。
小结
用 flock 包装 crontab 任务,可以用较小改动避免同一任务并发执行。核心是固定锁文件路径、所有实例先抢锁、抢不到按策略退出或等待,并注意锁的继承、NFS、容器和日志等边界。包装脚本比直接在 crontab 里写长命令更易维护,也方便本地模拟并发测试。
- 点赞
- 收藏
- 关注作者
评论(0)