Cron 跑得好好的,突然发现某张表里多出了重复记录,检查日志才发现任务在同一分钟被跑了两次。这种情况在个人站上不算罕见,而且原因往往不是你写的脚本有问题,而是「任务被触发的次数比你想象的多」。这篇把定时任务重复执行的四类成因、三种加锁方案(文件锁、数据库锁、Redis 锁)以及各自的失效场景讲清楚,重点放在「看起来加了锁,实际没锁住」的诊断方法上。
第一类成因:任务被触发了两次,而不是执行了两次
排查的第一步不是加锁,而是确认重复发生在哪一层。三个最常见的位置:
crontab 里写了两条。不同用户的 crontab 是独立的,crontab -l 只显示当前用户。root 和 www-data 各配了一条同样的任务,就会并发两遍。用下面这条命令把所有用户的定时任务列全:
for u in $(cut -d: -f1 /etc/passwd); do
c=$(crontab -u "$u" -l 2>/dev/null | grep -c 'backup.sh')
[ "$c" -gt 0 ] && echo "$u: $c"
done同时检查 /etc/cron.d/、/etc/cron.daily/ 这些目录,以及 panel(宝塔、aaPanel)自己的任务表。面板任务和系统 crontab 是两套机制,很容易双份。
同一任务的上一次还没跑完,下一次就开始了。任务耗时 20 分钟、cron 每 15 分钟触发一次,第二次触发时第一次还在跑,两者操作同一批数据。这种情况日志里看到的是「重叠」而不是「同时开始」,时间戳会相差十几分钟。
脚本自身重试。脚本里有失败重试逻辑,但重试时没有区分「上一次其实已经成功写入了,只是响应超时」,于是重试又写了一遍。这类重复的特征是记录内容几乎完全相同,只差时间戳和自增 ID。
判断方法:在任务开头打一行带主机名和 PID 的日志,事后看时间戳是否严格间距等于 cron 周期。
#!/bin/bash
echo "$(date '+%F %T') start host=$(hostname) pid=$$" >> /var/log/backup.log
# ... 任务正文 ...
echo "$(date '+%F %T') end pid=$$" >> /var/log/backup.log如果两次 start 的时间差正好等于 cron 间隔,是「没跑完就再触发」;如果时间差是 0 或几秒,是「两个触发源」;如果两次 start 之间没有对应的 end,说明上一次被杀了或者还在跑。
方案一:文件锁,最轻但最容易写错
用 flock 是最省事的方式,因为它不需要任何额外的服务。最简单的写法是直接把命令包起来:
*/15 * * * * /usr/bin/flock -n /var/lock/backup.lock /opt/scripts/backup.sh >> /var/log/backup.log 2>&1-n 表示拿不到锁立即退出(非阻塞),不加则会等待上一次跑完——多数备份场景要的是前者,避免任务堆积。
但这里有三个容易踩的地方:
锁文件路径必须所有触发者都能写。/var/lock 在多数发行版上是一个指向 /run/lock 的符号链接,只有该目录属主或 root 能创建文件。如果 cron 以 www-data 身份跑,而锁文件是 root 创建的,脚本会直接报「Permission denied」且不执行任何逻辑,看起来像任务没跑。
锁文件不要手动删除。flock 的锁是绑定在打开的文件描述符上的,文件本身存在与否不影响锁状态。有人为了「重置」手工 rm 掉锁文件,结果正在跑的任务持有一个指向已删除 inode 的锁,新任务创建一个同名新文件并成功加锁——两个任务同时跑,锁形同虚设。这属于典型的「以为加了锁」。正确做法是从来不删锁文件,让它一直存在。
脚本内部再调用子进程时要传锁。如果脚本用 nohup 或 setsid 把实际工作放到后台,父进程拿着锁退出了,锁立即释放,而后台任务还在跑。这种情况需要用 flock 包住真正的工作命令,而不是包住那个只负责派发的包装脚本。
在脚本内部加锁的写法更可控,也便于加日志:
#!/bin/bash
exec 9>/var/lock/backup.lock
if ! flock -n 9; then
echo "$(date '+%F %T') skip: another instance running" >> /var/log/backup.log
exit 0
fi
# 从这里开始就是独占执行区注意 exec 9> 这一行:它把文件描述符 9 绑定到锁文件上,之后所有子进程都会继承这个描述符,所以锁会一直持有到脚本完全退出。如果写成 flock -n 9 之后又显式 exec 9>&- 关闭了它,锁就提前释放了。
方案二:数据库锁,跨主机才需要
文件锁的前提是「所有触发者共享同一个文件系统」。一旦任务可能从两台机器触发(主备、容器多副本、cron 在 A 机而触发来自 B 机),文件锁就失效了——两个容器各自有一个 /var/lock,各自加锁成功。
MySQL 的 GET_LOCK 是跨主机方案里最方便的,因为它不需要额外组件:
SELECT GET_LOCK('backup_daily', 0);
-- 返回 1 表示拿到锁,0 表示别人持有,NULL 表示出错
-- 业务逻辑执行完后必须 RELEASE_LOCK('backup_daily')两个必须知道的特性:
锁是会话级的。连接断开(脚本崩溃、网络中断、超时被 kill)锁自动释放,这一点比文件锁更安全,不会留下死锁。但也意味着——如果脚本拿锁后做了一件耗时几分钟的事,而 MySQL 的 wait_timeout 是 60 秒且连接空闲,锁会在中途被服务端断开而释放,后半段的并发保护就没了。长任务要么定期发一条 SELECT 1 保活,要么把 wait_timeout 调大。
锁名是服务器全局的,不是按库隔离的。两个不同的库用同一个锁名会互相阻塞。命名时加上库名前缀最稳妥。
-- 在脚本里的典型用法(bash + mysql 客户端)
got=$(mysql -N -B -e "SELECT GET_LOCK('zz1984_backup', 0)")
if [ "$got" != "1" ]; then
echo "skip: locked" >> /var/log/backup.log
exit 0
fi
trap 'mysql -e "SELECT RELEASE_LOCK(\"zz1984_backup\")"' EXIT
# 业务逻辑trap ... EXIT 这一行很关键,它保证无论是正常结束还是被 kill,锁都会被释放。没有这一行,一旦脚本异常退出,锁要等到连接断开才释放,中间这段时间的任务全部被跳过。
方案三:Redis 锁,注意过期时间的陷阱
Redis 的 SET NX EX 是分布式锁最常见的实现,一句话就能写:
SET backup_lock 1 NX EX 1800含义是「键不存在时才设置,过期时间 1800 秒」。三个要点:
必须是 SET NX EX,不能拆成 SETNX + EXPIRE。两条命令之间有窗口期,进程如果在这个间隙崩溃,键就永久存在,后续所有任务都被跳过——而且因为键没有过期时间,需要人工去删。这是分布式锁最经典的事故形态。
过期时间要大于任务的最长耗时,但不能大太多。设太短,任务还没结束锁就过期了,第二个实例进来,两个一起跑。设太长,任务崩溃后要等很久才能恢复。比较实用的做法是设成「正常耗时的 3 倍」,并在任务末尾主动删除锁(删除前先比对值,避免删掉别人的锁)。
删除锁要原子。不能用 GET 判断再 DEL,因为这两步之间锁可能已经过期并被别人重新持有。用 Lua 脚本保证原子性:
EVAL "if redis.call('get',KEYS[1])==ARGV[1] then return redis.call('del',KEYS[1]) else return 0 end" 1 backup_lock "$TOKEN"其中 $TOKEN 是加锁时写入的随机值(比如 hostname-pid),确保只删自己持有的锁。
什么时候不该加锁:先修根本问题
加锁能让重复执行不造成数据损坏,但它掩盖了问题。如果任务是「每天同步一次数据」,正确做法是让同步操作本身幂等——用唯一索引挡住重复插入,用 INSERT ... ON DUPLICATE KEY UPDATE 或 REPLACE INTO,这样即使跑两次结果也一样。
我自己的习惯是两层都做:外层加锁(减少无效劳动和日志噪音),内层保证幂等(防止锁失效时的数据损坏)。只做其中一层,都会在某个边界条件下出事。
判断一个任务该用哪种方案,按这个顺序问三个问题:
1. 所有触发者是否共享同一个文件系统?是 → 文件锁足够,最省事。
2. 是否有多台机器或多副本?是 → 数据库锁或 Redis 锁,优先选已经存在的依赖,别为了一个锁再装一个服务。
3. 任务本身能否做成幂等?能 → 优先做幂等,锁作为附加保护而不是唯一防线。
验证锁真的生效了
加完锁一定要验证,而且要用「人为制造并发」的方式验证,不能只看代码。最直接的办法是手工同时启动两个实例:
# 终端 1
/opt/scripts/backup.sh &
# 立刻在终端 2 执行
/opt/scripts/backup.sh
# 期望第二个立即输出 skip,且日志里只有一条 start再验证「持锁进程崩溃后能恢复」:在任务执行中途 kill -9 掉它,然后立刻再跑一次。文件锁应该在进程消失后自动释放(因为描述符关闭),数据库锁在连接断开后释放,Redis 锁要等 TTL 到期。如果第二次仍然被跳过,说明锁的释放机制有问题——这往往是最难发现的缺陷,因为正常流程下永远不会暴露。
最后补一条运维习惯:在锁被拒绝的分支里一定要写日志。很多人写 if ! flock -n 9; then exit 0; fi,什么都不记。结果是某天任务连续几天没执行,日志里一片空白,完全不知道是「被锁跳过」还是「cron 没触发」。一行 echo skip 就能省下半小时排查。