PHP 使用文件锁 避免同时运行一个脚本


当 PHP 处理一个不确定运行时间的工作的时候,很可能会发生第一个还没完成,下一个就已经开始的情况。例如,我们运行一个定时发送邮件任务,如果需要发送的邮件数量超出了预期,就会导致两个不同的 PHP 进程在同时运行,可能发生异常情况。

这篇文章里不使用 Redis、RDS 等系统,因为它们不适用于一些功能简单的独立脚本。如果你使用了诸如 Laravel 之类的框架,使用它们是更优解。

同步发布在我的博客 爆米花手册 中,欢迎来踩一踩

使用 PHP 内部函数

2021/02/28 更新

看到了 @heguangyu5 朋友的回复,非常感谢!

PHP 有内部函数已经实现了这个功能,既然如此请优先使用 PHP 内部函数

PHP 的内部函数 flock() 可以实现共享锁或者独占锁,同时也支持阻塞模式和非阻塞模式来运行

以下是一个简单的例子

<?php
$fp = fopen('/tmp/lock.txt', 'r+');

// 使用独占锁和非阻塞模式运行
if(!flock($fp, LOCK_EX | LOCK_NB)) {
    echo 'Unable to obtain lock';
    exit(-1);
}

/* Your code */

fclose($fp);

更多使用范例请参考 PHP:flock 官方手册

同时推荐您继续阅读下面的小节来增强代码健壮性

问题分析和解决

我们以定时发送邮件这个小功能为例子,来思考一下真实场景里可能会出现的问题

1. 同时有多个不同进程在发送邮件

PHP 程序在处理完发送邮件这个动作后会把邮箱从待发送列表中删除。某个时段的用户量大增,同时有多个进程先后被 Cron 调用来处理邮件,因为发送邮件和修改列表之间有时间差,导致部分用户收到了多封相同的邮件,浪费了钱,用户体验也不好。

解决方法:

使用文件锁,同一时间只允许一个进程运行。

<?php

// 设置文件锁的路径和文件名
$lock = __DIR__ . '/cron.lock';

// 假如文件锁存在,退出
if(file_exists($lock)) {
    exit('Other cron task is running');
}

// 创建文件锁
touch($lock);

// 处理发送邮件的逻辑

// 运行完毕,释放文件锁
unlink($lock);

2. 邮件程序抛出的异常未处理

我们发现有个用户蜜汁操作,绕过了各种过滤措施设置了一个没办法解析的邮箱地址,当系统尝试给这个邮箱发送邮件时必报错,导致 PHP 异常退出。退出就算了,退出的时候还没释放文件锁,后来的定时任务都没办法处理邮件,只能人工介入处理。

解决方法:

使用 try , catch , finally 三件套,保证不管是正常、异常,都能释放文件锁。

<?php

$lock = __DIR__ . '/cron.lock';

if(file_exists($lock)) {
    exit('Other cron task is running');
}

try{
    touch($lock);

    // 处理发送邮件的逻辑

}catch (Exception $exception){

    // 回显报错信息
    echo $exception->getMessage();

}finally{

    unlink($lock);

}

3. 邮件服务器宕机,进程僵死

由于远程的服务器出问题,PHP 不断尝试发送未果,陷入死循环,我们需要在 PHP 运行时间远超预期的情况下,强行终止 PHP 进程,避免服务器被死循环拖累了性能。

解决方法:

给设置 PHP 超时时间,当运行时间超出我们设定的时间后,进程会被强行终止。只需要在代码开头调用 set_time_limit($time) 即可,$time 是超时的秒数,当 $time 的值为 0 时,进程永不超时。

// 设置 PHP 最大运行时间为600秒
set_time_limit(600);

4. 出现致命错误

这个错误是由上一个问题引出的,我们发现当脚本超时后,会发生一个致命错误(Fatal error),由于致命错误不是异常(Exception),不能被捕获,当PHP超时或者其他原因发生了致命错误,很可能发生没有释放文件锁的情况。

解决方法:

通过 register_shutdown_function() 注册 PHP 结束时运行的函数,当 PHP 进程结束之前会调用注册的函数。这个方法只能处理来自 PHP 进程内部的退出,不能处理系统结束进程的情况,所以手动 kill 掉进程还是不行的。

<?php

$lock = __DIR__ . '/cron.lock';

// 设置 PHP 超时时间(秒)
set_time_limit(600);

// 注册 PHP 结束时运行的函数
register_shutdown_function('unlock');

if(file_exists($lock)) {
    exit('Other cron task is running');
}

touch($lock);

// 发送邮件的逻辑

// 释放文件锁的函数
function unlock(){
    unlink($lock);
}

总结

我们已经解决了上面这一大堆的坑,这个 PHP 脚本总算是可堪一用,不会日常爆炸了。

使用时我们需要给 PHP 进程用户读写 cron.lock 文件锁的权限。

网站用户一般为apachewww,命令行调用则是设置计划任务的用户。

如果是命令行脚本的模式,Linux 环境中只需设置 cron

* * * * * /path/to/php /path/to/cron.php

优点

  • 结构简单,易于理解
  • 系统资源需求低
  • 不依赖 Redis、RDS 等队列系统
  • 不依赖特定系统,系统只提供计划任务

缺点

  • 需要进行 IO 读写文件锁
  • 不适合集群部署
  • 无法解决系统 kill 进程的问题
  • 意外上锁后无法自己解锁,需要人工删除锁文件

适用场景

  • 资源紧缺的 VPS,路由器等
  • 需要方便在不同平台部署
  • 单脚本或简单的网站
本作品采用《CC 协议》,转载必须注明作者和本文链接
讨论数量: 2

各位,文件锁不是这样用的。正确的用法是:

$lockFp = fopen('/path/to/cron.lock', 'a+');
if (!flock($lockFp, LOCK_EX | LOCK_NB)) {
    fclose($lockFp);
    echo "another script is running\n";
    exit(1);
}
// continue

@see www.php.net/manual/en/function.flo...

3年前 评论

@heguangyu5 谢谢,已经更新了您提到的方法!这段时间准备考研,今天才看到 :+1:

3年前 评论

讨论应以学习和精进为目的。请勿发布不友善或者负能量的内容,与人为善,比聪明更重要!