Câu "PHP chết sau mỗi request" được lặp lại nhiều đến mức nhiều dev tin rằng mỗi request đều khởi động lại toàn bộ PHP. Thực tế không phải vậy. Thứ bị dọn sau mỗi request là request state: biến, object, static property. Process PHP-FPM vẫn sống, và OPcache vẫn giữ bytecode trong shared memory. Hiểu đúng ranh giới này giúp bạn tune server có cơ sở, đọc đúng số liệu, và tránh một loại bug rất nguy hiểm khi chuyển sang worker mode. Bài này đi qua những gì thực sự xảy ra bên trong Zend Engine, kèm các lệnh và config dùng được ngay với PHP 8.4 trở lên.
Điều gì sống, điều gì chết sau một request?
PHP có hai vòng đời lồng nhau. Vòng ngoài là của process: module startup (MINIT) chạy khi process khởi động, module shutdown (MSHUTDOWN) chạy khi process tắt. Vòng trong là của request: RINIT ở đầu request, RSHUTDOWN ở cuối request. Với PHP-FPM, vòng trong lặp lại nhiều lần trong khi vòng ngoài chỉ chạy một lần cho mỗi worker.
Nếu không có OPcache, mỗi request phải đi qua cả pipeline: đọc file, lexing, parsing ra AST, compile thành opcode, rồi Zend VM mới thực thi. Với một app Laravel hoặc Symfony load hàng trăm file, phần compile này bị lặp lại vô ích. OPcache lưu opcode vào shared memory để các worker dùng chung, nên request sau bỏ qua được gần hết pipeline.
flowchart TD
A[Request den PHP-FPM worker] --> B[RINIT: khoi tao request state]
B --> C{Opcode co trong OPcache?}
C -- Co --> F[Zend VM thuc thi opcode]
C -- Khong --> D[Lexer, Parser, AST]
D --> E[Compile va luu vao OPcache]
E --> F
F --> G[RSHUTDOWN: don request state]
G --> H[Worker cho request tiep theo]
Điểm cần nhớ: OPcache chỉ bỏ qua bước compile. Code bootstrap vẫn chạy lại mỗi request, gồm đăng ký service provider, load config, resolve container. Đây là lý do một app framework lớn đã bật OPcache vẫn có thể mất vài chục millisecond trước khi chạm vào business logic.
Tune OPcache và PHP-FPM dựa trên số thật
Config production mình thường bắt đầu với:
; /etc/php/8.4/fpm/conf.d/10-opcache.ini
opcache.enable=1
opcache.memory_consumption=256
opcache.interned_strings_buffer=32
opcache.max_accelerated_files=20000
; Không stat file mỗi request, nhưng BẮT BUỘC reload khi deploy
opcache.validate_timestamps=0
Đây chỉ là điểm xuất phát. Sau khi app chạy một thời gian, hãy kiểm tra số liệu thật. Lưu ý là CLI và FPM dùng hai vùng OPcache riêng, nên chạy php -r trên terminal sẽ không cho biết gì về FPM. Hãy dùng cachetool kết nối trực tiếp vào FPM socket, hoặc tạo một endpoint nội bộ có bảo vệ:
<?php
// Chỉ mở cho mạng nội bộ, không public endpoint này
$status = opcache_get_status(false);
$stats = $status['opcache_statistics'];
$memory = $status['memory_usage'];
header('Content-Type: application/json');
echo json_encode([
'hit_rate' => round($stats['opcache_hit_rate'], 2),
'cached_scripts' => $stats['num_cached_scripts'],
'oom_restarts' => $stats['oom_restarts'],
'hash_restarts' => $stats['hash_restarts'],
'used_mb' => round($memory['used_memory'] / 1048576, 1),
'free_mb' => round($memory['free_memory'] / 1048576, 1),
'wasted_percent' => round($memory['current_wasted_percentage'], 2),
]);
Cách đọc kết quả:
-
oom_restartstăng: thiếumemory_consumption, cần tăng lên. -
hash_restartstăng:max_accelerated_filesquá thấp so với số file của app. -
hit_ratethấp kéo dài sau warm-up: kiểm tra xem có chỗ nào reset cache hoặc restart worker quá thường xuyên không.
Với PHP-FPM, sai lầm phổ biến nhất là đặt pm.max_children theo cảm tính. Hãy đo memory rồi tính:
# Đo RSS trung bình của các FPM worker (MB)
ps --no-headers -o rss -C php-fpm8.4 | awk '{s+=$1} END {print s/NR/1024 " MB"}'
# Kiểm tra config hiện tại có hợp lệ không
sudo php-fpm8.4 -t
# Reload nhẹ nhàng sau khi deploy, đồng thời làm mới OPcache
sudo systemctl reload php8.4-fpm
Ví dụ server 4 GB, dành 1 GB cho OS và Nginx, mỗi worker khoảng 60 MB, ta có 3000 / 60 = 50 workers. RSS có tính cả phần shared memory của OPcache nên con số này hơi cao hơn thực tế, nhưng sai số đó nghiêng về phía an toàn. Thêm pm.max_requests = 1000 để worker tự tái sinh định kỳ, phòng khi có extension hoặc thư viện bị memory leak.
Về JIT: từ PHP 8.4, giá trị mặc định của opcache.jit là disable. JIT hữu ích cho tác vụ nặng CPU như xử lý ảnh hoặc tính toán. Với app CRUD mà phần lớn thời gian là chờ database và network, lợi ích thường không đáng kể. Hãy benchmark với chính workload của bạn trước khi bật.
Worker mode: khi request state không còn được dọn giúp
FrankenPHP, Laravel Octane (chạy trên FrankenPHP, Swoole hoặc RoadRunner) giải quyết phần bootstrap lặp lại: app chỉ khởi động một lần, sau đó giữ trong memory và xử lý nhiều request liên tiếp.
sequenceDiagram
participant C as Client
participant W as Worker
participant A as App trong memory
W->>A: Bootstrap mot lan
C->>W: Request 1
W->>A: Handle
A-->>C: Response 1
C->>W: Request 2
W->>A: Handle, dung lai state cu
A-->>C: Response 2
Đổi lại, bạn mất đi lớp bảo vệ lớn nhất của PHP truyền thống: state tự biến mất cuối request. Đoạn code dưới đây vô hại trên FPM nhưng có thể gây lộ dữ liệu giữa các khách hàng trong worker mode:
<?php
class CurrentTenant
{
private static ?int $id = null;
public static function resolve(Request $request): int
{
// Request đầu tiên gán giá trị, các request sau của
// tenant khác vẫn nhận lại ID cũ trong cùng worker
return self::$id ??= $request->tenantId();
}
}
Đây không chỉ là bug hiển thị sai. Trong hệ thống multi-tenant, nó có thể khiến tenant A đọc được dữ liệu của tenant B. Trước khi migrate, hãy rà soát các điểm sau:
-
staticproperty và biếnstaticdùng làm cache. - Singleton trong container giữ reference đến request, user hoặc tenant.
- Mảng cache trong object sống lâu, vì chúng tăng dần và gây memory leak.
- Database connection bị server đóng sau thời gian idle dài, và transaction chưa kết thúc.
Với Laravel, cách thử nhanh là chạy Octane ở staging rồi load test với nhiều user khác nhau:
composer require laravel/octane
php artisan octane:install --server=frankenphp
php artisan octane:start --server=frankenphp --workers=4 --max-requests=500
Cờ --max-requests là lưới an toàn cho memory leak, nhưng không sửa được lỗi rò state giữa các request. Test nên gửi request xen kẽ từ hai user hoặc hai tenant, rồi assert rằng response của mỗi bên chỉ chứa dữ liệu của chính họ.
Chọn mô hình nào?
Không phải app nào cũng cần worker mode. Quy tắc mình hay dùng:
- App CRUD nhỏ và vừa, traffic ổn định: PHP-FPM cộng OPcache được tune đúng là đủ, vận hành đơn giản và ít rủi ro.
- Bootstrap chiếm phần đáng kể trong latency, codebase kiểm soát tốt: thử worker mode trên một service hoặc một nhóm route trước.
- Code legacy nhiều global state, thư viện cũ: sửa state trước, chưa nên migrate. Tiết kiệm vài chục millisecond không đáng để đổi lấy rủi ro lộ dữ liệu.
Để biết bootstrap có thực sự là vấn đề hay không, hãy đo bằng APM hoặc profiler như Blackfire, SPX trước khi quyết định. Nếu phần lớn thời gian nằm ở query SQL, đổi runtime gần như không giúp gì; thêm index sẽ hiệu quả hơn nhiều.
Kết luận
PHP không chết sau mỗi request; chỉ request state bị dọn đi. Từ hiểu biết đó, bạn có thể làm ngay những việc sau:
-
Kiểm tra OPcache trên FPM thật bằng
opcache_get_status()hoặccachetool, theo dõioom_restartsvàhash_restarts. -
Đặt
validate_timestamps=0ở production và đưasystemctl reload php8.4-fpmvào script deploy. -
Tính
pm.max_childrentừ memory đo được, không copy config từ blog, kể cả blog này. - Profile trước khi đổi runtime để biết bootstrap có đáng tối ưu không.
- Nếu dùng worker mode, audit state dùng chung và viết test request xen kẽ giữa nhiều tenant trước khi lên production.
Mô hình shared-nothing là một trong những điểm mạnh bị đánh giá thấp nhất của PHP: mỗi request bắt đầu với state sạch. Khi rời bỏ nó để lấy hiệu năng, hãy chắc rằng bạn biết mình đang đổi lấy điều gì.
Top comments (0)