我在一个有点大的 Web 应用程序上工作,后端主要是 PHP。代码中有几个地方我需要完成一些任务,但我不想让用户等待结果。例如,在创建新帐户时,我需要向他们发送一封欢迎电子邮件。但是当他们点击“完成注册”按钮时,我不想让他们等到电子邮件实际发送,我只想开始这个过程,并立即向用户返回一条消息。
到目前为止,在某些地方,我一直在使用 exec() 的感觉。基本上做这样的事情:
exec("doTask.php $arg1 $arg2 $arg3 >/dev/null 2>&1 &");
这似乎有效,但我想知道是否有更好的方法。我正在考虑编写一个在 MySQL 表中对任务进行排队的系统,以及一个单独的长时间运行的 PHP 脚本,该脚本每秒查询一次该表,并执行它找到的任何新任务。如果我需要的话,这还有一个好处,就是让我将来可以在几台工作机器之间分配任务。
我在重新发明轮子吗?有比 exec() hack 或 MySQL 队列更好的解决方案吗?
我使用了排队方法,它工作得很好,因为您可以推迟处理,直到您的服务器负载空闲,如果您可以轻松地划分“不紧急的任务”,那么您可以非常有效地管理您的负载。
自己动手并不太难,这里有一些其他的选择:
GearMan - 这个答案写于 2009 年,从那时起 GearMan 看起来很受欢迎,请参阅下面的评论。
如果您想要一个完整的开源消息队列,请使用 ActiveMQ。
ZeroMQ - 这是一个非常酷的套接字库,它可以轻松编写分布式代码,而不必过多担心套接字编程本身。您可以将它用于单个主机上的消息队列——您只需让您的 web 应用程序将某些内容推送到一个队列中,持续运行的控制台应用程序将在下一个合适的机会使用该队列
beanstalkd - 在写这个答案时才发现这个,但看起来很有趣
dropr 是一个基于 PHP 的消息队列项目,但自 2010 年 9 月以来一直没有得到积极维护
php-enqueue 是最近(2017 年)维护的围绕各种队列系统的包装器
最后,一篇关于使用 memcached 进行消息队列的博文
另一种可能更简单的方法是使用 ignore_user_abort - 一旦您将页面发送给用户,您就可以进行最终处理而不必担心提前终止,尽管这确实具有延长页面加载时间的效果用户视角。
当您只想执行一个或多个 HTTP 请求而无需等待响应时,也有一个简单的 PHP 解决方案。
在调用脚本中:
$socketcon = fsockopen($host, 80, $errno, $errstr, 10);
if($socketcon) {
$socketdata = "GET $remote_house/script.php?parameters=... HTTP 1.1\r\nHost: $host\r\nConnection: Close\r\n\r\n";
fwrite($socketcon, $socketdata);
fclose($socketcon);
}
// repeat this with different parameters as often as you like
在被调用的 script.php 中,您可以在第一行调用这些 PHP 函数:
ignore_user_abort(true);
set_time_limit(0);
这会导致脚本在 HTTP 连接关闭时继续运行而没有时间限制。
派生进程的另一种方法是通过 curl。您可以将内部任务设置为 Web 服务。例如:
http://domain/tasks/t1
http://domain/tasks/t2
然后在您的用户访问的脚本中调用该服务:
$service->addTask('t1', $data); // post data to URL via curl
您的服务可以使用 mysql 或任何您喜欢的方式跟踪任务队列:它全部包含在服务中,您的脚本只是使用 URL。如果需要(即易于扩展),这可以让您腾出时间将服务移动到另一台机器/服务器。
添加 http 授权或自定义授权方案(如 Amazon 的 Web 服务)可以让您打开任务以供其他人/服务使用(如果您愿意),您可以更进一步并在顶部添加监控服务以跟踪队列和任务状态。
http://domain/queue?task=t1
http://domain/queue?task=t2
http://domain/queue/t1/100931
它确实需要一些设置工作,但有很多好处。
如果只是提供昂贵任务的问题,在支持php-fpm的情况下,为什么不使用fastcgi_finish_request()
功能呢?
此函数将所有响应数据刷新到客户端并完成请求。这允许在不打开与客户端的连接的情况下执行耗时的任务。
您实际上并没有以这种方式使用异步性:
首先制作所有主要代码。执行 fastcgi_finish_request()。做所有重的东西。
再次需要 php-fpm。
我已将 Beanstalkd 用于一个项目,并计划再次使用。我发现它是运行异步进程的绝佳方式。
我用它做的几件事是:
图像调整大小 - 并且将轻负载队列传递给基于 CLI 的 PHP 脚本,调整大 (2mb+) 图像的大小工作得很好,但尝试在 mod_php 实例中调整相同图像的大小经常遇到内存空间问题(我将 PHP 进程限制为 32MB,而调整大小的时间不止这些)
近期检查 - beanstalkd 有可用的延迟(使此作业只能在 X 秒后运行) - 所以我可以在稍后的时间对事件进行 5 或 10 次检查
我编写了一个基于 Zend-Framework 的系统来解码一个“不错”的 url,例如,调整它会调用 QueueTask('/image/resize/filename/example.jpg')
的图像大小。 URL 首先被解码为一个数组(模块、控制器、动作、参数),然后转换为 JSON 以注入队列本身。
然后,一个长时间运行的 cli 脚本从队列中提取作业,运行它(通过 Zend_Router_Simple),如果需要,将信息放入 memcached 中,以便网站 PHP 在完成后根据需要获取。
我还提出的一个问题是 cli 脚本在重新启动之前只运行了 50 个循环,但如果它确实想按计划重新启动,它会立即这样做(通过 bash 脚本运行)。如果出现问题并且我执行了 exit(0)
(exit;
或 die();
的默认值),它将首先暂停几秒钟。
这是我为我的 Web 应用程序编写的一个简单类。它允许分叉 PHP 脚本和其他脚本。适用于 UNIX 和 Windows。
class BackgroundProcess {
static function open($exec, $cwd = null) {
if (!is_string($cwd)) {
$cwd = @getcwd();
}
@chdir($cwd);
if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$WshShell = new COM("WScript.Shell");
$WshShell->CurrentDirectory = str_replace('/', '\\', $cwd);
$WshShell->Run($exec, 0, false);
} else {
exec($exec . " > /dev/null 2>&1 &");
}
}
static function fork($phpScript, $phpExec = null) {
$cwd = dirname($phpScript);
@putenv("PHP_FORCECLI=true");
if (!is_string($phpExec) || !file_exists($phpExec)) {
if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$phpExec = str_replace('/', '\\', dirname(ini_get('extension_dir'))) . '\php.exe';
if (@file_exists($phpExec)) {
BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
} else {
$phpExec = exec("which php-cli");
if ($phpExec[0] != '/') {
$phpExec = exec("which php");
}
if ($phpExec[0] == '/') {
BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
}
} else {
if (strtoupper(substr(PHP_OS, 0, 3)) == 'WIN') {
$phpExec = str_replace('/', '\\', $phpExec);
}
BackgroundProcess::open(escapeshellarg($phpExec) . " " . escapeshellarg($phpScript), $cwd);
}
}
}
PHP 有多线程,只是默认情况下没有启用,有一个名为 pthreads 的扩展可以做到这一点。不过,您需要使用 ZTS 编译的 php。 (线程安全)链接:
更新:因为 PHP 7.2 并行扩展开始发挥作用
教程/示例
参考手册
这是我几年来一直使用的相同方法,但我没有看到或发现更好的方法。正如人们所说,PHP 是单线程的,因此您无能为力。
实际上,我为此添加了一个额外的级别,即获取和存储进程 ID。这允许我重定向到另一个页面并让用户坐在该页面上,使用 AJAX 检查进程是否完成(进程 ID 不再存在)。这对于脚本长度会导致浏览器超时的情况很有用,但用户需要等待该脚本完成才能进行下一步。 (在我的例子中,它正在处理带有类似 CSV 文件的大型 ZIP 文件,这些文件最多可向数据库添加 30 000 条记录,之后用户需要确认一些信息。)
我也使用了类似的过程来生成报告。我不确定我是否会对诸如电子邮件之类的东西使用“后台处理”,除非 SMTP 速度慢确实存在问题。相反,我可能会使用一个表作为队列,然后有一个每分钟运行一次的进程来发送队列中的电子邮件。您需要警惕两次发送电子邮件或其他类似问题。对于其他任务,我也会考虑类似的排队过程。
按照 rojoca 的建议使用 cURL 是个好主意。
这是一个例子。您可以在脚本在后台运行时监控 text.txt:
<?php
function doCurl($begin)
{
echo "Do curl<br />\n";
$url = 'http://'.$_SERVER['SERVER_NAME'].$_SERVER['REQUEST_URI'];
$url = preg_replace('/\?.*/', '', $url);
$url .= '?begin='.$begin;
echo 'URL: '.$url.'<br>';
$ch = curl_init();
curl_setopt($ch, CURLOPT_URL, $url);
curl_setopt($ch, CURLOPT_RETURNTRANSFER, true);
$result = curl_exec($ch);
echo 'Result: '.$result.'<br>';
curl_close($ch);
}
if (empty($_GET['begin'])) {
doCurl(1);
}
else {
while (ob_get_level())
ob_end_clean();
header('Connection: close');
ignore_user_abort();
ob_start();
echo 'Connection Closed';
$size = ob_get_length();
header("Content-Length: $size");
ob_end_flush();
flush();
$begin = $_GET['begin'];
$fp = fopen("text.txt", "w");
fprintf($fp, "begin: %d\n", $begin);
for ($i = 0; $i < 15; $i++) {
sleep(1);
fprintf($fp, "i: %d\n", $i);
}
fclose($fp);
if ($begin < 10)
doCurl($begin + 1);
}
?>
不幸的是,PHP 没有任何类型的本机线程功能。所以我认为在这种情况下你别无选择,只能使用某种自定义代码来做你想做的事。
如果你在网上搜索 PHP 线程的东西,有些人想出了在 PHP 上模拟线程的方法。
如果您在“感谢您注册”响应中设置 Content-Length HTTP 标头,则浏览器应在收到指定字节数后关闭连接。这会使服务器端进程运行(假设设置了 ignore_user_abort),因此它可以在不让最终用户等待的情况下完成工作。
当然,您需要在渲染标头之前计算响应内容的大小,但这对于短响应(将输出写入字符串、调用 strlen()、调用 header()、渲染字符串)非常容易。
这种方法的优点是不强制您管理“前端”队列,尽管您可能需要在后端做一些工作以防止竞争 HTTP 子进程相互踩踏,但这是您已经需要做的事情, 反正。
header('Content-Length: 3'); echo '1234'; sleep(5);
时,即使浏览器只需要 3 个字符,它仍然会等待 5 秒才能显示响应。我错过了什么?
phpinfo()
可以看出,数据似乎也是以非 gzip 格式发送的。我能想象的唯一另一件事是我需要首先达到最小缓冲区大小,例如 256 字节左右。
如果您不想要完整的 ActiveMQ,我建议您考虑 RabbitMQ。 RabbitMQ 是使用 AMQP standard 的轻量级消息传递。
我还建议查看 php-amqplib - 一个流行的 AMQP 客户端库,用于访问基于 AMQP 的消息代理。
我认为您应该尝试这种技术,它将有助于调用尽可能多的页面,所有页面将同时独立运行,而无需等待每个页面响应异步。
cornjobpage.php //主页
<?php
post_async("http://localhost/projectname/testpage.php", "Keywordname=testValue");
//post_async("http://localhost/projectname/testpage.php", "Keywordname=testValue2");
//post_async("http://localhost/projectname/otherpage.php", "Keywordname=anyValue");
//call as many as pages you like all pages will run at once independently without waiting for each page response as asynchronous.
?>
<?php
/*
* Executes a PHP page asynchronously so the current page does not have to wait for it to finish running.
*
*/
function post_async($url,$params)
{
$post_string = $params;
$parts=parse_url($url);
$fp = fsockopen($parts['host'],
isset($parts['port'])?$parts['port']:80,
$errno, $errstr, 30);
$out = "GET ".$parts['path']."?$post_string"." HTTP/1.1\r\n";//you can use POST instead of GET if you like
$out.= "Host: ".$parts['host']."\r\n";
$out.= "Content-Type: application/x-www-form-urlencoded\r\n";
$out.= "Content-Length: ".strlen($post_string)."\r\n";
$out.= "Connection: Close\r\n\r\n";
fwrite($fp, $out);
fclose($fp);
}
?>
测试页.php
<?
echo $_REQUEST["Keywordname"];//case1 Output > testValue
?>
PS:如果您想将 url 参数作为循环发送,请遵循以下答案:https://stackoverflow.com/a/41225209/6295712
使用 exec()
在服务器上生成新进程或使用 curl 直接在另一台服务器上生成新进程根本不能很好地扩展,如果我们选择 exec,您基本上是在用可以由其他非 Web 处理的长时间运行的进程填充您的服务器面对服务器,并且使用 curl 会占用另一台服务器,除非您构建某种负载平衡。
我在一些情况下使用过 Gearman,我发现它更适合这种用例。我可以使用单个作业队列服务器基本上处理所有需要由服务器完成的作业的排队并启动工作服务器,每个工作服务器都可以根据需要运行尽可能多的工作进程实例,并扩大数量根据需要工作服务器,并在不需要时将它们关闭。它还让我在需要时完全关闭工作进程并将作业排队,直到工作人员重新上线。
PHP 是一种单线程语言,因此除了使用 exec
或 popen
之外,没有其他官方方法可以使用它来启动异步进程。有一篇关于该 here 的博文。您对 MySQL 中的队列的想法也是一个好主意。
您在这里的具体要求是向用户发送电子邮件。我很好奇您为什么要尝试异步执行此操作,因为发送电子邮件是一项非常简单且快速的任务。我想如果您要发送大量电子邮件并且您的 ISP 因涉嫌垃圾邮件而阻止您,这可能是排队的一个原因,但除此之外,我想不出任何理由这样做。
不定期副业成功案例分享