当 PHP 处理一个不确定运行时间的工作的时候,很可能会发生第一个还没完成,下一个就已经开始的情况。例如,我们运行一个定时发送邮件任务,如果需要发送的邮件数量超出了预期,就会导致两个不同的 PHP 进程在同时运行,可能发生异常情况。
使用 PHP 内部函数
有朋友回复告诉我,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);
其他使用范例请参考官方文档
同样推荐您阅读下面的小节来增强代码健壮性
问题分析和解决
我们以定时发送邮件这个小功能为例子,来思考一下真实场景里可能会出现的问题
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
文件锁的权限。
网站用户一般为apache
或www
,命令行调用则是设置计划任务的用户。
如果是命令行脚本的模式,Linux 环境中只需设置 cron
* * * * * /path/to/php /path/to/cron.php
优点
- 结构简单,易于理解
- 系统资源需求低
- 不依赖 Redis、RDS 等队列系统
- 不依赖特定系统,系统只提供计划任务
缺点
- 需要进行 IO 读写文件锁
- 不适合集群部署
- 无法解决系统 kill 进程的问题
- 意外上锁后无法自己解锁,需要人工删除锁文件
适用场景
- 资源紧缺的 VPS,路由器等
- 需要方便在不同平台部署
- 单脚本或简单的网站