如何避免 Cronjob 重复运行

2022-08-23 17:37:03 浏览数 (1)

Cronjob使用中有很多问题需要注意,前段时间写了一篇文章《为什么 Cronjob 不执行》,里面谈到了各种会导致cronjob不执行的因素和解决方案,而本文就cronjob重复运行的场景,对技术手段、技术方案、具体代码和相互优劣展开详细讲解。

引子

之前写过一篇文章《Ctdb Rados(二):多场景断网高可用》,文中提到支持秒级的定时任务的方法,因为cronjob本身最小只支持分钟级别的定时任务,所以笔者在cronjob定时脚本中通过for循环来达到秒级定时的目的。

然而这种定时间隔很短的任务是很容易出现重复运行的问题的。正常情况下脚本执行时间是很短的,但是一旦遇到IO阻塞等问题,会出现多个任务同时运行的情况,这种情况往往不是我们所期望的,可能会导致意想不到的问题。

即使不是秒级的定时任务,只要任务执行时间超过定时间隔都会出现重复运行的问题,比如每分钟运行的定时任务,而其执行时间需要三分钟等等

例子如下:

代码语言:javascript复制
$ ps -elf | grep forever
4 S vagrant   4095  4094  0  80   0 -  1111 wait   21:59 ?        00:00:00 /bin/sh -c /var/tmp/forever.sh
0 S vagrant   4096  4095  0  80   0 -  2779 wait   21:59 ?        00:00:00 /bin/bash /var/tmp/forever.sh
4 S vagrant   4100  4099  0  80   0 -  1111 wait   22:00 ?        00:00:00 /bin/sh -c /var/tmp/forever.sh
0 S vagrant   4101  4100  0  80   0 -  2779 wait   22:00 ?        00:00:00 /bin/bash /var/tmp/forever.sh
4 S vagrant   4130  4129  0  80   0 -  1111 wait   22:01 ?        00:00:00 /bin/sh -c /var/tmp/forever.sh
0 S vagrant   4131  4130  0  80   0 -  2779 wait   22:01 ?        00:00:00 /bin/bash /var/tmp/forever.sh
4 S vagrant   4135  4134  0  80   0 -  1111 wait   22:02 ?        00:00:00 /bin/sh -c /var/tmp/forever.sh
0 S vagrant   4136  4135  0  80   0 -  2779 wait   22:02 ?        00:00:00 /bin/bash /var/tmp/forever.sh

解决方案

方案1:进程数

这是笔者第一时间自己想的方式,通过进程数来判断当前定时脚本同时执行的数量,比如执行的脚本名为/opt/test.sh,当有一个任务在运行的时候:

代码语言:javascript复制
[root@tony ~]# ps -ef | grep /opt/test.sh
root      1107 25880  0 23:26 pts/0    00:00:00 /usr/bin/bash /opt/test.sh
root      1305  1175  0 23:27 pts/5    00:00:00 grep --color=auto /opt/test.sh

此时通过ps -ef | grep /opt/test.sh | wc -l得到的数量应该是2,如果定时间隔完毕后又刷新了一轮,总进程数则会变成3

所以我们可以在/opt/test.sh中加入进程数的判断,如果进程数大于2,就说明存在已有任务在运行,此时应该退出执行

代码语言:javascript复制
count=$(ps -ef | grep /opt/test.sh | wc -l)
if [ $count -gt 2 ]; then
    echo "Exist job running!"
    exit 1
fi
do something

但是事与愿违,当我们在/opt/test.sh中通过ps命令获取定时任务运行数量的时候发现,如果只存在当前的任务运行时,得到的进程数是3,如果有其他一个已在运行,则进程数是4,以此类推。这是为什么呢?

经过一番研究发现,当只存在当前任务运行时,如果脚本里面是直接运行ps命令,得到的进程数是2,如下所示:

代码语言:javascript复制
ps -ef | grep /opt/test.sh | wc -l

不难看出这是$()的原因,它在shell中起了一个子shell,所以在子shell执行ps的同时多了一个当前脚本任务运行的进程,所以比正常进程数多1,所以上面代码我们需要改为:

代码语言:javascript复制
count=$(ps -ef | grep /opt/test.sh | wc -l)
if [ $count -gt 3 ]; then
    echo "Exist job running!"
    exit 1
fi
do something

方案2:普通文件锁

可以通过一个文件来标识当前是否存在任务在运行,具体做法为当运行任务时,先检查是否存在文件锁,如果存在则表示上个任务还没有运行结束,则退出;如果不存在文件锁,则新创建一个文件锁,然后执行任务,最后执行完毕后删除文件锁。

具体代码如下:

代码语言:javascript复制
file_lock=/opt/test.lock
if [ -f file_lock ]; then
    echo "Exist job running!"
    exit 1
fi
touch file_lock
do something
rm -f file_lock

方案3:进程号文件锁

所谓进程号文件锁,相比于方案2的普通文件锁不同的地方就是会把当前运行任务对应的进程号写入锁文件中,其优势在于除了可以通过检查文件是否存在来判断是否存在已经运行的任务,还可以再通过锁文件里面的进程号来做第二次确认。

也许有人会问这个二次确认有啥用?你还别说,这个还真有用,很多时候进程意外终止或者被手动杀掉后,文件锁依然存在,那么使用普通文件锁的结果就是其实并没有正在运行的任务,但是由于存在文件锁,之后所有的任务都不会再运行。而进程号文件锁则可以在文件锁判断之外,再对锁文件中的进程号进行判断是否还在运行,具体代码如下:

代码语言:javascript复制
#!/bin/bash

PIDFILE=/opt/test.pid
if [ -f $PIDFILE ]
then
  PID=$(cat $PIDFILE)
  ps -p $PID > /dev/null 2>&1
  if [ $? -eq 0 ]
  then
    echo "Exist job running!"
    exit 1
  else
    echo $$ > $PIDFILE
    if [ $? -ne 0 ]
    then
      echo "Could not create PID file!"
      exit 1
    fi
  fi
else
  echo $$ > $PIDFILE
  if [ $? -ne 0 ]
  then
    echo "Could not create PID file!"
    exit 1
  fi
fi

do something
rm $PIDFILE

虽然此方案看起来很完美,但是还是有一个场景没有考虑到,那就是如果正在运行任务的进程被kill掉,然后另一个进程使用了和被kill进程相同的pid,这样也会导致其实任务并没有在运行,由于存在锁文件和对应进程号的进程在运行,之后所有的任务不再运行。虽然这种场景很极端,但是也是有可能出现的,不过没关系,下面的方案会帮你解决这个问题。

方案4:flock 锁

linux flock锁有区别于一般的锁,它不仅仅是检查文件是否存在,它会一直存在直到进程结束,所以可以直接地知道进程是否真的执行结束了。

格式:

代码语言:javascript复制
flock [-sxun][-w #] fd#
flock [-sxon][-w #] file [-c] command

选项:

代码语言:javascript复制
 -s, --shared:    获得一个共享锁 
 -x, --exclusive: 获得一个独占锁 
 -u, --unlock:    移除一个锁,脚本执行完会自动丢弃锁 
 -n, --nonblock:  如果没有立即获得锁,直接失败而不是等待 
 -w, --timeout:   如果没有立即获得锁,等待指定时间 
 -o, --close:     在运行命令前关闭文件的描述符号。用于如果命令产生子进程时会不受锁的管控 
 -c, --command:   在shell中运行一个单独的命令 
 -h, --help       显示帮助 
 -V, --version:   显示版本 

锁类型:

代码语言:javascript复制
共享锁:多个进程可以使用同一把锁,常被用作读共享锁
独占锁:同时只允许一个进程使用,又称排他锁,写锁。

这里由于我们只允许同时存在一个任务运行,所以选择独占锁,然后需要在脚本执行完丢弃锁:

代码语言:javascript复制
* * * * *  flock -xn /opt/test.lock -c /opt/test.sh

方案5:solo 程序

Solo是一个Perl脚本,它的工作原理与flock类似,但它并不依赖于锁文件,因为Solo程序是通过绑定端口来实现。

代码语言:javascript复制
$ ./solo -port=6000 /opt/test.sh &
[1] 7503
$ ./solo -port=6000 /opt/test.sh
solo(6000): Address already in use

执行solo时,将绑定指定的端口并执行后面指定的命令。一旦命令完成,就会释放端口,允许任务的下一个调用正常执行。

solo的优势在于没有人能够通过删除一个文件并意外地导致任务重复运行。即使使用flock命令,如果锁文件被删除,也可以启动第二个作业。由于solo绑定了一个端口,所以不可能出现这种情况。

总结

上面提到了五种方案,第一种方案略显粗糙,但是缺陷相对来说较少;第二种方案存在锁文件被意外删除或者进程被kill的风险;第三种方案存在锁文件被意外删除和新进程占用相同进程号的问题;第四种方案还是存在意外删除锁文件的问题;第五种方案则不需要担心锁文件被删除导致任务重复运行的问题。

目前看起来第五种方案是最优的,不存在缺陷。不过还是得看具体场景,笔者认为第三种、第四种、第五种方案都是有可取之处的,大家还是根据各自的场景选择最适合自己的方案吧。

0 人点赞