我先打个招呼这篇稿子不是教科书是我在真实环境里被坑过之后总结出来的。你如果写过cron定时任务、搞过自动化测试脚本、维护过后台守护进程大概率遇到过这样一种诡异现象——脚本明明只该跑一次结果却同时在系统里蹦出好几个实例。轻则日志打架、数据重复处理重则直接把数据库写崩。没错说的就是Shell脚本重复执行问题。今天这篇内容的核心只有一件事怎么在脚本启动时可靠地检测“自己是不是已经在跑了”并且用最稳妥的方式把它拦截下来。我会把PID文件、pgrep进程匹配、flock文件锁、mkdir原子锁这些方案全部过一遍结合定时任务、全自动测试脚本、后台跑批这些实际场景给出可以直接抄走的模板和参数依据最后还会拆几个我踩过的坑。无论你是只写过几行Shell的新手还是常年维护线上脚本的老手都应该能从这里拿到点有用的东西。1. 为什么要防重复执行先看看脚本重复跑会捅多大娄子1.1 重复执行造成的三种典型事故先说日志混乱这是最轻的一档。脚本每跑一次就把同一批数据写一遍日志文件里同一个任务出现好几条相同记录排查问题的时候根本分不清哪条是哪次执行产生的。到了排障环节你对着日志找半天最后发现是被重复执行给刷花了那种感觉特别浪费时间。数据错乱就要严重一些。比如一个设备老化测试脚本它会按批次记录测试结果如果两个实例同时在写同一个结果文件要么互相覆盖要么在文件末尾交错追加整个结果表直接废掉。再比如一个自动备份脚本两个实例同时在打包同一目录最后生成的压缩包可能是坏的、不完整的。最要命的是资源耗尽。脚本里如果跑的是批量处理任务每个实例都会申请内存、占用CPU、打开文件句柄。一个实例跑得好好的突然又冒出两三个系统负载直接飙升。我在一台4核8G的机器上遇到过这种事情一个没做防重入的同步脚本被cron调度漏跑补跑结果同时起了四个实例CPU直接打满连SSH连上去都要卡好几秒。1.2 最容易踩雷的场景清单我整理了一下以下这些场景特别容易踩重复执行的雷cron定时任务。任务执行耗时超过了调度间隔上一次还没跑完下一次调度又来了。手动补救。看到脚本卡住了或者失败了下意识重新跑一遍结果旧进程其实还在后台运行。自动化触发链条。脚本A调用脚本B脚本B又调用脚本C中间某一环重复出发整条链跑了两遍。用户重复点击/重复提交。比如内部的Web管理面板上有个“执行脚本”按钮用户连点两次。开机自启。系统启动时通过rc.local或systemd拉起脚本同时用户在登录后手动执行了一次。针对性说一句最常见的其实是cron场景。因为很多人的cron只设置了执行时间根本没有考虑任务耗时浮动。比如设置成每5分钟跑一次正常情况下跑3分钟某次数据量大跑了6分钟下一轮调度直接又起了新实例。2. 方案一与方案二pgrep进程匹配和PID锁文件2.1 pgrep检测能跑通但很脆先说最直观的思路脚本启动时检查系统里有没有自己的进程。用命令行的方式通常是pgrep加脚本名去匹配。我见过很多初版脚本这么写#!/bin/bash if pgrep -f sync_data.sh | grep -v $$ /dev/null 21; then echo 脚本已在运行退出 exit 1 fi # 下面是实际任务这个方案的核心逻辑是用pgrep列出所有命令行里包含sync_data.sh的进程PID然后把自己这个进程排除掉如果还剩下其他PID说明有别的实例在跑。问题非常明显。第一grep -v $$只排除了当前shell进程如果你的脚本是通过bash脚本文件方式运行的实际对应的进程可能是/bin/bash /path/to/sync_data.sh当前PID并不代表脚本主体的PID容易误判。第二如果另一个用户用绝对路径运行脚本或者通过符号链接运行命令行里的字符串和你pgrep匹配的字符串对不上漏检。第三如果系统里有别的脚本也调用了相同的关键字莫名其妙就误判了。第四pgrep匹配的字符串里包含脚本路径一旦路径改了老实例没退出新实例照样能跑。所以这个方案我只能说“演示可以生产环境别用”。它最大的问题是判别逻辑太依赖命令行文本而命令行文本是可以被各种因素改变的。用这种方式来防重入本来就是一种脆弱的做法。2.2 PID锁文件把实例身份固化下来既然靠进程名匹配不可靠自然的思路是自己在脚本里记录一个PID文件。原理很简单脚本启动时先检查PID文件是否存在如果存在就读取里面的PID然后检查这个进程还活着没。如果活着直接退出如果死了说明是上次遗留的脏文件删除后重新创建。我整理了一个比较标准的模板#!/bin/bash SCRIPT_NAMEsync_data PID_FILE/tmp/${SCRIPT_NAME}.pid # 检查PID文件 if [ -f ${PID_FILE} ]; then OLD_PID$(cat ${PID_FILE}) if kill -0 ${OLD_PID} 2/dev/null; then echo [错误] 脚本已在运行PID${OLD_PID} exit 1 else echo [警告] 检测到残留PID文件清理 rm -f ${PID_FILE} fi fi # 创建PID文件 echo $$ ${PID_FILE} chmod 644 ${PID_FILE} # 退出时清理PID文件 trap rm -f ${PID_FILE} EXIT # 下面是实际业务逻辑 echo 开始执行任务PID$$ sleep 300这个方案里有两个技术点值得说。第一个是kill -0的用法它不会给进程发任何信号只用来检查进程是否存在以及是否具有发送信号的权限。如果进程存在且你有权限返回0进程不存在返回1。这比用ps -p $PID去过滤要快而且不依赖操作系统对进程名的解析。第二个是trap ... EXIT。这个很关键它的作用是脚本无论正常结束、被中断、还是被kill掉都会在退出时执行清理命令。我在模板里用的是删除PID文件避免下一次运行时读到残留的脏文件。但也必须指出它的缺陷。如果脚本在执行中途被kill -9强杀EXIT陷阱不会生效PID文件会残留。下次运行脚本时用kill -0去检查那个PID——如果PID恰好被系统其他进程复用了脚本就会误判“已有实例在运行”拒绝执行。这就是所谓的谬误锁死。2.3 两种方案各自的适用定位两者对比起来是这样方案优点缺点适合场景pgrep进程匹配实现最快、不用管理文件匹配规则脆弱、误判率高一次性临时脚本、机器上无其他脚本夹杂PID锁文件能防常规重复、逻辑透明kill -9会产生脏文件、PID复用会误判可管控生命周期、不使用强杀手段的内部脚本我个人的经验是如果只是自己机器上跑的一个临时脚本用pgrep也不是不行毕竟省事。但如果你写的脚本是要进cron、要长期跑、要给别人用的就别用这两个方案冒险。换用文件锁。3. 王者方案flock文件锁和mkdir原子锁3.1 flock为什么值得当首选flock是Linux内核提供的文件锁工具它锁定的是一个文件描述符。简单理解就是这样多把钥匙但只存在一把能打开同一扇门的锁。它的脾气很直。flock的核心用法是flock [选项] 锁文件 [命令]它的优势非常明显。第一锁是内核维护的进程退出后锁自动释放不会像PID文件那样留下脏文件。第二判断是否加锁是原子操作不存在两个进程同时加锁成功的情况。第三它不需要维护PID文件不需要检查进程状态天然就比前面两个方案可靠。这里说的原子性很重要我展开解释一下。所谓原子就是指一个操作在系统中要么完全执行、要么完全不执行中间不会有其他进程插入。比如创建文件、修改文件内容这两个动作就做不到原子两个进程完全可以同时读写插队。而flock加锁是一个系统调用内核保证同一时间只能有一个进程获得锁。3.2 可直接抄走的flock模板我最常用的写法是这样的你们可以直接保存使用#!/bin/bash LOCK_FILE/tmp/my_task.lock # 打开锁文件关联到文件描述符9 exec 9${LOCK_FILE} # 尝试获取排它锁非阻塞模式 if ! flock -n 9; then echo [$(date %F %T)] 已有脚本实例在运行本次退出 exit 1 fi # 运行锁内的业务逻辑 echo [$(date %F %T)] PID$$ 开始执行任务 sleep 30 echo [$(date %F %T)] 任务执行完毕解释一下每一行的作用。exec 9${LOCK_FILE}表示以读写方式打开锁文件并绑定到文件描述符9这一步很关键因为之后flock锁的是这个文件描述符对应的文件。flock -n 9表示对文件描述符9上的文件加排它锁-n表示非阻塞如果加锁失败立即返回不会傻等。这段代码真正实现了“检测是否已在执行”的目标如果前一个实例还在跑锁被占用flock -n 9返回非0脚本就退出。如果前一个实例已经结束内核释放锁新实例顺利加锁进入任务。还有一点值得提为什么用exec 9而不是直接在命令行里写flock命令因为我们需要的是“在整个脚本生命周期内持有锁”而不是只锁住某一条子命令。通过exec把锁关联到脚本自身的文件描述符上这个文件描述符在脚本退出前都是打开的锁也一直有效。脚本结束或进程被杀内核自动清理文件描述符锁自然释放。3.3 兼容性升级带等待时间的flock变体上面模板是“发现已有实例就立即退出”。但有些场景我们希望脚本等待一会儿等前一个实例跑完再继续。比如数据同步任务上一次运行快结束时下一次调度来了与其放弃不如排队。这种场景把flock -n改成flock即可exec 9${LOCK_FILE} flock 9 || { echo 无法获取锁 exit 1 }不带-n的flock会一直阻塞等待直到前一个实例释放锁。但要注意阻塞等待通常是有风险的。如果前一个实例卡死、锁一直不释放新实例会无限等下去反而变成一个新的“卡死进程”。更稳妥的做法是设置等待上限。flock本身没有超时参数但可以通过shell脚本实现有限期等待LOCK_FILE/tmp/my_task.lock exec 9${LOCK_FILE} # 尝试加锁最多等60秒 for i in $(seq 1 60); do if flock -n 9; then break fi if [ $i -eq 60 ]; then echo [错误] 等待锁超时退出 exit 1 fi sleep 1 done这样脚本最多等60秒拿不到锁就放弃。这个逻辑很实用尤其适合cron任务——它既能避开重复执行又不会无限期阻塞导致任务堆积。3.4 mkdir原子锁不依赖flock的兜底方案有些特殊环境里没有flock命令虽然现在多数Linux发行版都自带了flock但万一你在的是极简环境、busybox环境怎么办。有个经典替代方案利用mkdir命令的原子性做锁目录。mkdir的原子性在于同一目录下创建同一个子目录只能成功一次。两个进程同时执行mkdir /tmp/mylock只有一个会成功返回0另一个会返回“目录已存在”。这就天然实现了互斥。模板如下LOCK_DIR/tmp/my_task.lockdir if ! mkdir ${LOCK_DIR} 2/dev/null; then echo 已有脚本实例在运行退出 exit 1 fi trap rmdir ${LOCK_DIR} EXIT # 业务逻辑 echo 开始执行任务 sleep 30这套方案和PID锁文件比明显更可靠。它同样存在kill -9后锁目录残留的问题但是重试逻辑更好处理——因为目录的存在并不依赖于某个PID是否还活着你只要判断“目录在且里面有活的PID记录”就清理或者干脆通过目录内PID文件判断。当然最朴素的兜底逻辑是检查目录是否存在再检查目录里的PID文件对应的进程是否还活着如果进程死了就自动清理目录。这就是为什么我喜欢用mkdir锁目录当备选方案的原因它几乎不依赖任何外部命令mkdir、rmdir是每个系统都有的。3.5 四种方案横向对比直接告诉你选哪个把前面所有方案摆在一张表里看这个选择就非常清楚了方案防重复可靠性防脏文件依赖命令推荐度pgrep进程匹配低无需文件pgrep不推荐PID锁文件中低kill/ps临时脚本可用flock文件锁高高flock首选mkdir锁目录中高中mkdir/rmdir备选我自己的选用原则很简单能用flock就用flock不能用flock就用mkdir锁目录PID文件方案只用于纯自用脚本。4. 真实场景实战定时任务、全自动老化测试脚本的防重入设计4.1 cron定时任务的正确写法定时任务应该是最需要防重复的场合。如果任务本身跑得很快几秒内完成重复执行的概率很低可以不管。但如果任务耗时可能超过调度间隔就必须处理。我这里给出一套完整的cron防重入写法。它把脚本加锁逻辑和任务本体分开封装先写一个公共的加锁函数文件比如/opt/scripts/lib_lock.sh#!/bin/bash # 用法: acquire_lock lock_name # 返回值: 0表示获取锁成功非0表示失败 acquire_lock() { local lock_name$1 local lock_file/tmp/${lock_name}.lock exec 9${lock_file} if ! flock -n 9; then echo [$(date %F %T)] 获取锁失败已有实例在运行 return 1 fi return 0 } release_lock() { # 释放锁其实可以不做文件描述符9在进程退出时自动关闭 # 这里显式关闭是为了代码可读性 exec 9- }然后在具体任务脚本中引用#!/bin/bash source /opt/scripts/lib_lock.sh if ! acquire_lock hourly_backup; then exit 0 fi # 业务逻辑全量备份 rsync -av /data/ /backup/data/最后在crontab里配置30 * * * * /opt/scripts/hourly_backup.sh /var/log/backup.log 21这套结构的好处是清晰加锁逻辑从业务脚本里抽离出来多个脚本可以共用同一套函数锁文件名就是业务名一眼能看出来哪个锁对应哪个任务。4.2 设备老化测试全自动执行脚本的防重复设计网上那些热度很高的“设备老化测试全自动执行脚本”其实就是后台跑批数据处理日志上报的组合体。这类脚本有个特点测试时间长、需要持续监控、失败要自动重试如果重复执行两台设备同时写结果数据直接乱套。我设计过一个类似的脚本它的防重复策略是这样做的第一层flock锁保证同一时间只有一台“总控进程”在跑。第二层总控进程内部记录每个测试设备的状态用锁文件状态文件双重判断。第三层每次写入测试结果前再校验一下总控进程是否还是自己——这属于双保险。下面是一个精简后的框架#!/bin/bash LOCK_FILE/tmp/aging_test_controller.lock STATUS_FILE/tmp/aging_test_status DEVICE_LIST/opt/config/devices.txt # 总控进程只允许一个 exec 9${LOCK_FILE} if ! flock -n 9; then echo [$(date %F %T)] 已有老化测试总控在运行退出 exit 1 fi # 记录总控PID供子脚本查验 echo $$ ${STATUS_FILE} # 逐台设备执行测试 for device in $(cat ${DEVICE_LIST}); do echo [$(date %F %T)] 开始测试设备: ${device} # 这里建议再套一层flock防止同一台设备被并发测试 DEVICE_LOCK/tmp/aging_test_${device}.lock exec 8${DEVICE_LOCK} if ! flock -n 8; then echo [警告] 设备 ${device} 已在测试中跳过 continue fi # 执行单台设备的测试逻辑 bash run_test.sh ${device} exec 8- done这套设计看起来不复杂但解决了一个很隐蔽的问题总控不重入不代表子任务不重入。如果一台设备刚好在上一个测试周期里卡住新周期的总控又来了单台设备的锁就把重复的测试挡掉了。多级锁的思维在这个场景里很实用。4.3 超时处理与强制解锁策略脚本在执行中卡死的情况谁都会遇到锁一直占着后续任务全部被挡住。这时候怎么处理我分几步讲。第一步先看锁文件被哪个PID持有。flock本身不直接记录PID但你可以通过fuser命令查fuser -v /tmp/my_task.lock输出里能看到占用该文件的进程PID和用户。第二步判断该进程是否还健在。用ps -p $PID -o pid,etime,cmd看下它跑了多久、在干什么。第三步视情况处理。如果确认是死锁或者卡死的僵尸进程可以强杀或者直接用fuser -k /tmp/my_task.lock把持有锁的进程干掉。注意fuser -k是一个比较粗暴的命令它会杀掉所有打开该文件的进程。用之前必须确认没有其他重要进程在读写锁文件。现实里锁文件通常只被脚本自身打开风险可控。还有一点要提醒强制解锁后锁文件可以保留也可以删除。删除后下一次脚本重启会重新创建锁文件没问题。但如果脚本正在运行你在它退出前先把锁文件删了就会产生一个新问题——后续新脚本可以创建新锁文件并成功加锁。这时候旧实例和新实例就可能同时存在。所以正确顺序一定是先确认进程已死再删锁文件。如果进程还活着你删了锁文件就是在制造新的竞态。这也是flock方案比PID文件方案优越的地方哪怕你不删锁文件只要持有锁的进程死掉内核自动释放锁。删不删文件根本无所谓。推荐直接留着锁文件里面没东西不影响任何逻辑。4.4 开机自启场景的特殊处理开机自启也是重复执行的重灾区。rc.local里启动一次systemd里又启动一次用户登录后手动再来一次三次叠加。systemd场景下有一个细节如果你的脚本在启动时设置了flock但是systemd服务配置了Restartalways服务崩溃后会被systemd再次拉起。此时如果锁还在新实例会直接退出然后systemd继续尝试拉起形成一个“起来就退出”的死循环。这种问题我碰到过。解法有两个思路。思路一给flock加等待时间让新实例等待旧实例结束。思路二在脚本里跳过已经被systemd管理的场景简单判断一下环境变量if [ -n $SYSTEMD_UNIT ]; then echo 由systemd管理跳过自锁 exit 0 fi当然这不算通用方案但确实现实里有人这么处理。我更推荐的还是让systemd自己管理单实例直接在service文件中加Typeexec或Typenotify配合StartLimitIntervalSec、StartLimitBurst等方式控制服务重启行为。脚本层的锁只作为最后一道防线。5. 常见坑点与排查实录5.1 用了flock还是重入检查你是不是加锁了子进程我调试过的最常见案发现场是这样的脚本明明加了flock结果还是起了两个实例在后头跑。查了半天才发现业务逻辑里调用了bash run_task.sh或者nohup python worker.py 这些子进程自己不带锁于是父进程虽然被锁住了但是子进程在后台独立跑重复照旧。所以我想强调一点flock锁的粒度是“进程实例”不是“业务逻辑”。如果你的脚本会派生子进程尤其是后台进程一定要保证子进程在业务逻辑上也加了防重入。否则你防的只是父进程重复挡不住子进程叠罗汉。实际操作中最好的习惯是把整个业务逻辑抽成一个函数或者一个单独的脚本所有外层调用都指向同一个入口锁加在这个统一入口上。5.2 脚本软链接和调用方式会骗过锁其实不会但会骗过日志锁文件方案本身和脚本路径无关按说软链接不影响。但我遇到过一种场景同一个脚本通过软链接产生两个名字两个不同的cron任务分别调用它们用的锁文件却是同一个结果第二个任务永远在退出。这种情况的本质是锁名的设计太粗粒度。两个任务虽然在执行同一段代码但语义上可能是不同业务不应该共用一个锁。解决方法是根据业务来区分锁名不要按脚本名来。锁名应该是任务的身份标识什么业务就用什么锁名。举个例子LOCK_FILE/tmp/$(basename $0).lock # 按脚本名 LOCK_FILE/tmp/$TASK_NAME.lock # 按业务名我后来的习惯是在脚本头部定义一个明确的锁名常量而不是从路径动态生成。代码可读性和可维护性都会更好。5.3 跨用户运行时锁文件权限惹的祸在cron里用root跑了一次脚本锁文件是root创建的放到了/tmp/xxx.lock。后来又用普通用户手动执行同一个脚本结果flock对锁文件没有写权限直接报错。这个不算大坑但排查起来会卡住新手。解决方案有两个。第一个是选择共享目录固定权限比如用/var/lock/创建锁文件时touch并chmod 666。第二个是坚持“谁的脚本用谁的锁目录”不同用户使用不同的锁目录。/tmp其实不建议放跨用户共享的锁文件因为/tmp默认权限是1777但文件本身的属主权限会限制其他用户打开。最稳妥的写法是锁文件路径默认放在/var/lock/下创建后统一设置权限0644脚本里获取锁之前先确保文件存在。当你用普通用户fork出一个需要与其他用户共享的任务时再用/var/lockchmod 777的方式。5.4 flock与NFS文件系统的那些事如果你的脚本运行在NFS挂载的目录上并且锁文件也放在NFS上这里有一个要注意的点。传统的flockPOSIX记录锁在NFS上并不总是可靠因为NFS协议对flock的支持各发行版不一样。常见的现象是两个节点上同时跑同一个脚本锁却不起作用照样重入。正确做法是锁文件放到本地文件系统业务数据放NFS。比如锁放/var/lock/数据放/mnt/nfs/data/。这样锁的可靠性由本地内核保证不依赖NFS的锁语义。如果你的业务必须跨节点防重入那flock就不够用了需要用到分布式锁。那些情况已经超出本文讨论范围这里就不展开。5.5 实战排查流程5分钟定位脚本重复执行最后给一套手把手的排查流程你在现场照着走就行。第一步确认同时在跑哪些相关进程。用这个命令看到所有与任务相关的进程ps -ef | grep -E task_name|script_name | grep -v grep第二步确认锁文件被谁占用fuser -v /tmp/task.lock如果没有输出说明锁文件虽然存在但没进程占用属于残留锁。如果有进程占用看到的是PID和用户。第三步判断锁文件创建时间与实际进程启动时间的对应关系stat /tmp/task.lock ps -p PID -o lstart如果锁文件时间在前、进程启动时间在后说明进程正常持有锁。如果锁文件时间在后、进程时间在前说明锁被后来的进程重新创建可能存在两个实例。第四步根据排查结果选择处理方法。残留锁直接删除有进程占用则先查进程状态再决定强杀还是等待。这套流程是我日常排查问题的路线基本不用花太长时间就能定位到底哪一层出了问题。6. 我踩过几次坑之后的固定习惯写到最后分享几个我现在写脚本时定下来的固定习惯谈不上标准答案但确实帮我省了很多事。第一个习惯锁文件名永远不用$$或随机数直接用业务名称常量。因为锁名的可读性和唯一性比“随机性”重要得多。第二个习惯flock永远配合exec使用让锁跟着当前进程走而不是锁一条子命令。exec 9${LOCK_FILE}这行代码在模板里看着不起眼实际是最关键的一步。第三个习惯所有脚本的锁文件统一放在一个目录我用的是/var/lock/下按业务名建子目录配合cron和systemd都很自然。第四个习惯也是我觉得最值得说的不管用什么方案我都会在脚本开头打一行日志记录PID、锁文件路径、加锁结果。真的加锁成功还是失败、哪个PID成功加锁这些信息一旦写进日志文件后续排查重复执行问题能节省大量时间。LOG_FILE/var/log/task_lock.log echo [$(date %F %T)] PID$$ 尝试加锁 ${LOCK_FILE} ${LOG_FILE}别小看这行日志。等到哪天真出了线上问题你敢说你能从一堆进程里准确分清谁先谁后、谁锁谁没锁有日志在几分钟就定位完没日志可能要在机房蹲半天。这算是反复踩坑后形成的肌肉记忆吧。回到主题上Shell脚本防重复执行这件事起步门槛并不高但想要做得稳、应对各种特殊场景确实需要花点心思。我推荐的第一选择就是flock加exec组合它是我目前用下来最省心、最可靠、最不依赖运行环境的方案。设备老化测试脚本、cron定时任务、备份脚本、同步脚本凡是需要单实例运行的都可以直接用上面给你的模板改个锁名就上线。万一遇到flock都没法解决问题的情况再回头检查一下是不是锁粒度不对、跨用户权限不对、或者文件系统不支持按第5章的排查流程一步步过一遍基本都能有个结论。