定时脚本重复执行:用 flock 加心跳把并发挡住
问题
一个每十二分钟被调度一次的采集脚本,日志里偶尔会出现同一分钟的两份输出,随后目标 JSON 文件被写坏。单次手工执行结果完全正确,脚本逻辑本身没有问题,出问题的场景只有一个:同一时刻跑了两份。
原因分析
排查下来有三条常见诱因:上一轮还没结束,调度器已按固定间隔拉起下一轮;手工调试和定时执行撞在一起;进程重启时旧实例没退干净,新实例又起来了。三者本质相同——脚本默认假设"同一时刻只有自己在跑",却从未把这个假设变成代码里的约束。
用 ps 匹配进程名、或在脚本里维护一个全局布尔量,都只能覆盖单机单进程的理想情况。一旦换了机器、被容器重排,或者进程名对不上,这种判断就完全失效。它最大的危害不是失效,而是失效时悄无声息:脚本照常退出码 0,日志照常好看。
要确认是不是并发,成本其实很低:在每轮日志里带上 pid 和统一的批次号,同一批次出现两条不同 pid 的记录,就基本可以定案;再对照文件最后修改时间,如果写入时间落在两轮之间、内容却是拼接的,说明两个实例在交替写同一个文件。
做法与代码思路
把互斥落到文件系统上,用 fcntl.flock 的非阻塞锁。拿不到锁立刻退出并打一行日志,不要排队等待——排队的后果是任务堆积,最后雪崩式补跑。
```python
import fcntl, os, sys, time
LOCK = "/var/run/collector.lock"
def acquire():
f = open(LOCK, "w")
try:
fcntl.flock(f, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
return None
f.write(f"{os.getpid()} {int(time.time())}\n")
f.flush()
return f
lock = acquire()
if lock is None:
print("another instance is running, skip")
sys.exit(0)
```
flock 由内核在进程退出时自动释放,比手写 PID 文件可靠得多:进程崩溃、被 kill -9,锁都不会残留。
但这也带来一个新问题——如果进程没退出而是卡住了,锁会一直占着,后续所有轮次全部被跳过,而且看起来还很"安静"。所以长任务要叠一层心跳:每处理完一批就把当前时间写进状态文件,启动时若发现锁存在且心跳时间超过阈值(比如三倍调度周期),就判定为陈旧锁并告警。
输出文件同样要防半截写入。先写同目录下的临时文件,flush 加 os.fsync 之后再 os.replace 到目标名。replace 在同一文件系统上是原子操作,读取方要么看到旧内容,要么看到完整的新内容,不会读到写了一半的 JSON。
另外给脚本定义清楚的退出码:0 正常完成、0 因锁跳过、非 0 才算失败。很多调度系统只看退出码,跳过和失败混在一起时,告警会被噪音淹没,真出事反而没人看。
小结
脚本要显式声明并发假设,拿不到锁就退出,不要静默重入。
锁优先用 fcntl.flock,靠内核释放,不依赖清理逻辑。
长任务补心跳时间戳,用年龄判别陈旧锁,避免一次卡死锁住全部后续轮次。
任何会被下游读取的文件,都走 tmp + fsync + os.replace 落盘。
退出码要区分跳过与失败,否则监控等于没有。
改完之后可以用两个终端同时启动同一个脚本做验证:先起的那个正常跑完,后起的那个打印跳过并立即返回。这一步比读代码更能证明互斥真的生效,也顺手验证了锁在进程异常退出时确实会被释放。
- 点赞
- 收藏
- 关注作者
评论(0)