ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Shell脚本防重复执行:PID文件、flock与pgrep方案详解

Shell脚本防重复执行:PID文件、flock与pgrep方案详解 1. 为什么脚本会重复执行几个真实场景先聊一个我踩过多次的坑。Shell脚本跑着跑着就出乱子查了半天发现是同一份脚本被起了好几个进程数据写到一半又被另一个实例覆盖日志错乱、任务重复触发轻则浪费CPU重则把生产环境的文件搞坏。这个问题在技术圈里很常见核心就一句话脚本没有做执行前检测导致重复执行。我最早遇到这问题是在一台部署了数据同步任务的服务器上。crontab里写了每分钟跑一次同步脚本结果某次网络抖动让同步耗时拉长到了两分钟第二个cron实例又起来了两个进程同时读写同一个临时文件直接把中间件搞崩了。从那以后我做任何脚本第一件事就是加防重复执行机制这个习惯帮我省下了无数找bug的时间。哪些场景最容易触发重复执行我总结下来主要有三类。第一类是定时任务叠加cron的调度间隔小于脚本实际运行时长或者某次执行意外阻塞导致下一个调度点提前到来这是最常见的翻车方式。第二类是手动执行和自动化冲突人手工跑了一遍忘了关监控系统或CI流水线又自动触发了一遍两边互不知情。第三类是脚本自身逻辑缺陷比如内部循环递归调用、后台任务在退出前没有正确等待导致残留子进程还在运行。重复执行带来的危害远不止多花了点CPU。最典型的包括端口被重复绑定导致服务启动失败数据库被并发写入产生脏数据日志文件被多个进程同时追加出现错乱临时目录被清理后另一个进程还在写导致崩溃以及最隐蔽的——两个进程交替修改同一个配置文件每次启动都会把对方的状态覆盖掉。这些问题排查起来极其痛苦因为报错往往不是有重复进程而是各种莫名其妙的运行时错误。所以这个检测机制要解决的核心问题很明确**在脚本真正进入业务逻辑之前先确认当前环境里没有自己的另一个实例在跑。**如果有就直接退出并给出提示绝不继续执行。2. 方案一PID文件检测法2.1 核心原理与基础实现PID文件法是最直观的思路程序启动时把自己的进程号写入一个固定路径的pid文件退出时再删掉。下次启动时先检查这个文件是否存在、文件里的PID是否还在运行如果还在运行就说明已经有实例在跑直接退出。基础代码长这样#!/bin/bash PID_FILE/tmp/my_script.pid # 检查 PID 文件是否存在 if [ -f $PID_FILE ]; then OLD_PID$(cat $PID_FILE 2/dev/null) # 检查该进程是否存在 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 trap rm -f $PID_FILE EXIT # 业务逻辑 echo 开始执行业务任务... # ... 真实业务代码 # 脚本退出时 trap 会自动删除 PID 文件这里的kill -0非常关键它不是真的向进程发送终止信号而是检查这个PID是否还存在、是否有权限访问它。进程活着就返回0进程死了就返回非0。这种检查方式比ps -p要轻量得多几乎是零开销。2.2 需要留神的边界问题PID文件法看起来简单但实际用起来有几个坑。**第一个坑是PID复用问题。**Linux的PID是循环编号的旧进程结束一段时间后它的PID可能被新进程复用。如果脚本异常退出没来得及删除pid文件下次启动时恰巧某个无关进程占用了那个PID脚本就会误判为已有实例在运行然后拒绝启动。解决办法是额外记录启动时间或者通过/proc/$PID/cmdline比对进程名称更严谨的做法是读取进程的启动时间戳进行比对。如果不需要跨天级部署的强一致性简单的PID时间判断就够用了。第二个坑是脚本被kill -9强杀时trap里的清理代码不会执行。trap rm -f $PID_FILE EXIT只对正常退出或普通信号生效SIGKILL信号是无论如何都拦不住的。这会导致PID文件残留。所以代码里每次启动都做了残留检测发现文件里的PID已经不存在时主动清理这样就能自愈。#!/bin/bash PID_FILE/tmp/my_script.pid # 改进版记录进程启动时间防止 PID 复用误判 if [ -f $PID_FILE ]; then read OLD_PID OLD_START $PID_FILE if [ -d /proc/$OLD_PID ]; then # 获取该进程的实际启动时间jiffies 值 NEW_START$(awk {print $22} /proc/$OLD_PID/stat 2/dev/null) if [ -n $NEW_START ] [ $NEW_START $OLD_START ]; then echo 检测到同实例进程 (PID: $OLD_PID)已存在退出 exit 1 fi fi rm -f $PID_FILE fi echo $$ $(awk {print $22} /proc/$$/stat) $PID_FILE trap rm -f $PID_FILE EXIT这个改进版借鉴了systemd的pid检测逻辑把进程启动时的jiffies值也记录下来启动时比对/proc/$PID/stat里的第22个字段。如果PID存在但启动时间不同说明PID被复用了可以安全地清除旧PID文件。**第三个坑是并发启动时的竞争条件。**假设两个脚本实例在同一毫秒内执行[ -f $PID_FILE ]两个都发现文件不存在然后都写入自己的PID最终两个进程都进入业务逻辑防重复机制形同虚设。要彻底解决并发竞争需要用到文件锁机制这就引入了第二种方案。3. 方案二flock文件锁3.1 flock为什么能解决竞争问题flock是Linux提供的文件锁工具核心优势在于原子性flock通过open()系统调用和锁机制确保检测锁是否存在加锁是一个不可分割的操作不会出现PID检测法里的窗口期。举个例子你把锁文件想象成洗手间的门锁。PID文件法相当于先敲门问里面有人吗没人就推门进去。但两个人都同时敲门、同时听到没人、同时推门就可能撞在一起。flock相当于门上的旋钮锁不管多少人同时伸手去拧最终只有一个能拧开并锁上其他人只能等。flock的常用形式有两种一种是作为命令包裹整个脚本块另一种是在脚本内部调用flock并操作文件描述符。我推荐第二种因为能更精细控制锁的粒度和持续时间。3.2 flock参数详解与标准代码模板flock命令最核心的参数组合是-n和-x。-n表示非阻塞模式拿不到锁就立即返回失败而不是傻等-x表示独占锁是互斥行为区别于-s共享锁多个进程可以同时持有。组合起来就是拿不到锁就退出。标准写法#!/bin/bash LOCK_FILE/tmp/my_script.lock # 打开锁文件并尝试获取独占锁 exec 9$LOCK_FILE if ! flock -n -x 9; then echo 另一个脚本实例正在运行退出 exit 1 fi # 给文件描述符 9 设置自动解锁 trap flock -u 9; rm -f $LOCK_FILE EXIT # 业务逻辑 echo 成功获取锁开始执行任务 # ... 业务代码这里用exec 9$LOCK_FILE把锁文件以可写方式打开并绑定到文件描述符9上。后面flock -n -x 9就是对这个文件描述符加独占锁。为什么用9而不是1、2因为0、1、2分别是标准输入、标准输出、标准错误已经被系统占用了避开它们能防止业务代码里的输出重定向把锁描述符搞乱。trap flock -u 9; rm -f $LOCK_FILE EXIT的作用是在脚本退出时解锁并删除锁文件。但这里有个细节值得注意其实删不删锁文件对flock机制本身没影响。flock的锁是挂在文件描述符上的进程退出后内核会自动释放锁锁文件本身只是个载体残留也不影响下次加锁。删掉它纯粹是为了保持/tmp目录整洁省得看着碍眼。3.3 flock的隐蔽坑位**第一锁语义只对使用flock的进程生效。**如果另一个进程不使用flock而直接用普通方式打开同一个文件它完全不会被锁拦住。但这也意味着你可以把任意一个互斥文件比如某个配置文件、某个socket文件作为锁对象把业务代码和加锁逻辑天然绑定在一起。**第二NFS文件系统上的flock行为不可靠。**老版本内核上NFS对flock的支持有很多兼容性问题跨服务器共享存储时可能出现锁丢失或锁不生效的现象。如果是生产级多机部署场景应该考虑外部协调服务那已经超出本文范围了。**第三锁文件的所在目录必须存在。**如果/tmp被别人清理了比如某些系统定时任务会清空/tmp目录exec 9$LOCK_FILE可能会因为目录不存在而失败。所以稳妥的做法是先确保目录存在或者把锁文件放在脚本同目录下。我个人习惯放在/var/run/下配合目录检查逻辑。4. 方案三pgrep进程名检测法4.1 基于pgrep的轻量方案第三种常见做法是用pgrep直接按进程名匹配脚本启动时检查系统里有多少个同名进程在跑。代码非常短#!/bin/bash # 统计当前脚本的进程数量匹配完整命令行 COUNT$(pgrep -f $0 | wc -l) # pgrep -f 会匹配整条命令行$0 即脚本自身路径 # 运行中的进程至少有 1 个即当前进程超过 1 个说明已有其他实例 if [ $COUNT -gt 1 ]; then echo 检测到已有实例在运行退出 exit 1 fi这里pgrep -f $0匹配的是完整的命令行字符串比只匹配进程名要准确得多。因为脚本进程的完整命令行通常就是脚本的路径不会有其他进程碰巧撞名。但是这种方案有一个明显的陷阱**pgrep的匹配逻辑会把当前正在执行pgrep检测的脚本本身也算进去。**代码里已经做了处理——如果进程数大于1就退出等于默认当前进程本身就是那个1。逻辑上说得通但前提是执行pgrep检测的父进程和业务进程是同一个进程。如果你是这么写的#!/bin/bash pgrep -f my_script.sh echo 已有实例 exit那问题就大了pgrep -f my_script.sh在执行时它匹配到的第一个进程恰恰就是当前这个脚本自身于是永远都会命中已有实例脚本永远跑不起来。网上很多新手写的版本就是栽在这个坑里。4.2 进程名检测的局限与适用场景进程名检测比较适合短生命周期脚本的快速防护比如手动运维时不小心多敲了一次回车。但它的局限性也很明显脚本路径稍微变化比如从./my_script.sh变成/opt/scripts/my_script.shpgrep -f可能匹配不到原有实例。系统中存在同名但不同功能的脚本时会误伤。进程名被/proc文件系统里的权限限制遮蔽比如检查别人的进程非root用户可能只能看到自己的进程。所以它不适合作为唯一防线更适合做辅助手段。比如flock拿不到锁时再用pgrep查一下是谁占着输出到日志里方便排查。这样既有强互斥又有可观测性。#!/bin/bash LOCK_FILE/var/run/my_script.lock exec 9$LOCK_FILE if ! flock -n -x 9; then echo 锁被占用当前相关进程 pgrep -af $0 exit 1 fi trap flock -u 9 EXIT # 正常业务pgrep -af会列出匹配进程的完整命令行对排查非常有用。5. 方案对比与选型建议先给一份对照表方便你根据自己的场景快速决策方案防并发竞争PID复用问题强杀残留实现复杂度适用场景PID文件弱有需额外处理有需自愈低简单脚本、单机任务flock文件锁强不涉及无内核自动释放低生产环境、定时任务pgrep进程名弱不涉及无极低快速手动防护、辅助排查实际项目中我最常用的组合是flock做主防pgrep做辅助追踪。flock解决了并发竞争和强杀残留问题pgrep在锁被占用时给出足够详细的进程信息用于定位。PID文件法也不是完全没用在一些不支持flock的旧系统上它依然是主流方案但要特别注意第2节说的三个坑。补充一点关于脚本异常退出后锁什么时候释放的理解。flock的锁是内核自动管理的无论脚本是正常退出、被CtrlC中断、还是被kill杀掉内核都会在进程结束时自动释放所有文件锁。这意味着锁永远不会变成僵尸锁这也是我强烈推荐flock的根本原因。相比之下PID文件的残留问题需要靠代码自愈总归多一层隐患。另外还有一个许多人忽略的选择维度锁文件放在哪里。放在/tmp下的风险是可能被系统清理机制删掉而且不同用户之间可能互相干扰A用户创建的文件B用户无法写入。放在/var/run/下面更规范但需要目录存在且有写权限。放在脚本同目录则适合那些一个项目一套脚本的场景目录随项目走不存在跨用户问题。我的建议单机任务优先/var/run/临时脚本用/tmp/即可确保目录存在就行。6. 常见问题与排查技巧实录6.1 用真实案例梳理高频问题问题1加了flock之后定时任务还重复执行。排查思路先确认crontab里是不是配置了两个不同的任务比如一个在/etc/cron.d/一个在/var/spool/cron/两边都指向了同一个脚本。我遇到过一例运维同学的crontab和部署脚本里的cron配置各写了一份完全没注意到。另外确认flock确实生效在脚本开头和业务逻辑开始处各打一条日志看两条日志的时间戳能否对上。问题2脚本加锁后手动执行报另一个实例在运行但用ps aux查不到进程。这种通常是PID文件残留而flock方案不应该出现这个问题。如果你用的是flock看看是不是锁文件所在的目录被删了然后脚本通过exec 9创建了一个新文件但锁其实是加在新文件上的。另一个可能是别人的进程正好占用了你定义的那个锁文件名。查一下那个锁文件的属主和关联进程就行。问题3脚本在执行flock检测时卡住了不退出也没继续。这种是flock不带-n参数导致的。没有-nflock在锁被占用的默认行为是阻塞等待而不是立即失败。如果脚本在crontab里每分钟跑一次等锁的进程就会越积越多。排查方法ps aux | grep flock看有多少进程在等待然后杀掉多余的即可。根治方法是检查所有调用点确保用的是flock -n。问题4pgrep方案误判永远有实例在运行。大概率就是第4节说的自匹配问题。pgrep -f $0在执行时匹配到了当前脚本自身。验证方法很简单在判断之前先echo一下pgrep -f $0的输出看看数量是不是始终不小于1。如果是改成使用$$排除当前进程或者换用flock方案。6.2 我自己沉淀的检查清单结合多年运维脚本的经验我给自己的每个脚本都定了几个硬性要求**失败路径也必须解锁。**trap的EXIT钩子一定要覆盖所有退出路径包括最后一行正常退出、中途exit 1退出、被信号打断退出。如果trap本身被后续代码覆盖了锁就失效了。**日志里记录锁占用的证据。**拿不到锁就挂掉的脚本下次排查时最好能输出当前持有锁的进程是哪个、执行了什么命令、从什么时候开始的。这些信息写在日志里能省去大量猜测时间。**并发启动时有一个可预期的顺序。**如果两个实例同时启动理论上只有一个能进去另一个应该快速退出并给出明确提示。绝不能出现两个都等一等再试的逻辑那会产生意想不到的级联效应。**脚本开头要有明确的状态回执。**启动成功打一条实例启动PIDxxx退出时打一条实例结束耗时xx秒。这样在crontab日志里能对每次执行全周期有个清晰认知。**不要过度设计。**有些场景其实根本不需要防重复比如幂等性做得很好的脚本多跑几次也无所谓。把防重复机制当做默认配置但不要因为加了锁就忽视脚本本身的幂等性设计。两者是互补关系不是替代关系。6.3 一个小众但很实用的技巧对于需要单实例运行但允许新请求排队等待的场景flock默认的阻塞行为反而可能是你想要的。比如一个数据缓存构建脚本你希望同一时间只有一个在跑但新触发的请求可以等旧实例结束后自动接力运行。这时候去掉-n参数就行了exec 9$LOCK_FILE flock -x 9 # 没有 -n锁被占用时会一直等队首释放后自动获得锁这种模式我做CI构建脚本时经常用效果很好。不过要留意等待时间不能无限长可以配一个超时机制防止某次构建卡死导致后续任务全部排队到天荒地老。7. 在实际项目里沉淀的体会写了这么多年Shell脚本这类防重复执行的需求出现的频率比我当初预想的高太多了。很多新手写脚本的第一步是直接堆业务功能代码等跑出问题来才回头补救。我的建议反过来先想清楚这个脚本会不会被并发触发、并发触发了会不会出问题如果会第一时间就把防重复机制写进去。这比事后补要省力得多而且改动的复杂度几乎可以忽略。再分享一个小技巧给脚本统一封装一个公共的单实例启动函数放进一个公共脚本库所有业务脚本都从这个函数启动。这样整个脚本库的防重复逻辑就统一起来了不用每个脚本重复写。我在自己维护的脚本仓库里就是这么做的后来有几台新服务器要部署直接复制公共库过去几十个脚本零改动就全都带上了防重复能力。最后再补充一个被很多人忽略的点防重复机制本身也会成为故障点。如果锁文件被误删、锁目录权限不对、flock命令不存在比如最小化容器里没有安装util-linux包脚本会出现打开锁文件失败之类的报错。所以生产环境里一定要在脚本开头给flock做好依赖检测发现问题时给出明确的提示而不是让脚本莫名其妙地卡住或挂掉。这些看起来细碎的东西往往才是真正决定一个脚本靠不靠谱的关键。
返回列表