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 防重入,拿不到锁就退出别死等
避坑清单
- cron 是无条件按点触发的闹钟,到点就启动一个新任务实例,从不检查上一次有没有跑完
- 当单次耗时大于调度周期,任务实例必然一个压一个地叠加,这是 cron 的设计不是 bug
- cron 不是任务队列没有"做完一个再做下一个"的语义,把它当队列用是最根本的认知错误
- 凡是周期性且可能跑得久的 cron 任务,都应该套一把 flock -n 来保证同一时刻只有一个实例
- flock 是 Linux 自带的原子文件锁,检查和加锁是一个不可分割的动作,绝不会有竞态条件
- flock 的 -n 是拿不到锁立刻退出,防叠加就用它;-w 是等指定秒数;默认不加是死等
- flock 的锁绑在文件描述符上,持锁进程退出哪怕是崩溃被 kill,锁都会自动释放不留死锁
- 绝不要自己用 test -f 加 touch 来手写锁,检查和创建是两步有竞态,脚本崩了还会留死锁
- 锁文件放 /var/lock 目录,创建后就别动它,解锁靠进程退出自动完成绝不能手动 rm 锁文件
- flock 是建议锁不是强制锁,所有需要互斥的入口都必须用 flock 锁同一个文件漏一个锁就破了
总结
这次"备份脚本自我繁殖"的事故,纠正了我一个关于"定时"的、想当然了很多年的幻觉。在我过去的脑子里,把一个任务交给 cron,设上"每 10 分钟一次",这件事的含义是清清楚楚的:它会"每 10 分钟,认认真真地、完完整整地,跑一遍"。我潜意识里给这句话补上了一个我从没说出口、却深信不疑的前提——"跑完上一遍,才跑下一遍"。我以为我交给 cron 的,是一条整齐的、首尾相接的传送带:一个任务走完,下一个才上来。可现场把这条"传送带"彻底证伪了。cron 给我的,根本不是一条传送带,而是一个没有感情的发令枪。它只做一件事:到点,"砰"地一声,放一个任务出去跑。它从不回头看——上一枪放出去的那个,跑到哪了?摔倒了没?还在不在场上?它一概不管。它的世界里,只有"时间到了"和"时间没到"这两种状态,没有"上一个跑完了没有"这个概念。它根本不具备"看一眼"的能力。我过去以为的那个"跑完一遍再跑下一遍",那个让我无比安心的前提,从头到尾,只存在于我自己的想象里——是我一厢情愿地,替 cron 补上了一个它从来没承诺过的保证。复盘到根上我才明白,我混淆了两个截然不同的东西:"按时触发"和"顺序执行"。cron 提供的,只是前者——它是一个精准的【触发器】;而我需要的,是后者——我需要的是【一个时刻只有一个】。这两件事,中间隔着一道我从没意识到要去搭的桥。当任务跑得快、十分钟绰绰有余时,这道缺失的桥被掩盖了——因为上一个总是恰好在下一个到来前结束,"传送带"的假象侥幸成立。可一旦数据涨上来、单次跑过了十分钟,这个被掩盖的真相就赤裸裸地暴露了:触发是触发,执行是执行,没有任何东西在保证它们排队。这次最大的收获,是我学会了去审视那些"想当然的前提"。一个系统,它【明确承诺】给你的能力,和你【默默期待】它具备的能力,常常是两回事。我期待 cron 会"互斥",可它从没这么说过;它说的只是"我会按时触发"。这中间的差额——那个"它没说、而我以为它会做"的部分——就是事故的温床。而且这种差额,往往在"轻负载"下被完美地掩盖着:任务快,就显不出 cron 不排队;路通,就显不出没有兜底路由;内存够,就显不出没人做互斥。系统在风平浪静时"看起来正常",会让我误以为那个我期待的保证"是存在的"——直到负载上来,把这个从没真实存在过的保证,连同我的幻觉,一起击穿。所以下一次,当我要依赖任何一个组件去"保证"某件事时,我会强迫自己分清楚:这个保证,是它【白纸黑字提供】的,还是我【自己脑补】出来的?如果是后者,那这个保证,就得由我自己,亲手用一把明确的"锁"去补上。因为很多最惨烈的崩溃,都不是因为某个零件坏了,而是因为有一个谁都以为"理应有人在做"的事,其实从头到尾,根本没有人在做。
—— 别看了 · 2026