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_restarts tăng: thiếu memory_consumption, cần tăng lên.
  • hash_restarts tăng: max_accelerated_files quá thấp so với số file của app.
  • hit_rate thấ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:

  • static property và biến static dù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:

  1. Kiểm tra OPcache trên FPM thật bằng opcache_get_status() hoặc cachetool, theo dõi oom_restarts và hash_restarts.
  2. Đặt validate_timestamps=0 ở production và đưa systemctl reload php8.4-fpm vào script deploy.
  3. Tính pm.max_children từ memory đo được, không copy config từ blog, kể cả blog này.
  4. Profile trước khi đổi runtime để biết bootstrap có đáng tối ưu không.
  5. 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ì.