同一个备份脚本跑了 6 个:一次 cron 任务重叠与 flock 文件锁的复盘

一台备份服务器磁盘 IO 被打满、load 飙到几十,ps 一看同一个 backup.sh 居然有 6 个进程同时在跑。没人手动重复执行,脚本只在 crontab 里每 10 分钟一次。time 一量单次耗时 43 分钟——调度周期 10 分钟、单次 43 分钟,必然越堆越多。排查梳理:cron 是无条件按点触发的闹钟,到点就启动一个新任务实例,从不检查上一次跑完没,一旦单次耗时大于调度周期任务实例就会叠加,这是 cron 的设计不是 bug;cron 不是任务队列没有做完一个再做下一个的语义;防叠加要的是一把互斥锁保证同一时刻只有一个实例;进程间共享锁要靠一个文件;绝不能自己用 test -f 加 touch 手写锁,检查和创建是两步有竞态脚本崩了还留死锁;flock 是 Linux 自带的原子文件锁检查加锁不可分割绝无竞态;flock 的 -n 是拿不到锁立刻退出防叠加就用它,-w 是等指定秒数,默认死等;flock 的锁绑在文件描述符上持锁进程退出哪怕崩溃都自动释放不留死锁;给 cron 任务防叠加就是套 flock -n,可在 crontab 那行套也可在脚本开头 exec fd 再 flock -n;锁文件放 /var/lock 创建后别动它绝不能手动 rm 解锁;flock 是建议锁所有入口都得 flock 同一个文件才有效;查持锁进程用 lsof。正确做法是给周期性任务套 flock -n 防重入拿不到锁就退出并留日志,以及一套 cron 任务与文件锁排查纪律。

2023 年,一个备份脚本"自我繁殖"的故事,让我对"定时"这个词有了全新的敬畏。那天上午,监控告警:一台机器磁盘 IO 被打满,load 飙到几十,上面的服务全部变慢。我 SSH 上去,ps 一看,头皮发麻——同一个备份脚本 backup.sh,居然有 6 个进程,在【同时】运行。我心里第一个念头是:谁手贱,把这脚本连点了 6 次?我查了操作记录,没有。这脚本只在一个地方被调用——crontab 里,每 10 分钟一次。我盯着那 6 个进程的启动时间,它们一个比一个晚 10 分钟,像一串糖葫芦:10:00 启动一个、10:10 又一个、10:20 又一个……到 10:50,6 个全在跑,一个都没退。我一下子明白了:这脚本备份的数据,早就涨到一次跑不完了——它现在跑一趟,要 40 多分钟。可 cron 不管这个,它只认表:每到整十分,它就【准时】再发起一个,从不过问"上一个跑完没有"。于是新的压着旧的,6 个 backup.sh 一起抢同一块磁盘、读同一批文件、写同一个目标目录——它们互相拖慢,跑得更久,于是堆得更多……这是一个会自己滚雪球的死循环。我过去一直觉得,把一个任务交给 cron,就等于"让它每隔一段时间,安安静静地跑一遍"。可现实是,cron 给我的根本不是"一遍接一遍",而是"到点就发一个,死活不管前一个"。这件事逼着我把 cron 的触发模型、文件锁、flock 这一整套彻底理清了。本文复盘这次实战。

问题背景

环境:CentOS 7,一台跑数据备份的服务器
事故现象:
- 磁盘 IO 被打满,load 飙到几十,服务全部变慢
- ★ ps 一看:同一个 backup.sh,6 个进程同时在跑
- 没有人手动重复执行,脚本只在 crontab 里被调用

现场排查:
# 1. 看到底有几个 backup.sh 在跑
$ ps aux | grep backup.sh | grep -v grep
root  2310 ... 10:00  backup.sh        # ★ 10:00 启动的
root  2890 ... 10:10  backup.sh        # ★ 10:10 又一个
root  3471 ... 10:20  backup.sh
root  4055 ... 10:30  backup.sh
root  4633 ... 10:40  backup.sh
root  5210 ... 10:50  backup.sh        # ★ 6 个!一个没退
# ★ 启动时间正好每隔 10 分钟一个 —— 和 crontab 的
#   调度周期完全吻合。

# 2. 看 crontab 里是怎么配的
$ crontab -l
*/10 * * * * /opt/scripts/backup.sh    # ★ 每 10 分钟一次

# 3. ★ 量一下这脚本到底要跑多久
$ time /opt/scripts/backup.sh
real    43m12s                          # ★ 一趟要 43 分钟!
# ★ 关键矛盾:调度周期 10 分钟,单次耗时 43 分钟。
#   每 10 分钟发起一个,可一个要 43 分钟才结束 ——
#   必然越堆越多。

# 4. 看脚本自己的日志 —— 乱成一团
$ tail /var/log/backup.log
... 备份 /data/a 完成
... 备份 /d备份 /data/c 完成ata/b 完成     # ★ 多个进程
... 写入目标... 写入目标...               #   在【同时】往
... 备份 /d备份 /data/x...ata/y...        #   一个日志里写
# ★ 日志被多个进程的输出搅在一起,字都串行了。

根因(后来想清楚的):
1. ★ cron 的触发模型是"无条件按点触发"。它到点
   就启动一个新的任务实例,【从不检查】上一次
   那个实例,是不是还在跑。
2. ★ 这个 backup.sh,数据量早就涨到单次要跑 43
   分钟。而 crontab 配的是每 10 分钟一次。
3. ★ 于是:10:00 那个还在跑,10:10 cron 又发起
   一个;10:20、10:30…… 每个都要 43 分钟,
   每 10 分钟来一个 —— 它们必然【叠在一起】。
4. ★ 多个 backup.sh 同时跑,抢同一块磁盘的 IO、
   读同一批源文件、写同一个目标 —— 互相拖慢,
   单次耗时不降反升,于是堆积得更快。
5. ★ 这是个会【自我加速】的雪球:跑的越多 ->
   每个越慢 -> 堆的越多 -> 跑的更多……
6. 根本缺失:这个脚本【没有任何机制】,来保证
   "同一时刻,只有一个我在跑"。
不是 cron 坏了,是这个任务缺一把"锁" —— 没有人
拦住"上一个还没跑完,下一个就又冲进来"。

修复 1:cron 是"无条件按点触发"——它不管上一个跑完没

# === ★ 先纠正一个根深蒂固的误解:cron 的触发模型 ===

# === ★ 误解:"cron 每 10 分钟跑一遍" = "一遍接一遍" ===
# 我过去以为,*/10 的意思是"每 10 分钟,跑完一遍
#   再开始下一遍"。这是【错的】。
# ★ cron 的真实行为是:它就是一个【闹钟】。到了
#   设定的时间点,它就"叮"一声 —— 启动一个新的
#   任务进程。仅此而已。
# ★ 它【绝对不会】去看:上一次启动的那个进程,
#   现在是死是活、跑完没跑完。它压根不关心。

# === ★ 所以,当单次耗时 > 调度周期,灾难就来了 ===
# 调度周期 10 分钟,单次耗时 43 分钟:
#  10:00  启动实例 A(A 要跑到 10:43)
#  10:10  启动实例 B —— ★ 此时 A 还在跑!A、B 并存
#  10:20  启动实例 C —— A、B、C 三个并存
#  10:30  启动实例 D …… 越叠越多
# ★ cron 完全不知道、也不在乎 A 还没结束。它只是
#   机械地、准时地,又发了一个。

# === ★ 用一个最小例子,亲眼看见这件事 ===
# 写一个"故意跑很久"的脚本:
$ cat /tmp/slow.sh
#!/bin/bash
echo "$(date +%T) PID=$$ 开始" >> /tmp/slow.log
sleep 300                              # ★ 故意跑 5 分钟
echo "$(date +%T) PID=$$ 结束" >> /tmp/slow.log

# 配一个"每分钟一次"的 cron:
$ crontab -e
* * * * * /tmp/slow.sh
# ★ 周期 1 分钟,单次 5 分钟。看 /tmp/slow.log:
$ cat /tmp/slow.log
10:01:00 PID=1111 开始
10:02:00 PID=1234 开始                 # ★ 1111 还没结束
10:03:00 PID=1357 开始                 # ★ 又一个
10:04:00 PID=1480 开始                 # ★ 4 个并存了
# ★ 一目了然:cron 根本不等,到点就发新的。

# === ★ cron 不是"任务队列",它是"定时触发器" ===
# 队列的语义是"一个做完再做下一个";
# ★ cron 的语义是"到点就触发一次,触发完就不管了"。
#   这两者是完全不同的东西。把 cron 当队列用,
#   是这次事故最根本的认知错误。

# === 认知 ===
# ★ cron 是一个"无条件按点触发"的闹钟,它到点就
#   启动一个新任务实例,从不检查上一次有没有跑完。
#   一旦"单次耗时 > 调度周期",任务实例就会叠在
#   一起 —— 这不是 bug,是 cron 设计如此。要防止
#   叠加,必须任务【自己】加一把锁。

修复 2:文件锁——保证"同一时刻只有一个我在跑"

# === ★ 解决叠加的核心思路:互斥锁(mutual exclusion) ===

# === ★ 我们真正想要的语义 ===
# 我们想要的,不是"别再触发了" —— cron 该触发还
#   触发。我们想要的是:★ 任务实例被触发后,先问
#   一句"现在有别的我在跑吗?有 -> 我立刻退出;
#   没有 -> 我才开始干活"。
# ★ 这个"问一句、抢一下"的东西,就是【锁】。

# === ★ 锁是什么:一把同一时刻只能一个人拿的钥匙 ===
# 想象一个单人卫生间,门上有一把锁:
#  - 你进去,反锁 —— 你"持有"了这把锁。
#  - 别人来,门锁着,推不开 —— 他"拿不到"锁。
#  - 你出来,开锁 —— 锁释放,下一个人才能进。
# ★ 锁的本质:一种资源,【同一时刻,最多一个
#   持有者】。谁拿到,谁干活;拿不到的,要么等,
#   要么走。

# === ★ 为什么要用"文件"来做这把锁 ===
# 不同的进程之间,怎么共享这"一把锁"?它们各跑
#   各的,没有共同的内存。但它们能看到【同一个
#   文件系统】。
# ★ 所以:用一个【文件】来当锁。"持有锁" = "锁住
#   了这个文件";进程想干活前,先去锁这个文件,
#   锁成功 = 拿到锁,锁失败 = 别人正持有。
# ★ 这就是"文件锁(file lock)"。

# === ★ 千万别自己用"创建文件 + 删文件"来做锁 ===
# 很多人的第一反应是这样手写一个"锁":
$ cat bad_lock.sh
#!/bin/bash
if [ -f /tmp/my.lock ]; then           # ★ 锁文件在?在就退出
    echo "已有实例在跑"; exit 0
fi
touch /tmp/my.lock                     # ★ 不在?那我创建它
# ... 干活 ...
rm /tmp/my.lock                        # ★ 干完删掉
# ★ 这个写法【有严重 bug】,下一节细说。简单讲:
#   "检查"和"创建"是两步,两个进程可能在这两步
#   之间【同时穿过】—— 都看到"没锁",都去 touch,
#   都以为自己拿到了锁。这叫【竞态条件(race)】。
# ★ 还有:脚本中途被 kill / 崩溃,rm 没执行 ——
#   锁文件永远留着,以后谁都进不来(死锁)。

# === ★ 正确的做法:用操作系统提供的"原子锁" ===
# 我们需要一个【"检查 + 上锁"一步到位、不可能被
#   插队】的操作 —— 这种"不可分割"的操作叫【原子
#   操作】。Linux 提供了现成的:flock。
# ★ flock 把"看一眼有没有人 + 没有就锁上"这两件事,
#   合成【一个】谁也插不进来的动作。下一节讲它。

# === 认知 ===
# ★ 防止任务叠加,要的是一把"互斥锁":同一时刻只
#   允许一个持有者。进程间共享锁,要靠一个文件。
#   但【绝不能】自己用"判断文件存在 + touch"来做 ——
#   那有竞态、有死锁。要用操作系统的原子文件锁。

修复 3:flock——Linux 自带的原子文件锁

# === ★ flock:本文的主角,用法逐一拆解 ===

# === ★ flock 是什么 ===
# flock 是 Linux 自带的一个命令(也是一个系统调用)。
#   它能给一个文件加一把锁,而且"检查 + 加锁"是
#   ★【原子的】—— 两个进程同时来抢,操作系统保证
#   只有一个能成功,另一个明确地失败。
$ which flock
/usr/bin/flock                         # ★ 几乎所有发行版自带

# === ★ flock 最常用的形态:flock 文件 命令 ===
$ flock /tmp/my.lock -c '要执行的命令'
# ★ 含义:先锁住 /tmp/my.lock,锁成功后,执行
#   后面那条命令;命令跑完,自动释放锁。
# ★ 如果锁正被别人持有,flock 默认会【阻塞等待】,
#   一直等到对方释放。

# === ★ 关键选项 -n:拿不到锁就立刻失败(不等)===
$ flock -n /tmp/my.lock -c '命令'
# ★ -n = nonblock。加了 -n:锁能拿到就执行命令;
#   拿不到(别人正持有),★ 立刻退出,不等。
# ★ 防任务叠加,要的就是这个 -n —— "已经有一个
#   在跑了?那我这一个直接不干,退出。"
$ echo $?
1                                      # ★ 没拿到锁,flock 返回 1

# === ★ 选项 -w:最多等 N 秒,等不到才失败 ===
$ flock -w 10 /tmp/my.lock -c '命令'
# ★ -w 10 = 最多等 10 秒。10 秒内拿到锁就执行,
#   超过 10 秒还没拿到,就放弃退出。
# ★ -n 是"一秒都不等",-w 是"等一会儿"。按需选。

# === ★ 三种"等"的策略,对应三个选项 ===
# 1. ★ 默认(不加 -n / -w):死等,直到拿到锁。
# 2. ★ -n          :完全不等,拿不到立刻退出。
# 3. ★ -w 秒数     :等指定的秒数,超时退出。
# 防叠加用 -n;"想排队但不想等太久"用 -w。

# === ★ 在脚本【内部】用 flock(锁住一个 fd)===
# 除了"flock 文件 命令"这种包裹写法,还能在脚本
#   内部给某个文件描述符加锁:
$ cat myjob.sh
#!/bin/bash
exec 200>/tmp/myjob.lock               # ★ 把锁文件挂到 fd 200
flock -n 200 || { echo "已有实例"; exit 1; }   # ★ 锁 fd 200
# --- 到这里,锁已拿到,下面安心干活 ---
echo "开始备份..."
# ... 真正的工作 ...
# ★ 脚本结束、fd 200 关闭,锁【自动释放】。
# ★ 这种写法的好处:锁的作用域 = 整个脚本,而且
#   不用手动解锁。

# === ★ flock 的锁,进程一退出就自动释放(关键优点)===
# ★ flock 的锁是【绑在文件描述符上】的。持有锁的
#   进程,无论是【正常结束】,还是【被 kill、崩溃】,
#   只要进程没了,它打开的 fd 就被内核关闭,
#   锁也就【自动释放】。
# ★ 这正好补上了"手写锁"最大的坑 —— 不会因为
#   脚本中途挂掉,就留下一个永远解不开的死锁。

# === 认知 ===
# ★ flock 是 Linux 自带的原子文件锁:"检查+加锁"
#   不可分割,绝无竞态。-n 拿不到立刻退出(防叠加
#   就用它),-w 等指定秒数,默认死等。它的锁绑在
#   fd 上,进程一退出(哪怕是崩溃)就自动释放,
#   天然不会留下死锁。

修复 4:给 cron 任务套上 flock 的正确写法

# === ★ 把 flock 用到本文这个 backup.sh 上 ===

# === ★ 写法 1:直接在 crontab 这一行里套 flock ===
# 原来的(出事的)写法:
*/10 * * * * /opt/scripts/backup.sh
# ★ 改成:
*/10 * * * * /usr/bin/flock -n /var/lock/backup.lock /opt/scripts/backup.sh
# ★ 解读:每 10 分钟,cron 照样触发。但触发后:
#   - 先抢 /var/lock/backup.lock 这把锁;
#   - 抢到(说明没有别的实例)-> 执行 backup.sh;
#   - ★ 抢不到(上一个还在跑)-> flock 因为 -n
#     立刻退出,backup.sh 这次【根本不会启动】。
# ★ 一行之差,雪球彻底没了。

# === ★ 写法 2:在脚本内部加锁(推荐,更省心)===
# 改造 backup.sh 本身,让它"自带防重入":
$ cat /opt/scripts/backup.sh
#!/bin/bash
# --- 开头先抢锁 ---
exec 200>/var/lock/backup.lock
flock -n 200 || {
    echo "$(date) 上一个备份还没跑完,本次跳过" >> /var/log/backup.log
    exit 0
}
# --- 抢到锁了,下面是真正的备份逻辑 ---
echo "$(date) 备份开始" >> /var/log/backup.log
rsync -a /data/ /backup/data/
echo "$(date) 备份结束" >> /var/log/backup.log
# ★ 脚本退出,fd 200 关闭,锁自动释放。
# ★ 好处:不管这脚本被谁调用(cron、手动、别的
#   脚本),它都【自带】"同一时刻只跑一个"的保证。

# === ★ 两种写法怎么选 ===
# - 写法 1(crontab 里套):改动小,不动脚本。适合
#   脚本不方便改、或临时止血。
# - ★ 写法 2(脚本内部锁):防护跟着脚本走,谁调用
#   都安全。推荐 —— 锁是这个任务的"内在属性",
#   就该写在它自己身上。

# === ★ -n 退出时,别让它"静默消失" ===
# flock -n 拿不到锁时退出,默认是【悄无声息】的。
# ★ 最好留一行日志(像上面写法 2 那样),写明
#   "本次因上一个没跑完而跳过"。否则将来你会
#   疑惑"为什么这个点的备份没了" —— 其实是被
#   正常跳过了,但没记录就成了悬案。

# === ★ 验证 flock 真的生效了 ===
# 开两个终端,同时跑同一个加了锁的命令:
# 终端 A:
$ flock -n /var/lock/backup.lock -c 'echo A拿到锁; sleep 30'
A拿到锁                                 # ★ A 拿到了
# 终端 B(A 还在 sleep 时立刻执行):
$ flock -n /var/lock/backup.lock -c 'echo B拿到锁'
$ echo $?
1                                      # ★ B 没拿到,直接退出
# ★ B 什么都没输出、返回 1 —— 锁生效了。

# === 认知 ===
# ★ 给 cron 任务防叠加,就是用 flock -n 套住它:
#   要么在 crontab 那一行套,要么(更推荐)在脚本
#   开头 exec 一个 fd 再 flock -n。拿不到锁就 exit,
#   并留一行日志说明"本次跳过",别让跳过变成悬案。

修复 5:锁文件的那些坑——放哪、别误删、别手写

# === ★ 用 flock 之后,还有几个细节坑要避开 ===

# === ★ 坑 1:锁文件放在哪 ===
# ★ 推荐放 /var/lock/ 或 /run/(/var/run):
$ flock -n /var/lock/myjob.lock ...
# ★ 别随便放 /tmp:很多系统有 tmpwatch / systemd-tmpfiles,
#   会定期清理 /tmp 里的旧文件。锁文件被清掉本身
#   不致命(flock 锁的是 inode,文件还开着锁就还在),
#   但容易引起混乱。/var/lock 是专门给锁文件的地方。
# ★ 锁文件【不需要】你预先创建 —— flock 会自动建。
#   它也【不需要】有内容,是个空文件就行。

# === ★ 坑 2:绝不要"手动 rm 锁文件"来"解锁" ===
# flock 的锁,是靠【进程持有 fd】来维持的,不是靠
#   "锁文件存不存在"。
# ★ 所以:你手动 rm 掉那个锁文件,【并不能】解锁 ——
#   持锁进程的 fd 还开着,锁还在。
# ★ 更糟:rm 之后,下一个进程会新建一个【同名但
#   不同 inode】的文件去锁 —— 于是两个进程锁的是
#   两个不同的 inode,锁形同虚设,又能并发了。
# ★ 结论:锁文件【创建后就别动它】。解锁这件事,
#   交给"进程退出自动释放",不要人为干预。

# === ★ 坑 3:为什么"手写锁"(test -f + touch)是错的 ===
$ cat bad_lock.sh
#!/bin/bash
[ -f /tmp/x.lock ] && exit 0           # ① 检查锁在不在
touch /tmp/x.lock                      # ② 不在,创建锁
# ★ 致命问题:① 和 ② 是【两个独立步骤】。两个进程
#   可能【同时】跑到 ①,都看到"锁不在",于是都
#   去执行 ②,都以为自己拿到了锁 —— 这就是
#   【竞态条件】。锁,在这一刻形同虚设。
# ★ flock 的价值,就在于它把"检查+加锁"做成了
#   一个【原子】动作 —— 操作系统保证它中间不可
#   能被插队。手写永远做不到这一点。

# === ★ 坑 4:flock 是"建议锁",不是"强制锁" ===
# ★ flock 属于"建议性锁(advisory lock)":它只在
#   "大家都用 flock 去锁同一个文件"时才有效。
# ★ 如果有个进程【根本不调用 flock】,直接去读写
#   那个文件,flock 是【拦不住】它的。
# ★ 所以:要让锁有用,所有需要互斥的入口,都必须
#   走 flock 去锁【同一个】锁文件。漏一个,锁就破了。

# === ★ 坑 5:子进程会继承锁 —— 注意作用域 ===
# 用 exec 200>file; flock 200 之后,脚本里启动的
#   子进程,会【继承】fd 200。
# ★ 影响:就算主脚本逻辑跑完了,只要还有继承了
#   fd 200 的子进程活着(比如你 nohup 了一个后台
#   进程),锁就【不会释放】。
# ★ 排查"锁迟迟不释放"时,要想到:是不是有子
#   进程还攥着那个 fd。

# === ★ 坑 6:查"现在谁持有这把锁" ===
$ lsof /var/lock/backup.lock
COMMAND  PID  USER  FD  ...
backup.sh 2310 root 200  /var/lock/backup.lock
# ★ lsof 锁文件,能看到当前是哪个进程(PID)持有它。
#   排查"任务为什么一直被跳过"时,这一招直接定位
#   到那个"赖着不走"的进程。

# === 认知 ===
# ★ 锁文件放 /var/lock,创建后别动它;解锁靠进程
#   退出自动完成,绝不能手动 rm。手写"test+touch"
#   锁有竞态,必须用 flock。flock 是建议锁,所有
#   入口都得用它锁同一个文件才有效。查持锁进程用
#   lsof。

修复 6:cron 任务与文件锁排查纪律

# === 这次事故暴露的认知盲区,定几条纪律 ===

# === 1. ★ cron 是"无条件按点触发",到点就发新实例,从不检查上一个跑完没 ===

# === 2. ★ 单次耗时 > 调度周期,任务实例必然叠加,这不是 bug 是 cron 的设计 ===

# === 3. ★ 凡是"周期性、可能跑得久"的 cron 任务,都该套一把 flock -n 防叠加 ===
$ flock -n /var/lock/任务.lock 你的命令

# === 4. flock 是原子文件锁,"检查+加锁"不可分割,绝无竞态 ===

# === 5. ★ -n 拿不到锁立刻退出(防叠加用它),-w 等 N 秒,默认死等 ===

# === 6. ★ flock 的锁绑在 fd 上,进程退出(哪怕崩溃)就自动释放,不会留死锁 ===

# === 7. 绝不要手写"test -f + touch"做锁 —— 有竞态,脚本崩了还会留死锁 ===

# === 8. ★ 锁文件放 /var/lock,创建后别动它;绝不能手动 rm 来"解锁" ===

# === 9. flock 是建议锁,所有需要互斥的入口都必须 flock 同一个锁文件才有效 ===

# === 10. 排查"同一任务多个实例在跑"的步骤链 ===
$ ps aux | grep 脚本名 | grep -v grep   # ① 确认有几个实例、启动时间
$ crontab -l                            # ② 看调度周期
$ time 脚本                             # ③ 量单次耗时,和周期对比
$ lsof /var/lock/xx.lock                # ④ (已加锁时)看谁持锁
$ 给命令套 flock -n 锁文件               # ⑤ 根治:加锁防叠加
# 按这个顺序,"定时任务自我繁殖"基本能定位、能根治。

命令速查

需求                        命令
=============================================================
看某脚本有几个实例在跑      ps aux | grep 脚本名 | grep -v grep
看 cron 调度配置            crontab -l
量脚本单次耗时              time 脚本
锁文件 + 执行命令(死等)   flock /var/lock/x.lock 命令
锁不到就立刻退出            flock -n /var/lock/x.lock 命令
锁不到最多等 10 秒          flock -w 10 /var/lock/x.lock 命令
脚本内部加锁                exec 200>/var/lock/x.lock; flock -n 200
看谁持有某把锁              lsof /var/lock/x.lock
crontab 里防叠加的标准写法  */10 * * * * flock -n /var/lock/x.lock 脚本

口诀:cron 到点就发新实例从不管上一个,单次耗时超过周期就会叠加
      周期性任务一律套 flock -n 防重入,拿不到锁就退出别死等

避坑清单

  1. cron 是无条件按点触发的闹钟,到点就启动一个新任务实例,从不检查上一次有没有跑完
  2. 当单次耗时大于调度周期,任务实例必然一个压一个地叠加,这是 cron 的设计不是 bug
  3. cron 不是任务队列没有"做完一个再做下一个"的语义,把它当队列用是最根本的认知错误
  4. 凡是周期性且可能跑得久的 cron 任务,都应该套一把 flock -n 来保证同一时刻只有一个实例
  5. flock 是 Linux 自带的原子文件锁,检查和加锁是一个不可分割的动作,绝不会有竞态条件
  6. flock 的 -n 是拿不到锁立刻退出,防叠加就用它;-w 是等指定秒数;默认不加是死等
  7. flock 的锁绑在文件描述符上,持锁进程退出哪怕是崩溃被 kill,锁都会自动释放不留死锁
  8. 绝不要自己用 test -f 加 touch 来手写锁,检查和创建是两步有竞态,脚本崩了还会留死锁
  9. 锁文件放 /var/lock 目录,创建后就别动它,解锁靠进程退出自动完成绝不能手动 rm 锁文件
  10. flock 是建议锁不是强制锁,所有需要互斥的入口都必须用 flock 锁同一个文件漏一个锁就破了

总结

这次"备份脚本自我繁殖"的事故,纠正了我一个关于"定时"的、想当然了很多年的幻觉。在我过去的脑子里,把一个任务交给 cron,设上"每 10 分钟一次",这件事的含义是清清楚楚的:它会"每 10 分钟,认认真真地、完完整整地,跑一遍"。我潜意识里给这句话补上了一个我从没说出口、却深信不疑的前提——"跑完上一遍,才跑下一遍"。我以为我交给 cron 的,是一条整齐的、首尾相接的传送带:一个任务走完,下一个才上来。可现场把这条"传送带"彻底证伪了。cron 给我的,根本不是一条传送带,而是一个没有感情的发令枪。它只做一件事:到点,"砰"地一声,放一个任务出去跑。它从不回头看——上一枪放出去的那个,跑到哪了?摔倒了没?还在不在场上?它一概不管。它的世界里,只有"时间到了"和"时间没到"这两种状态,没有"上一个跑完了没有"这个概念。它根本不具备"看一眼"的能力。我过去以为的那个"跑完一遍再跑下一遍",那个让我无比安心的前提,从头到尾,只存在于我自己的想象里——是我一厢情愿地,替 cron 补上了一个它从来没承诺过的保证。复盘到根上我才明白,我混淆了两个截然不同的东西:"按时触发"和"顺序执行"。cron 提供的,只是前者——它是一个精准的【触发器】;而我需要的,是后者——我需要的是【一个时刻只有一个】。这两件事,中间隔着一道我从没意识到要去搭的桥。当任务跑得快、十分钟绰绰有余时,这道缺失的桥被掩盖了——因为上一个总是恰好在下一个到来前结束,"传送带"的假象侥幸成立。可一旦数据涨上来、单次跑过了十分钟,这个被掩盖的真相就赤裸裸地暴露了:触发是触发,执行是执行,没有任何东西在保证它们排队。这次最大的收获,是我学会了去审视那些"想当然的前提"。一个系统,它【明确承诺】给你的能力,和你【默默期待】它具备的能力,常常是两回事。我期待 cron 会"互斥",可它从没这么说过;它说的只是"我会按时触发"。这中间的差额——那个"它没说、而我以为它会做"的部分——就是事故的温床。而且这种差额,往往在"轻负载"下被完美地掩盖着:任务快,就显不出 cron 不排队;路通,就显不出没有兜底路由;内存够,就显不出没人做互斥。系统在风平浪静时"看起来正常",会让我误以为那个我期待的保证"是存在的"——直到负载上来,把这个从没真实存在过的保证,连同我的幻觉,一起击穿。所以下一次,当我要依赖任何一个组件去"保证"某件事时,我会强迫自己分清楚:这个保证,是它【白纸黑字提供】的,还是我【自己脑补】出来的?如果是后者,那这个保证,就得由我自己,亲手用一把明确的"锁"去补上。因为很多最惨烈的崩溃,都不是因为某个零件坏了,而是因为有一个谁都以为"理应有人在做"的事,其实从头到尾,根本没有人在做。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
Linux教程

文件明明 chmod 777 了还是 Permission denied:一次 Linux 目录执行位的排查复盘

2026-5-20 23:46:19

Linux教程

程序一启动就报 cannot open shared object file:一次 Linux 动态链接库路径的排查复盘

2026-5-20 23:59:02

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索