定时任务重复执行是个人站长最容易踩的坑之一,也是最难在一次事故之后就彻底解决的一类问题。它不像 500 错误那样立刻暴露,往往表现为「某篇文章的阅读数莫名其妙翻倍」「备份文件一天出现两个」「邮件通知发了两遍」,等你注意到的时候,可能已经跑了好几周。
我最早遇到这个问题是在一个统计脚本上:每小时统计一次当日 PV 写入汇总表,结果发现某些小时的数字刚好是正常值的两倍。查了日志,那个脚本确实执行了两次,时间差不到两秒。原因很普通——cron 任务在 59 分发起,脚本跑到 01 分还没结束,下一小时的 cron 又起来了。两个实例同时跑,重复统计。
这篇讲的是怎么在单机环境下,用一个可靠的分布式锁把「同一时间只能有一个实例」这件事钉死,以及各种锁实现的实际坑。
一、先分清三种「重复」
排查之前要认清,重复执行有三种完全不同的成因,处理方式也不一样。
第一种:重叠。上一轮还没结束,下一轮已经开始了。原因是任务耗时超过调度间隔,或者某次执行卡住(等数据库锁、等网络超时)超过了一个周期。这是最常见的,锁能解决。
第二种:多机执行。同一份 crontab 部署在两台以上的机器上(负载均衡的两个后端、或者主从两台),每台都按时执行一次。这种情况在单机锁里完全看不出来——两台机器上的锁互不可见。必须用跨进程可见的锁,比如数据库表、Redis、或者一个共享的文件系统。
第三种:调度器本身重复触发。systemd timer 的 Persistent=true 会导致「关机期间错过的任务在开机后补跑」,如果配合 OnCalendar 写得不严谨,补跑和正常触发会撞在一起。另外,cron 和 systemd timer 同时存在同一任务的配置(常见于从 cron 迁移到 systemd 但忘了注释旧任务)也会重复。
遇到重复,第一步不是加锁,是确认属于哪一种。用 logger 或者往一个文件里追加带 PID 和主机名的记录,跑一天看两次执行是不是同一个 PID、同一台机器。
二、flock:单机最省事的方案
如果确认是「重叠」,而且只在单机上跑,flock 就够了,不用引入任何外部依赖。系统的 util-linux 自带。
* * * * * /usr/bin/flock -n /var/lock/zz-stats.lock /usr/local/bin/stats.sh >> /var/log/stats.log 2>&1-n 是 non-blocking,拿不到锁就立刻退出,不排队。这一点非常关键:如果不加 -n,flock 会阻塞等待,那么这一轮任务就会排队,等上一个结束后立刻开始——结果是「不重复了,但延迟了」,而且如果积压严重,会形成一串排队进程,几小时后一起跑,比重复更糟。
另一个坑是锁文件的位置。/var/lock 在现代 Debian/Ubuntu 上通常是指向 /run/lock 的符号链接,而 /run 是 tmpfs,重启就清空——这对锁文件是好事(不会有僵尸锁),但要确认脚本里用的路径和 flock 用的路径是同一个,符号链接的两种情况会让两个不同的进程拿到两个不同的 inode。
如果脚本本身就想自己控制加锁(而不是在 crontab 里套一层),可以在 shell 里这么写:
#!/bin/bash
exec 200>/var/lock/zz-stats.lock
flock -n 200 || { echo "已有实例在跑,退出"; exit 0; }
# 下面正常执行
这里用文件描述符 200 打开锁文件,flock -n 200 尝试加锁。写成一行用 { } 要注意分号,flock -n 200 || exit 0 更简单。这种写法有个好处:脚本内任何 exec 出去的子进程都会继承这个 fd,所以只要主进程没死,锁就一直被持有;主进程退出时内核自动释放,不会留僵尸锁。
有一点要特别注意:flock 是基于 inode 的。如果你在脚本里 rm 了锁文件,那么另一个进程创建同名新文件、拿到新 inode,就能同时加锁成功。所以锁文件永远不会被删除,也不要放进会被 tmpfiles.d 清理的目录里。
三、MySQL 的 GET_LOCK:多机场景的轻量方案
站点已经有一个 MySQL,那就没必要为了一个锁再装 Redis。GET_LOCK 是 MySQL 内建的命名锁,跨连接、跨机器都可见。
SELECT GET_LOCK('zz_daily_stats', 0); -- 0 表示不等待,立即返回
SELECT RELEASE_LOCK('zz_daily_stats');返回 1 表示拿到锁,0 表示超时没拿到,NULL 表示出错。在 PHP 脚本里典型写法:
$db = new PDO('mysql:host=127.0.0.1;dbname=zz;charset=utf8mb4', 'app', 'xxx', [
PDO::ATTR_ERRMODE => PDO::ERRMODE_EXCEPTION,
]);
$stmt = $db->prepare('SELECT GET_LOCK(?, 0)');
$stmt->execute(['zz_daily_stats']);
if ((int)$stmt->fetchColumn() !== 1) {
error_log('GET_LOCK failed, another instance running');
exit(0);
}
try {
// 业务逻辑
} finally {
$db->query("SELECT RELEASE_LOCK('zz_daily_stats')");
}这里有四个实际的坑,都踩过。
第一个:锁跟着连接走,连接断了锁就没了。如果你的脚本用的是持久连接 PDO::ATTR_PERSISTENT => true,或者中间有过一次重连(MySQL 会把空闲太久的连接掐掉),那么原来持锁的连接已经不存在了,你的「锁」实际上早就释放了,另一个实例正在跑。所以用 GET_LOCK 时绝对不要用持久连接,而且要显式在脚本末尾释放。
第二个:锁名是全局的,跨库共享。zz_daily_stats 这个名字在整个 MySQL 实例里唯一,不同数据库下的同名锁会互相冲突。多站点共用一个 MySQL 实例时,锁名必须带站名或者库名前缀,否则 A 站的统计任务会把 B 站的备份任务锁住。
第三个:锁的持有者是谁,很难查。MySQL 8.0 可以用 performance_schema.metadata_locks 查元数据锁,但 GET_LOCK 的命名锁不在里面。要查谁持有,只能 SHOW PROCESSLIST 找对应的连接,加注释是个办法:在客户端连接时设置 SET SESSION wait_timeout 或者用 SELECT ... /* zz-stats-lock */ 注释。更实用的做法是拿到锁之后再写一行「任务开始」的记录表,里面带主机名和 PID,出问题时查那张表。
第四个:MySQL 重启后锁全部丢失。这在正常运维里不算问题(重启时任务多半也不在跑),但如果你在做主从切换,锁会跟着切换丢失,此时如果旧主库上的任务还在跑,新主库上的任务也能拿到锁,就出现了双跑。这类场景要么把任务挂在主库专用域名上,要么干脆接受「切换窗口内暂停定时任务」。
四、Redis 锁:能设 TTL,但有它的陷阱
如果站点已经有 Redis,用它加锁是很自然的选择,因为可以设过期时间,天然避免了「进程崩溃留下死锁」。
SET zz_daily_stats <hostname> NX PX 600000SET ... NX PX 是原子操作,返回 OK 表示拿到锁,返回 nil 表示没拿到,这是 Redis 锁唯一正确的写法。不要用 SETNX 加 EXPIRE 两步——如果两步之间进程挂了,锁就永久留下了。
TTL 一定要设得比任务最长耗时更长。设短了会出现一个很糟糕的情况:锁过期了但任务还在跑,另一个实例拿到锁,两个同时跑;等第一个跑完,它执行「释放锁」时把第二个的锁删掉了,第三个又进来。这就是经典的「锁失效」问题。解法是释放时校验持有者:
if redis.call('GET', KEYS[1]) == ARGV[1] then
return redis.call('DEL', KEYS[1])
else
return 0
end用 Lua 脚本保证「比较」和「删除」的原子性,不要分两次命令。
不过对一个个人站来说,我要说实话:Redis 锁在单机场景里带来的复杂度,往往超过它解决的问题。你需要处理 TTL 长短、续期、持有者校验、Redis 挂了怎么办(拿不到锁时是阻塞还是跳过?),一旦 Redis 重启(没有持久化的话)所有锁丢失,任务可能同时开跑。如果没有多机需求,flock 更省心。
五、systemd timer 的正确写法
如果任务已经迁移到 systemd timer,那锁这件事可以直接交给 systemd,不需要自己写:
# /etc/systemd/system/zz-stats.service
[Unit]
Description=Daily stats aggregation
After=network-online.target mysql.service
Wants=network-online.target
[Service]
Type=oneshot
User=www-data
ExecStart=/usr/local/bin/stats.sh
TimeoutStartSec=3600
Nice=10
IOSchedulingClass=idle
[Install]
WantedBy=multi-user.target# /etc/systemd/system/zz-stats.timer
[Unit]
Description=Run stats aggregation hourly
[Timer]
OnCalendar=hourly
AccuracySec=1min
Persistent=false
RandomizedDelaySec=120
[Install]
WantedBy=timers.target关键点:systemd 在同一个 unit 已经运行时会跳过新的触发,这是内建的重叠保护,不需要 flock。但这只对「同一个 unit」有效,如果你有 zz-stats.service 和 zz-backup.service 都想跑同一段逻辑,它们会并行。
Persistent=false 这一条要特别说明。默认情况下 OnCalendar 的 timer 不补跑,只有显式写 Persistent=true 才补。补跑的场景是「每天凌晨 3 点跑备份,但机器 2:50 重启了,3:10 起来」——这时候补跑是好事。但如果 Persistent=true 加上一个很密的 OnCalendar(比如 hourly),机器关机三小时后开机,systemd 会立刻补跑一次,然后下一个整点又跑一次,看起来就像重复了。AccuracySec 也别设成 1us,那会让所有 timer 在同一毫秒触发,反而增加瞬时压力;1min 是合理值。
另外提醒一句:从 cron 迁移到 systemd 时,务必注释掉旧 crontab。我就见过同时存在两边配置的情况,跑了一个月,每天多出来一份备份文件,直到磁盘满才发现。
六、日志、告警与验证
加了锁不等于没问题,锁本身也会出问题,所以要有验证手段。
验证一:每次执行都留一条记录。不管成功失败,都在一张表或者一个文件里写「开始/结束/主机名/PID/是否拿到锁」。跑一周后统计「同一天有多少次开始但没拿到锁」——这个数字应该为 0 或者很小。如果每天都在跳,说明任务耗时真的超过周期了,该优化任务本身。
验证二:监控「结束时间」而不是「执行次数」。任务重复执行的一个典型特征是「结束时间是乱的」,正常应该每次都在固定的时长范围内结束。所以告警要盯 最后一条记录的结束时间距今多久,超过周期 2 倍就报警——这同时能发现「任务卡死」和「任务根本没跑」两种问题。
验证三:加一道业务侧的唯一约束。如果统计结果是往一张 daily_stats(date, hour) 的汇总表里写,那就给 (date, hour) 建唯一索引,写的时候用 INSERT ... ON DUPLICATE KEY UPDATE。这样即使锁失效、跑了两遍,结果也还是对的。我后来在这类脚本上都会加上这层,它对「重复」的防御效果比锁更硬——锁防的是「不该跑」,唯一索引防的是「跑了也没关系」。
反过来说,如果业务本身可以做成幂等的,那很多时候根本不需要花力气去设计锁。判断标准很简单:这个任务重复执行一次,会带来什么后果?如果只是多花几分钟 CPU,那就让它重复;如果会发两封邮件、扣两次款、生成两个备份,那必须锁。先想清楚这一点,再决定要不要引入额外的组件。
最后说个我自己的习惯:所有定时脚本的入口第一行都写 set -u 和 set -o pipefail,不写 set -e。原因很实际——脚本里大量用 grep -c 判断,没有匹配时返回 1,set -e 会让脚本在第一次「正常」的查询上就静默退出,这种「任务跑了但啥也没做」比任务失败更难发现。