systemd 定时任务互斥:用 flock 把重复执行挡住
一、问题:定时任务为什么会重复跑
把每日巡检脚本挂到 systemd timer 上之后,最先遇到的问题不是脚本写错,而是它偶尔会被启动两次。典型场景有三种:
- 上一次运行还没结束,定时器的下一次触发已经到点;
- 运维手动
systemctl start一次做验证,正好撞上自动触发; - 脚本内部用
nohup或&把耗时步骤丢到后台,systemd 认为主进程已退出,于是又拉起新实例。
后果往往比“多占一点 CPU”严重得多:重复上报监控数据会让告警风暴翻倍;重复提交工单、重复扣减额度、重复写审计日志,都需要人工回头清理。因此「同一个任务在同一时刻只能有一个实例在跑」应当被当作硬约束,而不是靠脚本自觉。
二、一个 flock 就够,不必引入队列
很多人第一反应是上 Redis 分布式锁或者消息队列。对于单机定时任务,这是过度设计。Linux 自带的 flock 是内核级的建议锁,进程退出(包括被 kill -9)时由内核自动释放,不存在“锁残留导致任务永久挂起”的经典事故。
最小改动是在脚本开头加三行:
#!/usr/bin/env bash
set -euo pipefail
exec 9>/var/lock/backup.lock
flock -n 9 || { echo "already running, skip"; exit 0; }
关键点有两个:一是 exec 9> 必须在 flock 之前,锁的持有者是这个文件描述符而不是某条命令;二是 -n(non-blocking)配合 exit 0,让后来的实例安静退出,不要排队等待——定时任务排队会不断积压,等到半夜一起跑完,反而把 IO 打满。
三、systemd 侧的三个配套开关
只加 flock 还不够,unit 文件里有三处需要同时设对:
[Service]
Type=oneshot
ExecStart=/opt/scripts/backup.sh
TimeoutStartSec=3600
Restart=no
[Timer]
OnCalendar=*-*-* 03:30:00
Persistent=true
AccuracySec=1min
Type=oneshot:让 systemd 等到进程真正退出才认为结束。用默认的 simple 时,systemd 在 fork 后立刻认为启动成功,systemctl status也看不出它还在跑。OnCalendar与时区:服务器常是 UTC,而业务期望是本地时间。timedatectl显示的是系统时区,若timedatectl show -p Timezone返回 UTC,请写03:30 UTC或在 unit 里加Environment=TZ=Asia/Shanghai,否则任务会在本地时间的错位小时跑。Persistent=true:机器关机错过了触发点,开机会补跑一次。这对「每天必须有一份快照」的场景是必要的,但对「每小时上报一次心跳」则不合适,会一次性补出几十次,需要靠 flock 与脚本内的时间窗判断兜住。
四、验证是否真的互斥
很多同学加完 flock 就结束了,没验证。可靠的做法是人工制造并发:动手写一个测试脚本,前 20 秒不停打时间戳,同时在两个终端里各起一次,观察第二个是否立即返回 already running, skip。
# 终端 A
./backup.sh
# 终端 B(1 秒后)
./backup.sh # 应立刻打印 already running 并返回 0
再用 flock -n /var/lock/backup.lock -c 'echo locked' 反向确认锁确实被持有。lsof /var/lock/backup.lock 应当显示 PID 而非空。
另一个容易漏的点:如果脚本用 bash script.sh & 启动子任务却不 wait,主脚本退出后锁随之释放,子任务仍在跑——此时互斥形同虚设。要么在主脚本结尾 wait,要么把子任务也放进同一个进程组统一回收。
五、结论
单机定时任务的互斥问题,不需要引入任何外部组件:内核 flock + oneshot + 正确的时区配置,三件事加起来不到二十行。真正花时间的往往不是写锁,而是验证锁生效了——构造一次人工并发,比读十遍文档可靠。
把这套约束固定下来之后,定时任务从「偶尔重跑一次,人工去清理数据」变成「跑多少天都不用管」,这才是自动化该有的形态。
- 点赞
- 收藏
- 关注作者
评论(0)