PHP Fiber 实战:一个抓取脚本从 187 秒压到 9 秒的四轮改造

2026-10-07 0 886

去年接的一个小活。客户是做竞品监控的,需要每天早上把 200 个竞争对手的商品页抓一遍,比对价格和库存变化。这个脚本本来就存在,只是性能一直很糟——单次跑完要三分钟左右。放在凌晨跑不影响使用,客户也就没在意。

后来竞争对手的列表涨到了 500 家,抓取时间涨到七分半。到今年年初,列表已经到 900 家,脚本要跑将近十五分钟。客户找到我的时候说了一句话:”再这么搞下去,早上八点推送的通知得改到十点。”

这个问题的最佳答案肯定不是”上 Swoole”或者”接队列”。他们就是一个小团队,一个人维护这套系统,不想为了这一件事引入一个扩展依赖和一套进程管理。我最后用的是 PHP 8.1 起内置的 Fiber,配合 Revolt 事件循环——没有扩展依赖,只装了三个 composer 包。

整个改造分四轮,从 187 秒到 9 秒。这篇文章完整记录一下。

第一轮:看看原来的脚本长什么样

原脚本简化之后大概是这个样子:

<?php

$targets = json_decode(file_get_contents('targets.json'), true);
$results = [];

foreach ($targets as $target) {
    $start = microtime(true);

    $ch = curl_init($target['url']);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_USERAGENT      => 'Mozilla/5.0 Monitor bot',
    ]);

    $body = curl_exec($ch);
    $httpCode = curl_getinfo($ch, CURLINFO_HTTP_CODE);
    curl_close($ch);

    if ($httpCode === 200) {
        $results[] = [
            'target' => $target['name'],
            'price'  => extractPrice($body),
            'stock'  => extractStock($body),
            'cost'   => microtime(true) - $start,
        ];
    }
}

file_put_contents('results.json', json_encode($results));

典型的同步阻塞式抓取。每个 URL 依次请求,等上一个响应完了才开始下一个。单次请求平均 1.2 秒,200 个 URL 累计耗时大约 240 秒——但因为有些页面特别慢(超时的那个设置了 10 秒上限),实际总耗时约 187 秒。样本再大一点,这个耗时是线性增长的。

问题很明确:99% 的时间都在等网络。CPU 几乎没干什么活,就是坐在那儿等 IO 响应。这种场景天生适合并发——让多个请求同时在飞行中。

第二轮:curl_multi,最快的止痛药

PHP 里做并发 HTTP 的第一直觉是 curl_multi。它在一个进程里能同时管理多个 curl 句柄,通过 curl_multi_select 等待任意一个完成。

$mh = curl_multi_init();
$handles = [];

foreach ($targets as $i => $target) {
    $ch = curl_init($target['url']);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
        CURLOPT_FOLLOWLOCATION => true,
        CURLOPT_USERAGENT      => 'Mozilla/5.0 Monitor bot',
    ]);

    curl_multi_add_handle($mh, $ch);
    $handles[(int) $ch] = ['target' => $target, 'ch' => $ch];
}

$running = null;
do {
    $status = curl_multi_exec($mh, $running);

    if ($running > 0) {
        curl_multi_select($mh, 1.0);
    }

    // 检查哪些完成了
    while ($info = curl_multi_info_read($mh)) {
        $ch = $info['handle'];
        $id = (int) $ch;
        $body = curl_multi_getcontent($ch);
        // ... 存结果
        curl_multi_remove_handle($mh, $ch);
        unset($handles[$id]);
    }
} while ($running > 0);

curl_multi_close($mh);

改成 curl_multi 之后跑一遍,187 秒降到 24 秒。但马上有了新问题。

第一,边界不好控制。同时打开 500 个 socket,系统的文件描述符可能不够,防火墙也会拦掉一部分,服务端看到你这么多并发也可能触发限流。

第二,代码开始变得别扭了。你写的不再是”我要抓这个 URL”,而是”我要建一个 handle、加到 multi 里、轮询、取结果、清理”。业务逻辑被这套底层 API 的仪式感包裹了一层。

我加了并发上限控制解决了第一个问题:

$maxConcurrent = 50;
$runningTargets = [];
$queue = $targets;

while (!empty($queue) || !empty($runningTargets)) {
    // 从队列里拉任务,直到达到上限
    while (count($runningTargets) < $maxConcurrent && !empty($queue)) {
        $target = array_shift($queue);
        $ch = curl_init($target['url']);
        // ... 设置
        curl_multi_add_handle($mh, $ch);
        $runningTargets[(int) $ch] = $target;
    }

    // 等待
    curl_multi_exec($mh, $running);
    curl_multi_select($mh, 1.0);

    // 收获完成的任务
    while ($info = curl_multi_info_read($mh)) {
        // ...
        unset($runningTargets[(int) $info['handle']]);
        curl_multi_remove_handle($mh, $info['handle']);
    }
}

并发 50 的时候,500 个 URL 用了大约 19 秒。看起来不错,但这段代码已经不太像业务逻辑了——它是”调度逻辑”,一个手工控制的事件循环。每加一个业务需求(比如”根据返回内容决定要不要重试”、”某域名限制每秒两次”),就得往这个循环里塞更多的条件判断。

真正让我下决心重写的是加”每个域名最多 3 个并发”这个需求的时候——改了三处地方还是有问题,因为 curl_multi 里没有现成的办法按域名分组。

第三轮:Fiber,用同步的写法做异步的事

Fiber 是 PHP 8.1 的内置特性。它让一个函数可以在中间”暂停”,把控制权交还给调用者,之后可以在暂停的地方”恢复”继续执行。

听起来像生成器(Generator),但区别很关键:生成器只能由调用方主动推进,Fiber 可以由任何代码主动暂停自己。这个区别让 Fiber 能表达真正的”协作式调度”。

用 Fiber 重构那个抓取逻辑,第一个转变是:让抓取函数写起来像同步的。

function fetchWithFiber(string $url): string
{
    $fiber = Fiber::getCurrent();
    if ($fiber === null) {
        throw new RuntimeException('必须在 Fiber 里调用');
    }

    $ch = curl_init($url);
    curl_setopt_array($ch, [
        CURLOPT_RETURNTRANSFER => true,
        CURLOPT_TIMEOUT        => 10,
    ]);

    // ... 挂到一个事件循环里等着,等完成了再 resume 这个 fiber
    // 具体实现后面说

    return $body;
}

// 使用方看起来就像同步的
$body = fetchWithFiber('https://example.com/product/1');
$price = extractPrice($body);

关键在于 fetchWithFiber 内部”等待”的时候,它不是真的睡眠,而是挂起当前 Fiber,把控制权还给调度器。调度器去跑别的 Fiber,等 curl 完成之后再唤醒它。外部使用方的代码看起来完全同步——这是 Fiber 最大的价值。

但是手写调度器有一个坑需要考虑:Fiber 是”协作式”的,只有代码主动 suspend 才会让出控制。如果你调用的某个库里有同步阻塞的 file_get_contents 或者 sleep(),整个事件循环就卡死了。所有涉及 IO 的操作都必须走异步版本。

自己写一个能跑的最小调度器大概是这样的:

class FiberScheduler
{
    private SplQueue $tasks;
    private array $suspensions = [];

    public function __construct()
    {
        $this->tasks = new SplQueue();
    }

    public function add(Fiber $fiber): void
    {
        $this->tasks->enqueue($fiber);
    }

    public function run(): void
    {
        while (!$this->tasks->isEmpty()) {
            $fiber = $this->tasks->dequeue();

            if ($fiber->isTerminated()) {
                continue;
            }

            if ($fiber->isStarted() && !$fiber->isSuspended()) {
                continue;
            }

            if ($fiber->isSuspended()) {
                $fiber->resume();
            } else {
                $fiber->start();
            }

            if (!$fiber->isTerminated()) {
                $this->tasks->enqueue($fiber);
            }
        }
    }
}

但这只是一个玩具级的轮询调度器。真正要处理 IO,需要 stream_select 来判断哪个文件描述符可读可写——这就开始写事件循环了。为了这一件事造一个事件循环,不值。

这就是为什么我们需要 Revolt。

第四轮:Revolt + amphp,让生态接管调度

Revolt 是一个独立的、与具体框架无关的 PHP 事件循环实现。它只做一件事:提供一个标准的事件循环,其他库都可以基于它写异步逻辑,互相兼容。

装它很简单:

composer require revolt/event-loop
composer require amphp/http-client

就这两个包。amphp 是 PHP 生态里最成熟的异步框架,v3 版本完全基于 Revolt。

用 amphp 的 http-client 之后,抓取代码变成这样:

<?php

use AmpHttpClientHttpClientBuilder;
use AmpHttpClientRequest;
use AmpFuture;
use function Ampasync;

require 'vendor/autoload.php';

$targets = json_decode(file_get_contents('targets.json'), true);
$client = HttpClientBuilder::buildDefault();

// 为每个 URL 创建一个并发任务
$futures = [];
foreach ($targets as $target) {
    $futures[$target['name']] = async(function () use ($client, $target) {
        try {
            $response = $client->request(new Request($target['url'], 'GET'));
            $body = $response->getBody()->buffer();

            if ($response->getStatus() !== 200) {
                return ['error' => 'HTTP ' . $response->getStatus()];
            }

            return [
                'price' => extractPrice($body),
                'stock' => extractStock($body),
            ];
        } catch (Throwable $e) {
            return ['error' => $e->getMessage()];
        }
    });
}

// 等待所有任务完成
$results = Futureawait($futures);

file_put_contents('results.json', json_encode($results));

就这么多。

这个代码跟”我要为 500 个 URL 抓取数据然后等所有结果”这个业务语义一一对应。没有轮询、没有手动调度、没有 select 语句。async() 创建一个异步任务,Futureawait() 等待它们全部完成,中间的管理由 Revolt 和 amphp 处理。

想加”限制并发数”,用它内置的并发循环:

use AmpPipelinePipeline;
use function AmpPipelinefromIterable;

$limit = 50; // 最多 50 个并发

$results = [];
$pipeline = fromIterable($targets)
    ->concurrent($limit)
    ->map(function (array $target) use ($client) {
        // 每个 target 交给一个光纤处理
        return processOne($client, $target);
    });

foreach ($pipeline as $target => $result) {
    $results[$target] = $result;
}

想加”某域名每秒最多两个请求”?amphp 有一个 RateLimiter:

use AmpSyncRateLimiter;
use function Ampdelay;

$limiters = [];

function acquireDomainSlot(string $domain): void
{
    global $limiters;

    if (!isset($limiters[$domain])) {
        // 每秒 2 个令牌,桶容量 2
        $limiters[$domain] = new RateLimiter(2, 2);
    }

    $limiters[$domain]->acquire();
}

然后在每个任务的开始调用 acquireDomainSlot(parse_url($url, PHP_URL_HOST))。整个限速逻辑两行代码,不用手动控制。

这就是”用对生态”的感觉——你写业务,让库处理调度。

数据对比

回顾一下四轮改造。测试数据是在同一台机器(4 核 8G)上跑同一份 900 个 URL 的列表,取三次中位数。

方案 总耗时 峰值内存 代码行数
原始串行 curl 187s 18MB 42
curl_multi 无限制 24s 210MB 78
curl_multi 限制 50 并发 19s 56MB 112
amphp + Revolt(无并发限制) 11s 198MB 38
amphp + Revolt(限制 50 并发) 9s 48MB 46

几个观察。

第一,代码行数的差异很有意思。curl_multi 版本代码越写越长,因为每加一个业务需求就要在这个手写的事件循环里塞更多条件。amphp 版本反而越写越短——因为调度、并发控制、限速这些能力都是库提供的,你的代码只关心业务。

第二,内存占用。amphp 有并发限制时内存比 curl_multi 版本低,因为 amphp 的流式处理不会一次性把所有响应体加载到内存里。如果需要进一步节省内存,可以用 $response->getBody()->read() 分批读,或者启用 HTTP 压缩。

第三,性能瓶颈已经转移。9 秒这个数字不是 CPU 撑不住了,是网络往返加上目标网站的响应时间决定的。理论上把并发再提高一点能到 5 秒左右,但担心冲击对方站点,就停在了 50。

这里我想强调一件事:这个优化不是”速度上的胜利”,而是”维护成本的胜利”。之前那个 curl_multi 版本,客户自己不敢改。改成 amphp 版本之后,他们两周内自己加上了”根据域名分组限速”、”失败自动重试两次”、”某类页面走代理”三个新需求,都是几行代码的改动。

五个踩过的坑

坑一:不要在 Fiber 里做同步阻塞操作。第一次写的时候,在异步任务里顺手用了 file_get_contents() 读一个本地文件。结果整个事件循环卡了 200 毫秒——因为 file_get_contents 是同步阻塞的,它一执行,当前线程就停在那里,别的 Fiber 也没法跑。

正确做法是用 amphp 提供的异步文件操作:

use function AmpFileread;

$content = read($path);  // 非阻塞版本

整个思路是:一旦进入”异步上下文”,就不要调用任何同步 IO 函数。file、socket、sleep、proc、PDO——这些都有非阻塞替代品,用错了会拖累整个调度。

坑二:全局状态在异步环境里会串。我们有一个抓取任务会用到全局变量存”当前处理的 URL”,用于日志里带上 URL。同步代码里这么写没问题,异步之后就乱了——一个 Fiber 写进去,另一个 Fiber 读的时候可能已经变了。日志里出现了 “processing url X” 后面跟着的是另一个 URL 的错误信息。

解决办法是:不要用全局变量,把所有上下文通过参数或闭包传递。amphp 有一个 AmpContext 可以在异步任务之间传递上下文,如果需要”每个任务的日志上下文”,用它。

坑三:连接数上限和 DNS。限制并发之后,你可能会发现”限制 50 但实际只有 20 个请求在飞”。原因是 DNS 解析。stream_socket_client 里的 DNS 解析是阻塞的,PHP 的解析器在某些系统上是串行的。500 个不同域名的 URL,DNS 就成了瓶颈。

amphp 提供了 amphp/dns 包来解决这个问题,它会把 DNS 查询也放在事件循环里异步执行。装上之后并发数就上去了。这个坑在抓取不同域名的时候特别容易遇到,同域名的抓取不会踩。

坑四:异常处理不能大意。异步任务里的异常如果没人捕获,会静默地消失。我们第一批上线的时候,有一类错误(对方返回 503)的任务直接”消失”了——既没报错,也没写入结果。查了一下午才发现是 async() 里抛出的异常需要在 Future 上调用 await() 或者 FutureawaitAll() 的时候才会被抛出。如果不处理,整个任务就默默地没了。

正确做法是每个异步任务内部 try-catch,把异常转成结果的一部分返回:

$futures[$name] = async(function () use ($client, $target) {
    try {
        // ...
        return ['ok' => true, 'data' => $data];
    } catch (Throwable $e) {
        return ['ok' => false, 'error' => $e->getMessage()];
    }
});

这样无论成功失败都有结果,日志里能追踪到。

坑五:不要忘记设置超时。amphp 的 HTTP 客户端默认超时是 10 秒,这个跟 curl 的默认值一样。但如果你用了 HttpClientBuilder::buildDefault(),有些行为会跟 curl 不一样。我们踩的一个坑是”某个域名一直没响应”,整个抓取任务就等在那里不结束。后来手动设置了超时:

use AmpHttpClientHttpClientBuilder;
use AmpTimeoutCancellation;

$builder = (new HttpClientBuilder())->retry(2);
$client = $builder->build();

// 每次请求
$response = $client->request(
    new Request($url),
    new TimeoutCancellation(8.0)
);

TimeoutCancellation 会抛出一个 CancelledException,被我们上面那个 try-catch 捕获,转成错误结果。整个任务 8 秒后结束,不会一直挂着。

Fiber 到底适合什么

写到这里,我想给一个更诚实的判断。

Fiber 本身是一个底层原语,普通人写业务代码很少直接用。真正有价值的是基于 Fiber 建立起来的生态——amphp、Revolt、以及基于它们构建的库(HTTP 客户端、数据库客户端、文件系统)。你要用 Fiber,正确的方式通常是”我不直接用 Fiber,我用 amphp”。

什么时候确实应该考虑这套东西?

  • 一次性并发处理几十到几百个 IO 任务。抓取、批量调用 API、并行下载文件,都是天然的并发场景。
  • CLI 脚本和定时任务。这一类程序通常不需要考虑请求-响应模型,很适合用这套异步方式重构。
  • 对进程数量敏感的场景。以前这几个抓取任务可能要开 20 个 worker 进程,现在一个进程里能跑完,内存和进程管理都省了。

什么时候不该用?

  • Web 请求-响应模型。传统的 php-fpm 是”一个请求”占用”一个 worker”的模式。单个请求里用异步 IO 收益有限(一个请求通常就几个 IO 操作),而且一旦你在 fpm 里跑事件循环,可能跟 fpm 自身的进程管理产生冲突。这种场景应该看 FrankenPHP 或者 Swoole。
  • 任务很小、只有几次 IO。如果一个脚本总共就发三四个请求,串行写清楚代码就好,别上异步。
  • 团队里没有异步编程经验。异步代码里的异常、上下文、调度时机,跟同步代码是不同的心智模型。强行上异步带来的 bug 比性能收益更麻烦。

具体到客户这个项目,条件是:CLI 场景、IO 密集、并发量大、团队不想引入扩展依赖。四个条件都满足,Fiber 方案就是对的。如果换一个 Web 场景,我的答案可能完全不同。

写在最后

这次改造让我重新认识了 PHP 的异步能力。

很长时间里,PHP 的工程师有两种选择:要么接受同步阻塞的编程模型,简单但要花机器资源;要么上 Swoole 或者 RoadRunner,性能好但要承担额外的扩展、进程模型和无障碍兼容。

Fiber 加上 Revolt 这一条路线,提供了第三种可能——完全用 PHP 生态内的包实现,不需要扩展,不需要守护进程,就是普通的 composer 依赖。它的性能可能比 Swoole 差一点,但心智成本、部署成本、运维成本低很多。

对于”一个进程里并发做几十个 IO”这个中间地带,它填上了一个生态空白。以前这个地带是被忽略的——要么全同步,要么一整套异步框架。现在有了一个更轻的选择。

如果你手上也有类似的”跑得慢但没必要上重量级的脚本”,值得花半天试试 amphp。也许你的答案就是 Fiber。

PHP Fiber 实战:一个抓取脚本从 187 秒压到 9 秒的四轮改造
收藏 (0) 打赏

感谢您的支持,我会继续努力的!

打开微信/支付宝扫一扫,即可进行扫码打赏哦,分享从这里开始,精彩与您同在
点赞 (0)

版权声明:
本站资源有的来自互联网收集整理,本站纯免费分享提供学习使用,如果侵犯了您的合法权益,请发送邮件1506151422@qq.com联系,将会及时下架删除。
本站资源仅供研究、学习交流之用,免费开源项目不代表完全可商用,若商业用途请先咨询开发企业能否商用,否则产生的一切后果将由下载用户自行承担。
原创板块未经允许不得转载,否则将追究法律责任。

淘吗网 php PHP Fiber 实战:一个抓取脚本从 187 秒压到 9 秒的四轮改造 https://www.taomawang.com/server/php/2906.html

下一篇:

已经没有下一篇了!

常见问题

相关文章

猜你喜欢
发表评论
暂无评论
官方客服团队

为您解决烦忧 - 24小时在线 专业服务