Laravel Octane 初體驗

godruoyi發表於2021-04-30

Laravel Octane 初體驗

Laravel Octane 已經發布好幾周了,雖說目前還處於 beta 狀態,也擋不住開發者對他的熱愛,一個月不到,其在 GitHub 的 star 數量已超過 2K;部分開發者已將他們的專案執行在 Laravel Octane 之上。

如果你還在觀望,也可等等一兩週後的穩定版。

We will likely go ahead and tag Octane 1.0 as stable next week @Taylor Otwell on Twitter.

為了體驗一把加速的魔力,作者已拿一個簡單的 H5 專案在生產環境下試了試水,除了一些亂七八糟的問題,其他的都令作者激動不已,客戶還表示我們的平臺好快啊,下次還找你。

Laravel Octane 的組成

Laravel Octane 內建了兩個高效能的應用服務:SwooleRoadRunner,正如官方文件介紹的:

Octane boots your application once, keeps it in memory, and then feeds it requests at supersonic speeds.

我們知道,Laravel 框架一直很優秀,但是他在效能方面卻一直為人詬病。框架的 boot 時間可能比業務處理時間還長,並且隨著專案第三方 service provider 的增多,其啟動速度越來越不受控。而 Laravel Octane 則通過啟動 Application 一次,常駐記憶體的方式來加速我們的應用。

Laravel Octane 需要 PHP8.0 支援,如果你是在 macOS 下工作,你可以參考這篇文章來更新你的 PHP 版本 Upgrade to PHP 8 with Homebrew on Mac

Octane 簡單示列

雖說官方文件已經描述的很詳細,不過作者這裡還是通過一個簡單的示列專案來演示。

Create Laravel Application

➜ laravel new laravel-octane-test

 _                               _
| |                             | |
| |     __ _ _ __ __ ___   _____| |
| |    / _` | '__/ _` \ \ / / _ \ |
| |___| (_| | | | (_| |\ V /  __/ |
|______\__,_|_|  \__,_| \_/ \___|_|

Creating a "laravel/laravel" project at "./laravel-octane-test"
Installing laravel/laravel (v8.5.16)
...
Application ready! Build something amazing.

Install Laravel Octane

$ composer require laravel/octane

安裝成功後,讀者可以直接執行 artisan octane:install 來安裝依賴;Octane 將提示你想使用的 server 型別。

➜ php artisan octane:install

 Which application server you would like to use?:
  [0] roadrunner
  [1] swoole
 >

如果你選擇的是 RoadRunner,程式將會自動幫你安裝 RoadRunner 所需的依賴;而如果你選擇的是 Swoole,你只需要確保你已經手動安裝了 PHP swoole 擴充套件。

使用 RoadRunner Server

RoadRunner 的使用過程不盡人意,作者在安裝過程中總會出現一些官方文件忽視的錯誤。

下載 rr 可執行檔案失敗

在執行 octane:install 安裝 RoadRunner 依賴時,作者本機根本無法通過 GitHub 下載 rr 可執行檔案,提示的錯誤如下:

In CommonResponseTrait.php line 178:

HTTP/2 403  returned for "https://api.github.com/repos/spiral/roadrunner-binary/releases?page=1".

如果你也遇到了這樣的錯誤,建議直接去 RoadRunner 官網 下載對應平臺的 rr 可執行檔案及 .rr.yaml 配置檔案並放到專案根目錄。如 macOS 平臺的可執行檔案及配置檔案地址:

最後記得修改 rr 的可執行許可權及 RoadRunner 的 Worker starting command。

chmod +x ./rr
server:
  # Worker starting command, with any required arguments.
  #
  # This option is required.
  command: "php artisan octane:start --server=roadrunner --host=127.0.0.1 --port=8000"

ssl_valid: key file ‘/ssl/server.key’ does not exists

RoadRunner 的配置檔案中,預設開啟了 ssl 配置, 若你不需要啟用 https 訪問,可註釋 http.ssl 配置。

Error while dialing dial tcp 127.0.0.1:7233

RoadRunner 預設開啟 temporal 特性,其 listen 埠為 7233,若你不想啟用該特性,可註釋 temporal 配置。

# Drop this section for temporal feature disabling.
temporal:

關於 temporal 的資訊可檢視官網 temporalio/sdk-php: Temporal PHP SDK

Executable file not found in $PATH

這種情況一般是配置檔案中未制定程式執行路徑,請檢查以下配置。

  1. Server.command

修改為 RoadRunner worker 的啟動命令,如:

php artisan octane:start —server=roadrunner —host=127.0.0.1 —port=8000
  1. Service.some_service_*.comment

如果你不想使用該特性,註釋該配置。至此,作者的 RoadRunner 終於啟動起來了。

Laravel Octane RoadRunner

AB Test For RoadRunner

作者用自己的筆記本(2018-13inch/2.3GHz/16GB)做了一個簡單的 AB Test,框架程式碼未做任何改動,為 Laravel 預設的 welcome 頁面。

經過改變不同的併發引數和請求數,得到的結果都如下圖所示上下輕微波動,其 QPS 基本維持在 230/s 左右。

~ ab -n 2000 -c 8 http://127.0.0.1:8000/
Server Software:
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        17490 bytes

Concurrency Level:      8
Time taken for tests:   8.418 seconds
Complete requests:      2000
Failed requests:        0
Total transferred:      37042000 bytes
HTML transferred:       34980000 bytes
Requests per second:    237.59 [#/sec] (mean)
Time per request:       33.671 [ms] (mean)
Time per request:       4.209 [ms] (mean, across all concurrent requests)
Transfer rate:          4297.28 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        3   11   4.6     11      29
Processing:     3   20  34.8     15     270
Waiting:        3   18  34.8     12     270
Total:          7   31  35.2     25     284

預設情況下,Laravel 的 welcome 頁面會先經過 web 中介軟體,最後在渲染 blade 頁面;而 web 中介軟體包含大量 Cookie 和 Session 操作:

protected $middlewareGroups = [
    'web' => [
        \App\Http\Middleware\EncryptCookies::class,
        \Illuminate\Cookie\Middleware\AddQueuedCookiesToResponse::class,
        \Illuminate\Session\Middleware\StartSession::class,
        \Illuminate\View\Middleware\ShareErrorsFromSession::class,
        \App\Http\Middleware\VerifyCsrfToken::class,
        \Illuminate\Routing\Middleware\SubstituteBindings::class,
    ],
];

所以作者重新定義了一個測試路由,該路由不包含任何中介軟體(全域性除外),並只輸出一個 Hello World。

// RouteServiceProvider.php
public function boot()
{
    require base_path('routes/test.php');
}

// test.php
Route::get('/_test', function () {
    return 'Hello World';
});

再次測試後如下,可以看到其 QPS 已經達到官方宣傳標準 2300/s(難道官方測試也是這樣 Remove All Middleware?)。

Server Software:
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /_test
Document Length:        11 bytes

Concurrency Level:      8
Time taken for tests:   0.867 seconds
Complete requests:      2000
Failed requests:        0
Total transferred:      374000 bytes
HTML transferred:       22000 bytes
Requests per second:    2307.81 [#/sec] (mean)
Time per request:       3.466 [ms] (mean)
Time per request:       0.433 [ms] (mean, across all concurrent requests)
Transfer rate:          421.45 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        0    0   0.1      0       3
Processing:     1    3   8.8      2     143
Waiting:        1    3   8.8      2     142
Total:          1    3   8.8      2     143

上述測試過程中,作者本機的資源限制如下。

~ ulimit -n
256

使用 Swoole Server

Swoole server 的使用就要順暢多了;通過 pecl 安裝好 PHP swoole 擴充套件後,無需任何配置就能啟動。

Laravel Swoole

AB Test For Swoole Server

作者用同樣的配置對 swoole server 進行 AB Test,結果如下,其 QPS 也基本維持在 230/s 左右。

Server Software:        swoole-http-server
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /
Document Length:        17503 bytes

Concurrency Level:      8
Time taken for tests:   8.398 seconds
Complete requests:      2000
Failed requests:        0
Total transferred:      37130000 bytes
HTML transferred:       35006000 bytes
Requests per second:    238.15 [#/sec] (mean)
Time per request:       33.592 [ms] (mean)
Time per request:       4.199 [ms] (mean, across all concurrent requests)
Transfer rate:          4317.61 [Kbytes/sec] received

Connection Times (ms)
              min  mean[+/-sd] median   max
Connect:        3   11   6.6     10     102
Processing:     4   20  50.3     12     442
Waiting:        2   18  50.3     11     441
Total:          7   30  50.9     23     450

無中介軟體路由測試結果如下,可以看到其 QPS 已達到了 1650/s。

Server Software:        swoole-http-server
Server Hostname:        127.0.0.1
Server Port:            8000

Document Path:          /_test
Document Length:        21 bytes

Concurrency Level:      8
Time taken for tests:   1.212 seconds
Complete requests:      2000
Failed requests:        0
Total transferred:      528000 bytes
HTML transferred:       42000 bytes
Requests per second:    1650.63 [#/sec] (mean)
Time per request:       4.847 [ms] (mean)
Time per request:       0.606 [ms] (mean, across all concurrent requests)
Transfer rate:          425.55 [Kbytes/sec] received

從 AB Test 結果來看,兩種 Server 的效能基本持平;但由於是在本地開發環境測試,未考慮到的因素較多,測試結果僅供參考。

部署上線

Laravel Octane 雖然提供了 start 命令用於啟動 Server,但該命令只能在前臺執行(不支援 -d);在部署到生產環境時,常見的辦法還是利用 Supervisor 來進行程式管理。讀者可以參考 Laravel Sail 的 Supervisor 配置。

[program:php]
command=/usr/bin/php -d variables_order=EGPCS /var/www/html/artisan serve --host=127.0.0.1 --port=80
user=sail
environment=LARAVEL_SAIL="1"
stdout_logfile=/dev/stdout
stdout_logfile_maxbytes=0
stderr_logfile=/dev/stderr
stderr_logfile_maxbytes=0

後續持續交付時,可通過 Jenkins 連線到服務節點,使用 octane:reload 命令重新載入服務。

stage("部署 ${ip}") {
    withCredentials([sshUserPrivateKey(credentialsId: env.HOST_CRED, keyFileVariable: 'identity')]) {
        remote.user = "${env.HOST_USER}"
        remote.identityFile = identity
        sshCommand remote: remote, command: "php artisan config:cache && php artisan route:cache && php artisan octane:reload"
    }
}

不過這裡需要注意的是,當你更新了 Composer 依賴,如新增了一個第三方包時,你最好在生產環境重啟下 Laravel Octane。

sudo supervisorctl -c /etx/supervisorctl.conf restart program:php

否則可能會出現如 Class “Godruoyi\Snowflake\Snowflake” not found 的錯誤。

Laravel Octane 是執行緒安全的嗎?

在回答這個問題之前,我們先來看看 Laravel Octane 的請求處理流程。

Laravel Octane

隨著 Server 的啟動,程式會建立指定數量的 Worker 程式。當請求到來時,會從可用的 Worker 列表中選取一個並交由他處理。每個 Worker 同一時刻只能處理一個請求,在請求處理過程中,對資源(變數/靜態變數/檔案控制程式碼/連結)的修改並不會存在競爭關係,所以 Laravel Octane 時執行緒(程式)安全的。

這其實和 FPM 模型是一致的,不同的地方在於 FPM 模型在處理完一個請求後,會銷燬該請求申請的所有記憶體;後續請求到來時,依然要執行完整的 PHP 初始化操作(參考 PHP-FPM 啟動分析)。而 Laravel Octane 的初始化操作是隨著 Worker Boot 進行的,在整個 Worker 的生命週期內,只會進行一次初始操作(程式啟動的時候)。後續請求將直接複用原來的資源。如上圖,Worker Boot 完成後,將會初始化 Laravel Application Container,而後續的所有請求,都將複用該 App 例項。

Laravel Octane 工作原理

Octane 只是一個殼,真正處理請求都是由外部的 Server 處理的。不過 Octane 的設計還是值得一說的。

從原始碼也可以看出,隨著 Worker 的 Boot 完成,Laravel Application 已被成功初始化。

// vendor/laravel/octane/src/Worker.php
public function boot(array $initialInstances = []): void
{
    $this->app = $app = $this->appFactory->createApplication(
        array_merge(
            $initialInstances,
            [Client::class => $this->client],
        )
    );

    $this->dispatchEvent($app, new WorkerStarting($app));
}

在處理後續到來的請求時,Octane 通過 clone $this->app 獲取一個沙箱容器。後續的所有操作都是基於這個沙箱容器來進行的,不會影響到原有的 Container。在請求結束後,Octane 會清空沙箱容器並 unset 不再使用的物件。

public function handle(Request $request, RequestContext $context): void
{
    CurrentApplication::set($sandbox = clone $this->app);

    try {
        $response = $sandbox->make(Kernel::class)->handle($request); 

    } catch (Throwable $e) {
        $this->handleWorkerError($e, $sandbox, $request, $context, $responded);
    } finally {
        $sandbox->flush();

        unset($gateway, $sandbox, $request, $response, $octaneResponse, $output);

        CurrentApplication::set($this->app);
    }
}

再次注意,由於同一個 Worker 程式同一時刻只能處理一個請求,故這裡是不存在競爭的,即使是對 static 變數的修改,也是安全的。

注意事項 & 第三方包適配

由於同一個 Worker 的多個請求會共享同一個容器例項,所以在向容器中註冊單例物件時,應該特別小心。如下面的例子:

public function register()
{
    $this->app->singleton(Service::class, function ($app) {
        return new Service($app['request']);
    });
}

例子中採用 singleton 註冊一個單例物件 Service,當該物件在某個 Provider 的 Boot 方法被初始化時,應用容器中將始終保持著唯一的 Service 物件;後續 Worker 在處理的其他請求時,從 Service 中獲取的 request 物件將是相同的。

解決方法是你可以換一種繫結方式,或者使用閉包。最值得推薦的辦法是隻傳入你需要的請求資訊。

use App\Service;

$this->app->bind(Service::class, function ($app) {
    return new Service($app['request']);
});

$this->app->singleton(Service::class, function ($app) {
    return new Service(fn () => $app['request']);
});

// Or...

$service->method($request->input('name'));

強烈推薦讀者閱讀官方提出的注意事項。如果你覺得文章對你有幫助,你也可以訂閱作者的部落格 RSS 或直接訪問作者部落格 二愣的閒談雜魚

參考

本作品採用《CC 協議》,轉載必須註明作者和本文連結
二愣的閒談雜魚

相關文章