改善 PHP 測試階段的效能(二):ParaTest 與環境層加速
延續改善 PHP 測試階段的效能,當時講的是「程式 / 參數調校」(xdebug 關掉、phpcs --parallel、生命週期管理)的加速。本篇紀錄後續走過的三條路:
- PHPUnit → ParaTest 切換:從單一 process 的 PHPUnit 換成 ParaTest 多 worker 平行
- 環境層加速:schema 合併、MySQL datadir 改 tmpfs
- FormRequest 抽出單元測試:把驗證邏輯從 Feature Test 拉出來,改用不啟動 Laravel app 的單元測試
這篇文章由 Claude Opus 4.7 模型產生,人工微調內容完成。
關於本篇數據的免責說明
文章裡會列出不少「before / after」實測數字,但這些實測並不全都是當下做的,有的是寫這篇文章的時候才補測。所以:
- 時間軸跨度大,每次調整時的測試規模、相依套件版本、本機環境都不完全一樣(例如 PCOV 是後期才裝的,早期紀錄沒這層加速)
- 部分數字是當時做完隨手記下來的,部分是寫這篇文章時刻意回頭重跑量出來的 —— 基準不總是同一個
- 因此各章節的 before / after 在「該章節內部」可比,但跨章節的數字不該直接相減做加總;後面會用獨立貢獻表來標示
把這個前提先放在前面,免得大家看到某些數字對不起來覺得奇怪。
加速分成三層、成本由低到高
把這次做的調整整理成三個層級,由淺入深:
| 層級 | 動到什麼 | 成本 | 範例 |
|---|---|---|---|
| 第一段:只調設定,不動程式碼 | Makefile、docker-compose、PHP extension | 低,不動任何程式碼,團隊看不見 | 靜態分析多核 / cache、PCOV 偵測、tmpfs |
| 第二段:改測試 code 與流程 | 測試檔、phpunit.xml、ServiceProvider、schema 維護方式 | 中,動測試 / 流程但不動 production | 切換 ParaTest、schema 合併 |
| 第三段:改 production code | app/ 下面的程式碼 | 高,要思考相容性與設計 | FormRequest 抽出單元測試 |
由低到高做,每一層做到底再考慮往上爬。理由很單純:低成本的調整風險最小,做完就有回報;改到 production code 那層才會碰到「為了測試效能,要不要承擔改壞 production 的風險」這個取捨。
第一篇講的調整都在第一段。本篇延續這條軸,把第一段沒做完的補上,然後往第二、第三段走。
專案規模約 9000 個測試、Laravel 12 + MySQL,本機 M1 Pro 10 核,搭配 CI 跑。
第一段:只調設定,不動程式碼
這段不動任何程式碼,只調 Makefile、docker-compose 與 PHP extension。改動範圍小、團隊基本看不見。
先處理三個第一篇沒做完的靜態分析尾巴 —— parallel-lint、phpcs、phpstan 各有自己的多核 / cache 機會。
parallel-lint 的 -j 要配合 CPU 核數,不是越大越好
parallel-lint 預設是 -j 10(固定 10 個 process)。這個預設值在多核機器上很合理,但在 CPU 核數比 10 少的環境裡反而會拖慢 —— 10 個 process 搶 2 顆核,context switch 的成本超過並行的收益。
改成依環境的核數動態決定:
${PHP_INTERPRETER} ${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE} \
vendor/bin/parallel-lint -j ${PROCESSORS_NUM} app tests srcPROCESSORS_NUM = $(shell getconf _NPROCESSORS_ONLN) 取系統實際核數。本機 10 核就跑 10、CI 2 核就跑 2,每個環境都吃滿但不超載。
這個 Makefile 的
${PROCESSORS_NUM}同時用在 phpcs、phpcbf、ParaTest 上,效果一致:本機快、CI 不會因為 process 太多而退化。「越多越好」是直覺陷阱,並行度應該對齊硬體現實。
phpcs --cache 體感差最大
第一篇講過 phpcs --parallel,但 --cache 沒開:
${PHP_INTERPRETER} ${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE} \
vendor/bin/phpcs --parallel=${PROCESSORS_NUM} \
--cache=.cache/phpcs/.phpcs.cache ${TARGET_FILES}
--cache 那行很有感:第一次跑滿,後面只跑改過的檔案。實測本機(10 核)make phpcs:
- 第一次(cache miss):8.41s
- 第二次(cache hit,無檔案變更):343ms
差異 24 倍。對於「本機 commit 前跑一下」的情境,從「等一下」變成「按下 Enter 已經跑完」,體感差很多。
CI 上(2 核)的 cache miss 估計會落在 50~60s(核數比約 1:5),但 cache hit 後的 343ms 級數不會變太多 —— 這也是為什麼 cache 在 CI 上的相對價值更高:少跑一次全量等於省幾十秒。
--cache從 PHP_CodeSniffer 3.0.0a1(2016-07-20) 就支援了,但很多專案 Makefile 沒開。屬於「資訊一直在那但容易錯過」的那種優化。
phpstan 的 cache 也是免費午餐
phpstan 跟前兩者不太一樣:多核並發是預設行為(會自動偵測 CPU 數),不用手動加 --parallel。但 cache 可以靠預設的 .phpstan.result.cache/,第二次跑只分析改過的檔案以及它的相依檔。
第一篇講過「只跑 git add 的檔案」加速 phpcs,這個邏輯也適用於 phpstan:
.PHONY: fast-stan
fast-stan:
${PHP_INTERPRETER} ${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE} \
vendor/bin/phpstan analyse ${CACHED_PHP_FILES}
CACHED_PHP_FILES 來自 git diff --cached --name-only --diff-filter=ACM | grep -e '^app' -e '^tests'。
實測 make stan(完整全量分析、Larastan level 9,沒走 git diff):
- 全新執行(cache miss):78.9s
- 第二次(cache hit,無檔案變更):6.95s
11 倍加速。這個差異對「本機 commit 前跑一下」很重要 —— 78 秒會讓人乾脆不跑,7 秒就會願意每次都跑。
Larastan level 9 是「除了最嚴格的 level 10 以外」最嚴格的層級,啟用了所有規則。level 對分析時間的影響沒有官方說明,社群也有「不同 level 跑出來時間差不多」的回報(討論串),實務上 parsing / autoloading / type inference 通常是主要成本,rule check 本身相對便宜。所以絕對時間 78.9s 不見得能直接拿來對比你的專案,但 cache 帶來的相對加速 應該可以。
PCOV / Xdebug 偵測,不要硬寫死 -d xdebug.mode=off
第一篇推薦 php -d xdebug.mode=off vendor/bin/phpunit。但隨著環境差異,越來越多開發者改裝 PCOV(產覆蓋率比 Xdebug 快很多),這時 -d xdebug.mode=off 會收到 PHP warning「Unknown extension ‘xdebug’」,CI log 變髒。
改成偵測:
PHP_HAS_XDEBUG_EXTENSION := $(shell ${PHP_INTERPRETER} -r 'echo extension_loaded("xdebug") ? 1 : 0;' 2>/dev/null)
PHP_HAS_PCOV_EXTENSION := $(shell ${PHP_INTERPRETER} -r 'echo extension_loaded("pcov") ? 1 : 0;' 2>/dev/null)
ifeq ($(PHP_HAS_PCOV_EXTENSION),1)
PHP_DISABLE_COVERAGE_MODE := -d pcov.enabled=0
else ifeq ($(PHP_HAS_XDEBUG_EXTENSION),1)
PHP_DISABLE_COVERAGE_MODE := -d xdebug.mode=off
else
PHP_DISABLE_COVERAGE_MODE :=
endif「跑測試時關掉 coverage 收集」這件事,對應到 PCOV 是 -d pcov.enabled=0,對 Xdebug 是 -d xdebug.mode=off,沒裝就別加。同樣的邏輯在「啟用 coverage」的 PHP_ENABLE_COVERAGE_MODE 也對等做一份。
MySQL:datadir 改 tmpfs、移除 binlog、InnoDB 弱化
CI 容器跑完即丟,DB 沒有「持久化」需求 —— 這代表 disk I/O 都是無謂成本。一次把這個方向能做的都做了:
# docker-compose.yml
database:
image: mysql
tmpfs:
- /var/lib/mysql
volumes:
- ./database/schema:/docker-entrypoint-initdb.d:ro
command: [
--character-set-server=utf8mb4,
--collation-server=utf8mb4_unicode_ci,
--innodb-flush-log-at-trx-commit=0,
--innodb-flush-method=nosync,
--innodb-doublewrite=0,
--sync-binlog=0,
--skip-innodb-checksums,
]三件事疊在一起:
/var/lib/mysql掛 tmpfs:datadir 整個放 RAM,disk 寫入物理成本歸零- 移除 binlog(拿掉
--log-bin與--server-id):CI 沒有 replication / point-in-time recovery 需求,binlog 寫入是純成本 - InnoDB 弱化參數:commit 不等 fsync、關掉 doublewrite、跳過 checksum
引擎仍是 InnoDB,transaction、FK、TEXT 全部維持,測試行為零改動。團隊不需要知道這件事,本機 make up 行為也一樣。
實測(純 master + 這三件事,不含 schema 合併):
| 階段 | 原始 | 套用後 |
|---|---|---|
make up | 31.4s | 23.6s(-25%) |
make test | 36.5s | 35.6s |
一個觀察:InnoDB 弱化參數本身在 tmpfs 之上幾乎無加成。先前在「tmpfs 已經啟用」的狀態下單獨打開 InnoDB 弱化,
make up從 23.4s 變 23.6s,在誤差範圍。原因:tmpfs 已經把所有寫入導向 RAM,commit 時的 fsync 本來就不會打到實體 disk。但移除 binlog 是真實有效的 —— binlog 寫的是邏輯複本(每筆 SQL 變更),就算 datadir 在 tmpfs 上,binlog 仍會被寫進去並消耗 CPU 與記憶體頻寬。CI 不需要 replication 場景,直接拿掉。
疊加優化要先確認上一層瓶頸還在:tmpfs 解決了 disk I/O,但沒解決 binlog 寫入;移除 binlog 後 InnoDB 弱化就空轉。順序很重要。
第二段:改測試 code 與流程
第一段的天花板很快會到,畢竟參數能調的就那些。要再壓下去就得動測試 code、測試框架的接線設定,或團隊共識的流程(例如怎麼維護 schema)。這段比第一段有感,但成本還是遠低於改 production code。
Schema 合併成一份最終態
CI 跑 make up 拆出來看:
flowchart LR
A["docker compose up\n5–6s"] --> B["sleep 3\n3s"] --> C["setup-paratest-databases.sh\n22s+"]
style C fill:#c0392b,color:#fff
容器啟動本身不到 6 秒(mysql 慢啟動是大宗),剩下 25 秒幾乎都在 setup-paratest-databases.sh 把 70+ 個 schema 檔依序餵給 10 個 worker DB。
這 70+ 個 schema 檔是什麼? 一個 base SQL 加上 50+ 個 patch SQL,每個 patch 都是一次「結構演進」(ALTER TABLE 加欄位、加 index 等)。歷史上是合理的,但對「測試環境每次重建」這個情境而言:
- 每個 patch 都要解析、執行
- 結構是透過「base + N 次 ALTER」逐步逼近最終狀態的,重複工很多
最直覺的做法:與其讓資料庫從 base 跑到最終態,不如直接給最終態。「最終態」怎麼產生?docker-compose 啟動完 mysql 跑完所有 patch 後,DB 裡就是答案,直接 mysqldump 出來:
docker exec -i db mysqldump -uroot -ppass \
--no-data --skip-comments --skip-add-drop-table --skip-set-charset \
--skip-lock-tables --skip-triggers \
--ignore-table=main_db.some_unused_view \
--databases main_db \
> database/schema/main_db.sql幾個細節:
--no-data:只要 schema,不要資料--ignore-table:少數 view 因為 definer 已不存在會卡住。剛好 grep 確認 code 完全沒用,直接拋棄- 多個 MySQL connection 中只有最大那個有大量 patch(50+),其他幾個只有少量 patch、影響很小先不動
合併後 schema 檔從 70+ 個降到 5 個,make up 從 31.4s 降到 22.4s(約 -29%)。和 tmpfs 疊加效果獨立(兩者攻不同瓶頸),詳見後面「環境層改動的獨立貢獻」。
為什麼這個算第二段、第一段不算
第一段的 tmpfs / binlog / InnoDB 弱化都只動 docker-compose,團隊感覺不到:本機 make up、CI make up 行為一樣、看到的 schema 檔結構也一樣,只是執行更快。
但 schema 合併不一樣 —— schema 從「base + N 次 patch」變成「單一最終態」會影響團隊未來的 schema 維護流程:以後有 schema 變更不能直接丟 patch 檔,要重新 dump 或同步維護兩份。這是團隊共識變更,所以歸在第二段。
「合併後容易讓 schema 演進的可讀性下降」是個合理的擔憂,但測試環境的 schema 跟 production 演進可以分開管:production 仍走 patch 演進(這是審查歷史和回滾用的),測試環境只需要最終態。
最後決定:先 revert,留作討論
實作完跑了一輪比較,發現雖然加速明顯(-9 秒),但合併 schema 對團隊現有的 schema 維護流程衝擊夠大 —— 必須有共識「以後測試環境的 schema 怎麼管」才能落地,不是技術上可行就直接合併。
所以這個改動最後 revert 回去,先不採用,但測試紀錄保留下來給後續討論。也是這篇文章把它放在第二段、而不是與第一段一併視為「順手就做」的原因。
PHPUnit → ParaTest 切換
換掉 PHPUnit、改用 ParaTest(透過 Laravel 11+ 的 artisan test --parallel)的 Makefile diff:
- ${PHP_INTERPRETER} ${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_XDEBUG_CONFIG} \
- vendor/bin/phpunit --no-coverage --stop-on-failure --stop-on-error
+ ${PHP_INTERPRETER} ${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE} \
+ artisan test --parallel --without-databases --processes=${PROCESSORS_NUM} \
+ --no-coverage --stop-on-failure --stop-on-error \
+ --passthru-php="${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE}"
看起來只是換指令,實際上要踩過幾個坑:
1. --passthru-php 把 PHP 設定傳給子 process
ParaTest 會 fork N 個子 process 跑測試。父 process 用 -d 帶的 PHP 設定,子 process 不會繼承。
如果不帶 --passthru-php,每個 worker 起來時 xdebug / pcov 還是會自己載入,效能直接退化回沒關 xdebug 的速度。所以:
artisan test --parallel \
--processes=${PROCESSORS_NUM} \
--passthru-php="${PHP_GLOBAL_CONFIG} ${PHP_DISABLE_COVERAGE_MODE}"--passthru-php 會把指定的 PHP 參數傳到每個 worker process。
2. --without-databases 配合自家 paratest setup
Laravel 內建的 --parallel 會自動切換 default connection 為 _test_{token} worker DB。但本專案有多個 MySQL connection,內建邏輯處理不來。
加上 --without-databases 阻止 Laravel 自動處理,改用自定義的 paratest setup 流程,針對每個 connection 為每個 worker 各建一份 schema。同時搭配 ParallelTestingServiceProvider 在每個 test case 開始時把 config 切到 worker DB:
// 簡化版(只示意主 connection,實務上每個 connection 都要切)
ParallelTesting::setUpTestCase(function ($testCase, ?string $token) {
if (empty($token)) return;
$database = Config::get('database.connections.main_db.database');
Config::set('database.connections.main_db.database',
$database . '_test_' . $token);
DB::purge('main_db');
});第二段成果
本機(M1 Pro 10 核)實測:
| 階段 | 切換前(單一 PHPUnit) | 切換後(ParaTest) |
|---|---|---|
make test | 4m32s | 27.5s |
make up | 9.0s | 27.7s |
make test 從 4 分半降到 27 秒,約 10 倍的加速,10 核紅利吃得很滿。
但 make up 變慢了 —— 從 9 秒拉到 27 秒。原本只是拉 image + 起服務,現在多了「為 10 個 worker 各建一份 schema」這道工。
實作這段時 AI 協助一開始是單執行緒在跑 schema 建立,慢得很合理;後來改成並發處理,效果其實改善很有限 —— ParaTest setup 的瓶頸在 mysql 本身對「同個 server 並發 import 大量 SQL」的處理能力,不是腳本的並發層。這也是第一段為什麼要花心思攻 make up 的原因。
CI(2-core)的場景比本機更艱難:核心數少,ParaTest 的多核紅利大打折扣,又得付建 schema 的成本,整體加速幅度比本機小很多 —— 但仍是淨贏。
第三段:改 production code
最貴的一段。動到 production code 就有相容性與設計取捨的成本,所以這段只有少量、目標明確的改動。本篇只講一個:把 FormRequest 的驗證從 Feature Test 拉出來、改用單元測試。
原本 Laravel 的 FormRequest 驗證邏輯都是用 Feature Test 測:發 HTTP request、驗 422 / 200 / 各種錯誤訊息。優點是場景貼近真實使用,缺點是貴 —— 每個 Request 規則都要起一次完整的 Laravel app、走完整 routing + middleware。一個 Request 可能對應 10+ 個測試案例,整體成本疊起來很可觀。
重構的方向是:把 Request 驗證從 Feature Test 拉出來,改用單元測試直接打 Validator,繼承 PHPUnit 原生 TestCase,不啟動 Laravel app(不需要 RefreshDatabase / DatabaseTransactions / route loading)。等價的 Feature test 移除。
過程中會動到 production code 的部分 —— 例如為了讓 Request class 在「不啟 Laravel」的情境下也能被測,要調整一些相依注入的拿法,或重新拆分 rule class 讓它們可以被獨立呼叫。這是這段成本比前兩段高的主因。
實測(同一個專案、做這個重構前後):
| 階段 | 重構前 | 重構後 |
|---|---|---|
make test | 5m21s | 4m44s |
| 測試數 | 5394 | 9528 |
| Assertions | 15013 | 19830 |
時間少了 37 秒,但測試數量幾乎翻倍(5394 → 9528)。看起來矛盾,原因是:
- 單元測試本身比 Feature 便宜很多,所以即使數量爆增整體還是更快
- CoverClass 嚴格設定下,原本 Feature Test 不會把 Request class 算進覆蓋率,改寫成單元測試後 Request 都有對應 CoversClass,coverage 也跟著上升
代價是「整合測試的覆蓋面變窄」—— 但整合測試本來該驗的是業務流程,不是 Request 規則的變形組合,這部分損失可以接受。詳細的單元測試思考可以參考淺談單元測試 - 簡介篇、淺談單元測試 - 實踐篇。
環境層改動的獨立貢獻
第一段的 tmpfs / InnoDB 弱化 / 移除 binlog,加上第二段的 schema 合併,這幾個都對 make up 有效,但彼此能不能疊加值得驗證。各種組合單獨量一次:
| 配置 | make up | make test |
|---|---|---|
| 原始(master) | 31.4s | 36.5s |
| 僅 schema 合併 | 22.4s | 36.5s |
| 僅 tmpfs | 23.4s | 36.0s |
| schema 合併 + tmpfs | 18.3s | 34.9s |
| tmpfs + InnoDB 弱化 + 無 binlog(最後決定的版本) | 23.6s | 35.6s |
| 純 InnoDB 弱化 + 無 binlog(無 tmpfs) | 25.2s | 35.8s |
幾個觀察:
- schema 合併與 tmpfs 攻不同瓶頸:schema 合併攻「SQL 解析與執行的次數」,tmpfs 攻「寫入的物理成本」,所以 -9s + -8s 幾乎獨立疊加成 -13s。
- InnoDB 弱化 / 無 binlog 在 tmpfs 之上幾乎沒加成:23.4s → 23.6s 在誤差範圍內。tmpfs 已經把所有寫入導向 RAM,commit 時的 fsync 不會打到實體 disk。
- 但「無 binlog」單獨拿出來在沒 tmpfs 時是有效的:純 master + InnoDB 弱化 + 無 binlog 把 31.4s 壓到 25.2s(-20%)。binlog 寫入是邏輯複本,就算 datadir 在 tmpfs 上,binlog 仍會被寫進去並消耗 CPU;如果環境沒掛 tmpfs(某些 DinD / runner),這幾個參數就有獨立價值。
最後決定保留 tmpfs + InnoDB 弱化 + 無 binlog 這組(純設定改動),schema 合併 revert(待團隊共識)。
走過但沒採用的路:主 connection 切到 SQLite in-memory
這是第三刀,也是最有意思的一段失敗紀錄。
想法
主要 connection 是測試最常用的(幾乎所有單元測試經過、20+ 個 table)。如果把它從「跨網路 socket 連 MySQL」換成「same-process in-memory SQLite」,理論上比 tmpfs 更快 —— 不只 disk 不見,連 socket 都不見。
一連串該處理的事
實作下來需要解的問題:
- 多 connection 但只切主 connection:
config/database.php改成主 connection 在 env 旗標為 sqlite 時切 sqlite,否則保持原本 mysql + read/write split;其他 connection 保持 mysql - MySQL schema 轉 SQLite schema:寫了個 perl 腳本處理整型寬度、
AUTO_INCREMENT、binary(N)→BLOB、enum(...)→TEXT、COMMENT '...'、ENGINE=、把 inlineKEY/UNIQUE KEY拆成獨立的CREATE INDEX :memory:是 connection-local:兩個 PDO 連:memory:是兩個獨立 db。Laravel 的DatabaseTransactionstrait 會在每個 test 結束disconnect(),下個 test 看到的是空 db。解法是用 URI 模式file::memory:?cache=shared&mode=memory,並在 ServiceProvider 持有一個常駐 PDO(anchor)防止 db 被釋放DB::connection('xxx::write')不能用:Laravel 的::write會建第二個 PDO,shared cache 下兩個 PDO 互鎖。專案有個取號 driver 用了這個語法,需要切到 in-memory 版本繞過- Snowflake driver 同秒撞號:原本
nextId沒 sequence bit,同 process 同秒連續呼叫會撞 PK constraint。寫了個測試專用 driver,毫秒精度 + processId + counter - ParaTest setup 跳過主 connection:worker DB 名替換邏輯改成只處理其他 mysql connection
- schema 載入時機:每個 worker process 啟動時載一次
走完上面這串,只剩 3 個測試 fail(其餘全綠),make test 從 34.9s 降到 28.4s(再 -19%)。
為什麼還是放棄
那 3 個 fail 都是業務行為差異,不是 SQL 錯:
- 字串大小寫比對:MySQL 的
utf8mb4_unicode_ci預設 case-insensitive,SQLite 預設 BINARY case-sensitive。有多個欄位的業務邏輯依賴大小寫不敏感查詢,在 SQLite 下找不到既有資料,走成 INSERT 變兩筆 - 排序 ties 不穩定:
ORDER BY status DESC, created_at DESC這類 query,兩筆 ties 完全相同時,MySQL 的「自然順序」剛好符合測試期待,SQLite 不一樣
第 1 個可以靠 schema 加 COLLATE NOCASE 解(要選擇性對使用者輸入的字串欄位加,不能對 hash/salt 也加)。第 2 個的根因是「production code 的 ORDER BY 沒給穩定 tie-breaker」,測試剛好依賴 MySQL 的自然順序,要改要動 production code。
走到這裡的判斷:
- 加速幅度:6.5 秒
- 維護成本:要新建並維護 SQLite schema(schema 演進時兩份都要改)、寫測試專用取號 driver、改 ServiceProvider 機制、處理 read/write 雙 PDO 鎖死、處理 collation 差異、未來新增測試要記得「不能依賴 collation」、「ORDER BY 要明確」
6.5 秒不值這個維護負擔。直接 revert,保留前兩刀的成果。
這也是經驗:當一個技術方案要求「以後寫程式都要小心一件事」,那個成本通常是被嚴重低估的。
回到 MySQL 後再單獨試了一次「保留 testing 取號 driver」(每個 factory()->create() 可省 1-2 次 DB round trip)。實測 make test 34.97s vs 原本 34.9s —— 誤差範圍。tmpfs 已經把那幾次 DB query 變得很便宜,再省也沒明顯差。也跟著移除了。
最終結果
按三段做完,整理本機(M1 Pro 10 核)實測:
第一段(只調設定,不動程式碼)—— 保留採用
| 項目 | 原始 | 套用後 |
|---|---|---|
make up | 31.4s | 23.6s(-25%) |
make test | 36.5s | 35.6s(≈ 持平) |
包含:靜態分析 cache(phpcs / phpstan)、parallel-lint 多核、PCOV / Xdebug 偵測、tmpfs、移除 binlog、InnoDB 弱化。團隊感覺不到差異,但 CI 變快。
第二段(改測試 code 與流程)—— 部分採用
| 改動 | 採用? | 影響 |
|---|---|---|
| 切換 ParaTest(含自家 paratest setup) | 採用 | make test 從 4m32s → 27.5s(10× 加速,10 核紅利吃滿) |
| Schema 合併成單一最終態 | revert | 加速明顯(make up -9s),但團隊 schema 維護流程衝擊大,待共識 |
第三段(改 production code)—— 採用
| 改動 | 採用? | 影響 |
|---|---|---|
| FormRequest 抽出單元測試 | 採用 | make test 從 5m21s → 4m44s,測試數從 5394 → 9528,coverage 也順帶上升 |
三段加總
每一段的數據基準不同(第二段 ParaTest 切換時專案規模約 5400 → 9500 個測試,第三段 Request 重構時又是另一個時間點),所以三段時間不能直接相加。但方向都對,每一段都壓掉了一塊。
帶走的觀察
- 由淺入深做加速:先改設定(無感)、再改測試 / 流程(中度成本)、最後才動 production code(高成本)。每一層都做到底再考慮往上爬,可以避免「為了 5% 的加速付 50% 的維護成本」這種失衡。
- 疊加優化前先確認上一層瓶頸還在:InnoDB 弱化在 tmpfs 之上空轉、testing 取號 driver 在 tmpfs 之上空轉、移除 binlog 在 tmpfs 之上也空轉 —— 但移除 binlog 在沒 tmpfs 時是真的有效。順序很重要,每一刀下去前都該想:「上一層瓶頸是不是已經被前面的改動解掉了?」
- 多核從工具擴展到整個流程:第一篇講的
phpcs --parallel是單一工具的多核,這篇的 ParaTest 把多核擴展到測試本身。下一個層級可能是讓 schema setup 也吃多核(雖然這次實測效果有限,瓶頸在 mysql 而不是腳本)。 - 加速是有上限的:SQLite 那輪是極致實驗,告訴我下個瓶頸已經不在 DB I/O 了,而在 PHP test runtime 本身。當改動需要全團隊「以後寫程式要小心 collation / ORDER BY」這種隱性成本時,不該做。
- 不是所有有效的改動都該採用:schema 合併量得出加速,但對團隊維護流程的衝擊夠大,最終 revert 等共識。技術上有效 ≠ 應該採用。
下次想再壓的話有兩個方向:
- PHP 測試端的細部調校:ParaTest process 數調整、找長尾測試、共用 fixture 等等。這些都還在「不太動 production code」的範圍。
- 盡量移除對 Laravel Test 的依賴:Laravel 的
TestCase每跑一次都要起一個應用程式實例,成本不便宜。如果能讓更多測試繼承 PHPUnit 原生TestCase(像第三段對 FormRequest 做的),啟動成本會大幅下降。但這條路會更深入動到 production code 的設計 —— 比如服務怎麼被注入、相依怎麼解析、static facade 怎麼被替換 —— 不像 FormRequest 那樣有明確邊界,要花更多力氣思考相容性。
但兩個都會是另一輪實驗了。
延伸閱讀:
- 改善 PHP 測試階段的效能 —— 程式 / 參數面的加速