做 API 网关最怕的不是逻辑复杂,而是请求一多,机器先扛不住。很多人第一次把网关上线后,最直观的感受就是:并发上去了,响应却崩了。

尤其是还在用传统 PHP-FPM 的那套打法,流量一冲,CPU 像被按在地上摩擦,延迟抖得根本稳不住。

传统 PHP-FPM 的瓶颈到底在哪

每次请求都要把框架再“装”一遍,这其实是个结构性问题。

PHP-FPM 的无状态模型决定了它处理请求的方式很“重”:请求进来,fork 或派生 worker,初始化环境,加载引导文件,重建服务容器,再跑业务逻辑。对 Laravel 项目来说,这意味着每一次请求几乎都要把整个框架重新启动一次。数据库连接、Redis 客户端、HTTP 客户端实例,这些原本可以复用的东西,也会随着进程生命周期被反复创建和销毁。CPU 花在“准备环境”上的时间,比真正处理业务还多。

当并发达到一定规模,FPM 的动态进程池会把内存吃光。你把 pm.max_children 调大,确实能撑住更多请求,但内存也在同步飙升;调到上限后又开始频繁回收旧进程,造成抖动。更糟的是,网关通常要做反向代理、限流、鉴权、日志记录等一系列通用动作,这些“公共开销”在每个进程里都会被重复计算。于是你会发现:CPU 长期 90%+,响应时间一路拖到两秒以上,甚至触发上游超时。

我们拿一台常规配置的机器做压测:1000 并发持续打向一个基于 Laravel 的 API 网关。FPM 模式下,CPU 直接顶到 90%,平均响应时间超过 2 秒,P95/P99 更是没法看。

// 一个典型的 Laravel 网关路由闭包,每次都会走完整生命周期
Route::get('/proxy', function () {
    // 每次请求都会重建容器、服务提供者、数据库连接等
    return Http::client()->get('http://upstream-service/status');
});

传统 PHP-FPM 的架构在高并发下,不是慢,而是结构性浪费。

Laravel Octane Swoole memory resident coroutine

常驻内存与协程如何打破天花板

上一节压完那组数据之后,传统 PHP-FPM 的并发能力基本就是个死结——请求一来就拉进程、读脚本、编译、走人、销毁,周而复始,CPU 全耗在重复造轮子上。Octane 换了个打法:让 Laravel 只在启动时完整走一遍生命周期,之后应用实例就老老实实驻在内存里,每个请求进来直接复用,不用再反复拉起来、拆掉,再拉起来。说白了就是“一次加载,反复接客”,那套 FPM 时代的老路子,真该退休了。

安装 Octane 之后,你只需要跑一条命令:

php artisan octane:start --server=swoole --host=0.0.0.0 --port=9501

这条命令会把 Laravel 应用整个“预热”一遍——加载所有服务提供者、注册路由、绑定容器里的单例,然后在内存里生成一份快照。此后进来的每一个请求,都不需要再重新走这些流程。假如你在某个服务提供者里注册了一个 Guzzle 客户端作为单例:

$this->app->singleton(HttpClient::class, function () {
    return new Client(['timeout' => 5.0]);
});

在 FPM 模式下,每一个新请求 fork 出来的 worker 都要重新执行这段闭包。但在 Octane 里,这个客户端实例在启动时已经存在,所有请求共用同一份。内存里只有一个 Guzzle 对象,而不是几十上百个一模一样的副本。

数据上看,Octane 官方文档给过一个基准测试:在相同硬件配置下,常规 Laravel 应用从 FPM 迁移到 Octane+Swoole,吞吐量(RPS)提升大约 5 倍。这 5 倍里至少 3 倍是常驻内存省下来的框架初始化开销,剩下 2 倍才轮到协程的功劳。

光常驻内存还不够。传统 FPM 一个 worker 处理一个请求,请求里如果有个 HTTP 调用要等 500 毫秒,整个 worker 就戳在那干等。Swoole 的协程解决了这个浪费——遇到 I/O 等待时,协程主动让出 CPU,切换到另一个协程继续干活。

我去年踩过一个坑:网关里有个路由要同时转发到三个上游服务做聚合:

Route::get('/aggregate', function () {
    $responses = Http::pool(fn ($pool) => [
        $pool->get('http://service-a/data'),
        $pool->get('http://service-b/data'),
        $pool->get('http://service-c/data'),
    ]);
    // 合并三个结果返回
});

在 FPM 下,这段代码如果不用连接池,三次 HTTP 调用是串行的,总耗时等于三者之和。用上 Swoole 协程后,三次调用并发执行,总耗时取决于最慢的那个上游。实测从 1.2 秒降到了 400 毫秒——这不是算法优化,纯粹是让 CPU 不再闲着等网络。

Swoole 的协程调度器能做到一个进程同时维护数千个协程。对比 FPM 一个 worker 一个请求的模型,内存占用差异非常明显。同样扛 2000 并发,FPM 可能需要 200 个 worker,内存占用奔着 4GB 去了;Swoole 开 8 个 worker 进程,每个进程里跑 250 个协程,内存 1GB 出头就稳住了。

还是上一节那台机器,同样的 1000 并发,压测场景换到 Octane+Swoole:CPU 占用从 90% 降到了 40% 左右。平均响应时间从 2 秒多掉到了 180 毫秒。最关键的 P99 延迟——网关最怕的那个尾巴——从超过 2 秒缩到了 200 毫秒以内。之前有 1% 的请求要等 2 秒以上,现在只有 1% 的请求会超过 200 毫秒。

有个细节很多人初次接触 Octane 会忽略:单例的副作用。因为容器里的对象不再每次请求重建,你在请求里修改了某个单例对象的属性,下一个请求会读到被改过的状态。比如在服务提供者里注册了一个全局配置对象作为单例,然后在中间件里往里塞用户信息——后面来的请求就会拿到前一个人的数据。这种 bug 在 FPM 下不会出现,因为每个进程隔离。迁移到 Octane 后,必须手动用 request()->macro() 或协程上下文来隔离数据,否则线上会出诡异的数据错乱。

常驻内存不是银弹,它只是换了一个更高效的代价结构。把框架加载的固定成本砍掉,把等待 I/O 的时间还给 CPU——这扇门推开之后,后面的路才真正通向高并发。

踩坑实录:从路由缓存到协程安全

把 Laravel 搬到 Octane + Swoole 之后,最吓人的不是慢,而是“看起来一模一样”的代码突然在高并发下行为诡异。你以为路由刷新就完事了?不行。你以为静态变量省内存?更不行。连数据库连接数都不再是 php.ini 那个熟悉的 max_connections 能单独决定的。

Octane 把框架和应用实例常驻在内存里,路由表也是一次加载反复使用。开发时你改了 route/web.php,浏览器却一直报 404——不是 Nginx 有问题,而是 Octane 还在吃旧的路由缓存。解决方式很直白:每次路由文件改动,都得让 Octane 重新载入应用状态。部署脚本里别只 restart,给够 graceful 时间,顺带清理 Octane 的路由缓存位。Artisan 那条命令是 Octane 用来标记路由需要重新编译的开关,不跑它,改动就像被吞了一样安静失效。

php artisan octane:reload --server=swoole

线上别指望文件监听救命,容器里进程间共享文件描述符的方式不同,inotify 不一定靠谱。稳妥做法是在 CI/CD 的最后一步显式触发 reload,让新版本带着新路由进内存。

FPM 时代,请求结束进程释放,全局状态等于自动重置;Swoole 的常驻进程模型则把你声明的 static 变量钉死在内存里。一个控制器里随手写的 static $counter = 0,在同进程的上千协程里会被共享访问与修改,结果就是两类问题:要么协程 A 读到协程 B 留下的脏数据,要么这个对象越长越大把内存撑爆。别把业务状态塞进全局静态变量,改用请求上下文或协程本地存储。Laravel 里可以用 Request 实例携带宏或者通过服务容器绑定到当前请求生命周期;更低层级可以把 OpenSwoole\Coroutine\Context 当口袋用,保证键值只在当前协程可见。想再稳一点,上原子计数与线程安全封装,避免竞态条件把统计口径搞歪。

Swoole 下的数据库驱动走连接池复用,连接不会随请求结束而断开。很多人照着 FPM 经验把 max_connections 设成几百,却在压测时看到大量 Sleep 连接把 MySQL 拖死,Nginx 反向代理那边只能返回 503。根因是 Swoole 的连接池上限与 MySQL 侧的允许连接数没对齐,加上 PDO 默认并不会回收闲置连接,导致峰值时资源被锁死。

  • 先核对 Swoole 文档里对应版本的 pool.max_connections 参数,把它调到比预期并发略高的数值。
  • 再把 MySQL 的 max_connections 抬到同一量级,并配合 wait_timeout 缩短空闲回收周期。

调参的同时别忘了看慢查询与锁等待,连接池再大也救不了糟糕的索引选择。把这些数字对齐之后,P99 延迟那条尾巴才会真正掉下来,而不是把压力转移给数据库等着崩。

API gateway routing rate limiting circuit breaker Laravel Oc

API网关实战:路由转发、限流与熔断

前面把内存泄漏和连接池那些坑都踩了一遍,终于能动手搭网关本体了。传统PHP-FPM搞API网关,最憋屈的还真不是路由逻辑写不出来——而是每次请求都得把整个框架重新加载一遍。你费老大劲写了个漂亮的路由分发,压测一上来,CPU烧在框架启动上的时间,比跑业务逻辑还多,这就很离谱了。

Octane+Swoole这套组合拳,本质是把"每次请求重建应用"变成"应用常驻,请求只走中间件管道"。路由服务还是在用Laravel那套门面,但注册时机从每次请求变成了Worker进程启动时一次搞定。代码里要做的改动其实不大,但心理上得转过这个弯:你的路由文件不再是个"每次执行都重新require的脚本",而是进程内存里一张静态的路由表。

大部分API网关的第一个活儿是转发。传统做法是nginx里写一堆proxy_pass,后端服务多了,配置文件比业务代码还长。换Octane之后,我倾向于直接在Laravel中间件里做代理转发——用Swoole的协程HTTP客户端发起请求,不走CURL,不阻塞Worker进程。

随便贴一段核心逻辑,大致长这样:

namespace App\Http\Middleware;

use Closure;
use Illuminate\Http\Request;
use Swoole\Coroutine\Http\Client;

class ServiceProxy
{
    protected array $routes = [
        'user' => 'http://user-service:8080',
        'order' => 'http://order-service:8081',
    ];

    public function handle(Request $request, Closure $next)
    {
        $path = $request->path();
        foreach ($this->routes as $prefix => $target) {
            if (str_starts_with($path, $prefix)) {
                $client = new Client(
                    parse_url($target, PHP_URL_HOST),
                    parse_url($target, PHP_URL_PORT)
                );
                $client->set(['timeout' => 3]);
                $client->post(
                    substr($path, strlen($prefix)),
                    $request->all(),
                    ['Content-Type' => 'application/json']
                );
                return response($client->body, $client->statusCode);
            }
        }
        return $next($request);
    }
}

有个坑:别在协程客户端里做同步阻塞的DNS解析。Swoole默认的DNS查询会走系统函数,协程一多就把DNS服务器打挂了。提前在 swoole.init 里用协程DNS解析器,或者干脆把服务地址写成IP加端口。

另一个细节是负载均衡。我见过有人在这个中间件里直接写array_rand选后端,并发上来后分布极不均匀。Swoole Table里存一个自增计数器,用原子操作做轮询,比 rand 靠谱得多。代码不贴了,思路就是每次请求拿当前Worker ID + 原子计数器取模,命中哪个后端就转发哪个。

限流这件事,用Redis计数器当然可以,但每次请求都走一次网络IO,在网关这种高频场景下成本太高。Swoole Table本质是共享内存的哈希表,读写都在进程内完成,延迟在微秒级,不涉及锁争用(它内部用读写锁+行锁实现)。

我实现了一个滑动窗口限流器,用Table存每个IP的时间戳桶:

use Swoole\Table;

$table = new Table(1024);
$table->column('count', Table::TYPE_INT);
$table->column('window_start', Table::TYPE_INT);
$table->create();

// 每次请求来了
$ip = $request->ip();
$now = time();
$window = 60; // 60秒窗口
$limit = 1000;

if ($table->exist($ip)) {
    $row = $table->get($ip);
    if ($now - $row['window_start'] > $window) {
        $table->set($ip, ['count' => 1, 'window_start' => $now]);
    } elseif ($row['count'] >= $limit) {
        abort(429, 'Too Many Requests');
    } else {
        $table->incr($ip, 'count', 1);
    }
} else {
    $table->set($ip, ['count' => 1, 'window_start' => $now]);
}

这段代码在单Worker进程里跑没问题。但Octane默认启动多个Worker(通常是CPU核心数),每个Worker维护各自的Table副本,计数就串不到一起。解决办法是把Table声明为全局共享,在启动之前通过 Swoole\Table 创建并注入到Laravel的服务容器里。所有Worker读写同一块共享内存,计数才是准确的。

阈值怎么定?别拍脑袋写个1000就完事。我习惯先压测出后端服务的实际QPS上限,然后留20%余量做限流阈值。线上流量波动大的场景,阈值可以动态调整——把Table的计数暴露成一个HTTP端点,运维脚本定期根据CPU和内存水位改值。这功能用传统FPM做,响应慢不说,改个配置还得reload PHP进程。

限流是防客户端打爆你,熔断是防上游打爆你。Octane的并发任务机制给了个天然的优势:可以在网关进程里起一个后台定时协程,每隔5秒去探测上游服务的健康端点,然后把状态写进Swoole Table。

熔断器的状态机就三个状态:关闭(正常转发)、打开(直接降级)、半开(试探性放量)。实现时我用了Laravel的 Cache 门面,但底层驱动改成Swoole Table——这样既不用Redis,也不会因为缓存驱动切换导致代码侵入。

// 在Octane的Worker启动时注册定时任务
Octane::tick('health-check', function () use ($table) {
    $upstreams = ['user', 'order', 'payment'];
    foreach ($upstreams as $service) {
        $healthy = $this->healthCheck($service);
        $table->set('circuit:' . $service, ['healthy' => (int)$healthy, 'checked_at' => time()]);
    }
}, every: 5);

实际转发时,中间件读一下Table里对应服务的健康状态。不健康就直接返回缓存数据或者一个友好的降级响应,而不是让用户等30秒超时。熔断恢复也得小心——半开状态只放1%的流量过去,连续成功10次才切回关闭。这逻辑在传统PHP里很难做,因为每次请求都是独立的,状态无处可存。

最后提醒一点:别把所有降级逻辑堆在网关层。网关只做"转还是不转"的判断,具体的降级数据(比如从缓存读旧订单、返回静态页)应该交给下游服务自己的降级策略。网关干太多事,一旦它崩了,整个系统就真的黑了。

路由转发、限流、熔断这三板斧搭完,网关的骨架就有了。剩下的就是加日志、加链路追踪、加认证鉴权这些外围能力。但比功能更重要的是,这套东西跑在常驻内存里,每个请求的额外开销就是几次Table读取和一次协程IO——比FPM下每次重建框架省掉的时间,足够你跑好几轮业务逻辑了。

部署与监控:从开发机到生产环境

代码写完、压测也过了,真正麻烦的是把它稳稳地放到线上。Octane 不像 FPM 那样随手就能启,它是常驻内存的服务,得有人守着它。

我用 supervisor 4.2 管过最忙的一台机器。配置不长,但每行都得较真。

[program:octane]
command=php /path/to/artisan octane:swoole --env=production --task-enable
process_name=%(program_name)s_%(process_num)02d
numprocs=8
directory=/var/www/html
user=www-data
autostart=true
autorestart=true
startretries=3
stopwaitsecs=60
environment=PATH="/usr/local/bin",OCTANE_ENV="production"

numprocs 别贪多,按 CPU 核数给就行;stopwaitsecs 要留足,默认 10 秒不够 Octane 优雅退出。日志切走,不然三天就撑爆磁盘。

光看服务器日志心里没底。我把 Swoole 的统计写到 Prometheus,再用 Grafana 画出来。关键指标就几项:当前活跃 worker 数、请求延迟 P99、共享内存使用量、协程数量。

Octane 提供了 Artisan 命令,但我更信数字。顺手装了 php-prometheus-client 0.14,在全局中间件里埋了两行:

$metric = new Histogram('octane_request_duration_seconds', 'Request duration');
$metric->observe($endTime - $startTime);

Grafana 面板一挂,流量高峰时哪台机器先喘不过气,一眼就看见。

上线最怕删文件停服务。我用符号链接做蓝绿切换:准备两套目录 v1、v2,current 软链指向正在跑的版本。发版时先把新版本传到 v2,确认编译无误,再原子替换 current 指向,最后给 Octane 发 reload 信号。

reload 不会打断现有连接,新请求自然会落到新 worker。万一炸了,切回 v1 只要一秒钟。这套玩法我在三台 4C8G 的机器上跑了半年,没让用户感知过一次维护窗口。当然,内存泄漏还是得盯着。每次发版前后都跑一遍监控,再把老进程的 RSS 画条线,异常放大就报警。这活儿脏,但比半夜被电话吵醒强。

性能调优:让Octane+Swoole跑得更稳更快

容器跑起来了,请求也能接了,但你以为这就完了?

上线第一周,我盯着 Grafana 面板,发现有一台 worker 的内存悄悄涨到 1.2GB,吓得我当场就想重启。冷静下来才意识到——常驻内存的好处是快,代价是任何一点资源泄漏都会被放大。调优这事,不能靠感觉,得摸清 Swoole 那几张底牌。

文档说 worker_num 通常设为 CPU 核心数。我在 8 核机器上试了 8,结果压测时 CPU 打满,响应时间反而抖得厉害。后来改成 6(worker_num = 6),留两个核给系统中断和 Swoole 的 reactor 线程用,P99 立刻降了 40ms。你问为什么?reactor 线程负责网络事件轮询,worker 负责执行业务逻辑。两者抢 CPU 的话,谁都不好过。另外 max_request 我设了 500,不是怕内存泄漏——是怕某些第三方库的静态变量在常驻进程里一点点攒脏数据。500 次请求后自动重启,干净利落。

传统 PHP-FPM 下,OpCache 是锦上添花。Swoole 常驻内存后,opcache.preload 成了雪中送炭。我在 php.ini 里加了这么一段:

opcache.preload=/var/www/laravel/preload.php
opcache.preload_user=www-data
opcache.memory_consumption=256
opcache.max_accelerated_files=20000

preload.php 里把 Laravel 的核心类全部提前加载——框架本身的门面、服务容器、路由集合。第一次请求进来时,这些类已经编译成 opcode 躺在共享内存里了。实测第一个请求的响应时间从 120ms 降到 18ms。当然,你每次改代码后得手动触发 opcache_reset(),我写了个 Artisan 命令挂在部署脚本里。

网关里免不了要转发请求到下游服务。如果一个上游响应慢,Swoole 的 worker 就被卡住——协程虽然能切换,但 I/O 等待时间太长还是会影响其他请求。我的做法是用 Swoole\Server->task() 把大块头的耗时操作丢给 Task Worker 池:

public function handle(Request $request, Closure $next)
{
    if ($request->route()->getName() === 'proxy.slow') {
        $response = \Swoole\Coroutine::create(function () use ($request) {
            $taskId = app('swoole.server')->task($request->all());
            return waitForTaskResult($taskId);
        });
        return response()->json($response);
    }
    return $next($request);
}

Task Worker 虽然是同步阻塞的,但人家在独立进程池里干活,不会卡住主 worker 接新请求。开了 4 个 task_worker_num,专门丢那些动不动就耗 3 秒的第三方接口转发。有个坑得记住:Task Worker 里面别碰协程语法,也别想着能共享 Laravel 的 IoC 容器——它是独立进程,每次任务跑完上下文直接销毁,干净利落。

调来调去,最终目标是让网关像台机器一样稳定,而不是像个赛车。