All posts
Vì sao website "gục ngã" giờ cao điểm — và cách tính config PHP-FPM, Nginx, OPcache cho đúng
phpnginxweb

Vì sao website "gục ngã" giờ cao điểm — và cách tính config PHP-FPM, Nginx, OPcache cho đúng

Trung Ngo T.'s avatarTrung Ngo T.
Table of Contents21 sections

Đa số sự cố 5xx lúc traffic dồn về không phải vì "server yếu", mà vì các tham số mặc định chưa bao giờ được tính lại theo đúng hệ thống. Bài viết này chỉ ra chính xác con số nào quyết định, công thức tính ra sao, và cách dùng OPcache để tăng tốc gần như miễn phí.

19h tối. Chiến dịch flash sale vừa mở. Traffic tăng gấp 5 lần và dashboard bắt đầu nhuộm đỏ: 502, 503, 504 xen kẽ nhau. Reload thì lúc được lúc không. Điều khó chịu nhất: CPU mới chỉ 40%, RAM còn trống, log MySQL sạch sẽ — mà người dùng vẫn nhận lỗi.

Nghịch lý này gặp ở rất nhiều hệ thống PHP, và gần như luôn quy về một nguyên nhân: tài nguyên còn dư, nhưng số "khe" xử lý đồng thời thì đã cạn. Server không nghẽn vì hết sức — nó nghẽn vì bị giới hạn bởi một vài con số trong file cấu hình mà từ lúc cài đặt đến giờ chưa ai động vào.

Để chỉnh đúng những con số đó, trước hết phải nhìn rõ một request thực sự đi qua đâu.


01. Một request đi qua đâu trước khi trả lỗi?

Trong stack PHP điển hình, Nginx không tự chạy code PHP. Nó nhận request, rồi chuyển phần PHP qua giao thức FastCGI cho một tiến trình riêng là PHP-FPM (FastCGI Process Manager). PHP-FPM duy trì một "pool" gồm nhiều tiến trình con (worker), mỗi worker chỉ xử lý đúng một request tại một thời điểm. Hiểu được điều này là đã giải quyết được một nửa vấn đề.

Điểm mấu chốt: nếu pool có pm.max_children = 10 thì hệ thống chỉ chạy được tối đa 10 request PHP cùng lúc, bất kể bạn còn 8GB RAM hay 16 nhân CPU. Request thứ 11 phải xếp hàng; xếp hàng quá lâu sẽ thành 504; hàng đợi tràn sẽ thành 502. Server "rảnh" mà vẫn lỗi là vì vậy.


02. Đọc vị lỗi 4xx/5xx: mỗi mã "tố cáo" một điều

Thay vì hoảng loạn khi thấy lỗi, hãy đọc chúng như chẩn đoán. Mỗi mã chỉ thẳng tới một nút trong sơ đồ trên.

Mã lỗi

Nghĩa thực tế khi quá tải

Nghi phạm hàng đầu

502

Nginx kết nối được PHP-FPM nhưng không nhận được phản hồi hợp lệ.

Pool cạn worker + hàng đợi tràn, hoặc PHP-FPM crash / fatal error.

503

Dịch vụ tạm thời không phục vụ được.

Quá tải, hoặc bị chặn bởi limit_req / upstream không còn node sống.

504

PHP-FPM nhận request nhưng trả lời quá chậm.

Query chậm, vòng lặp nặng, gọi API ngoài treo — vượt fastcgi_read_timeout.

429

Quá nhiều request trong thời gian ngắn.

Rate limit chủ động (đôi khi là tốt — nó đang bảo vệ pool).

499

Client đóng kết nối trước khi server trả lời (mã riêng của Nginx).

Người dùng bỏ cuộc vì chờ lâu — hệ quả gián tiếp của 504.

💡 Dấu hiệu vàng trong log. Khi pool cạn, PHP-FPM ghi rõ trong log của nó. Tìm đúng dòng này trước khi đoán bất cứ điều gì:

[pool www] server reached pm.max_children setting (10), consider raising it

Thấy dòng này nghĩa là vấn đề không nằm ở MySQL hay code — mà ở số khe xử lý. Chuyển thẳng sang phần 03.


03. PHP-FPM: nút thắt cổ chai thật sự

Đây là nơi 80% các vụ 5xx giờ cao điểm được sinh ra và cũng là nơi được sửa. Trước hết là cách pool tự quản lý số tiến trình.

Chọn chế độ pm: static, dynamic hay ondemand

Chế độ

Hành vi

Hợp với

static

Tạo sẵn đúng max_children tiến trình, giữ nguyên.

Server chuyên dụng chạy PHP, traffic ổn định cao. Độ trễ thấp nhất.

dynamic

Co giãn số tiến trình trong khoảng spare đã đặt.

Lựa chọn mặc định an toàn cho phần lớn web app.

ondemand

Không tạo tiến trình nào tới khi có request; tắt khi rảnh.

Server ít RAM, nhiều site, traffic thấp/thưa.

pm.max_children — tham số quan trọng nhất của toàn bộ stack

Số request PHP tối đa được xử lý đồng thời. Đặt thấp → 502/504 dù tài nguyên còn dư. Đặt cao → các tiến trình ăn hết RAM, kernel kích hoạt OOM-killer và giết tiến trình ngẫu nhiên (thường là MySQL). Phải tính, không đoán.

Công thức tính pm.max_children

Ý tưởng đơn giản: lấy phần RAM thực sự dành cho PHP, chia cho lượng RAM trung bình một tiến trình PHP tiêu thụ.

# Bước 1 — RAM còn lại cho PHP sau khi chừa cho OS và DB
RAM_cho_PHP = RAM_tổng − RAM_cho_OS − RAM_cho_MySQL/Redis

# Bước 2 — chia cho mức tiêu thụ trung bình mỗi tiến trình
pm.max_children = RAM_cho_PHP / RAM_trung_bình_mỗi_process

Đừng đoán "RAM trung bình mỗi process". Đo trực tiếp trên chính server đang chạy:

# Trả về: số tiến trình hiện có và RAM trung bình (MB) mỗi tiến trình
ps --no-headers -o "rss,cmd" -C php-fpm8.2 \
  | awk '{ sum+=$1 } END { printf("%d process, trung binh %.0f MB\n", NR, sum/NR/1024) }'

Cách RAM được "chia khẩu phần" trên một server 4GB:

⚠️ Bẫy thường gặp. Đừng tính max_children trên RAM tổng. MySQL, Redis, cron, hệ điều hành đều cần phần của mình. Nếu PHP "ăn" lấn sang, OOM-killer sẽ ra tay và thường giết đúng MySQL — biến một sự cố nhỏ thành sập toàn hệ thống.

Các tham số đi kèm (chế độ dynamic)

Bốn tham số còn lại điều khiển cách pool co giãn. Ràng buộc bắt buộc: min_spare ≤ start_servers ≤ max_spare ≤ max_children.

; /etc/php/8.2/fpm/pool.d/www.conf
pm = dynamic
pm.max_children = 40        ; trần tuyệt đối — tính từ RAM ở trên
pm.start_servers = 10       ; số tiến trình lúc khởi động
pm.min_spare_servers = 8    ; luôn giữ tối thiểu bấy nhiêu tiến trình rảnh
pm.max_spare_servers = 20   ; dọn bớt khi rảnh vượt mức này
pm.max_requests = 500       ; restart worker sau 500 request → chống rò rỉ bộ nhớ

💡 Vì sao cần max_requests. Một số extension hoặc code rò rỉ bộ nhớ chậm theo thời gian. pm.max_requests cho worker "tự làm mới" định kỳ, giải phóng RAM mà không cần can thiệp tay. Đặt 300–1000 là hợp lý cho phần lớn app.


04. Nginx: người gác cổng ít được để ý

Nginx hiếm khi là thủ phạm chính, nhưng cấu hình mặc định có vài chỗ âm thầm bóp băng thông và tạo ra 504 không đáng có.

Số kết nối tối đa cùng lúc

max_connections = worker_processes × worker_connections

# Nginx làm reverse proxy: mỗi client dùng 2 kết nối (client ↔ nginx ↔ upstream)
# → số client thực tế ≈ max_connections / 2

worker_processes auto; — Đặt auto để Nginx tạo đúng một worker cho mỗi nhân CPU, không hơn. Nhiều worker hơn số nhân chỉ tạo thêm chuyển ngữ cảnh, không tăng thông lượng.

Hai tham số ít người chỉnh nhưng đáng giá

keepalive tới upstream (PHP-FPM). Mặc định, Nginx mở rồi đóng kết nối tới PHP-FPM cho mỗi request. Bật keepalive trong khối upstream để tái sử dụng kết nối — giảm độ trễ rõ rệt khi traffic cao. Đây là một trong những thay đổi "một dòng" cho hiệu quả lớn nhất.

# /etc/nginx/nginx.conf
upstream php_fpm {
    server unix:/run/php/php8.2-fpm.sock;
    keepalive 32;            # số kết nối rảnh giữ lại để tái dùng
}

location ~ \.php$ {
    fastcgi_pass php_fpm;
    fastcgi_keep_conn on;     # bắt buộc để keepalive ở trên có hiệu lực
    include fastcgi_params;
}

worker_rlimit_nofile & somaxconn. Mỗi kết nối tốn một file descriptor. Nếu giới hạn FD thấp hơn số kết nối, Nginx báo too many open files và rớt request. Đặt worker_rlimit_nofile lớn hơn worker_connections. Đồng thời nâng net.core.somaxconn của kernel để hàng đợi kết nối đủ sâu, tránh rớt ngay ở tầng TCP lúc cao điểm.

Timeout & buffer — bộ đôi trị 504

504 xảy ra khi PHP trả lời chậm hơn fastcgi_read_timeout. Nâng timeout chỉ là vá triệu chứng — gốc rễ thường là query chậm. Nhưng đặt một giá trị tỉnh táo vẫn cần thiết, kèm buffer đủ lớn để Nginx không phải ghi tạm ra đĩa với response lớn.

# khối http hoặc server
fastcgi_connect_timeout 5s;
fastcgi_send_timeout    60s;
fastcgi_read_timeout    60s;   # 504 bung ra khi vượt mốc này
fastcgi_buffer_size     16k;
fastcgi_buffers         16 16k; # tổng buffer cho mỗi response

🚫 Đừng làm thế này. Đặt fastcgi_read_timeout 600s để "hết 504" là cái bẫy chết người. Nó không sửa gì cả — chỉ giữ worker bận lâu gấp 10 lần, khiến pool cạn nhanh hơn và 502 thay thế 504. Hãy đi tìm query chậm.


05. OPcache: tăng tốc gần như miễn phí

Mặc định, mỗi request PHP đều phải đọc file .php, phân tích (lexing), dựng cây cú pháp (parse) rồi biên dịch (compile) thành opcode — trước khi chạy được dòng lệnh đầu tiên. OPcache lưu phần opcode đã biên dịch vào bộ nhớ chia sẻ, nên từ lần thứ hai trở đi, mọi bước nặng nề đó bị bỏ qua hoàn toàn.

Cấu hình OPcache cho production

; /etc/php/8.2/fpm/conf.d/10-opcache.ini
opcache.enable = 1
opcache.memory_consumption = 192        ; MB bộ nhớ chia sẻ cho opcode
opcache.interned_strings_buffer = 16    ; MB cho chuỗi dùng lại
opcache.max_accelerated_files = 20000   ; phải ≥ tổng số file .php
opcache.validate_timestamps = 0         ; PROD: không kiểm tra mtime mỗi request
opcache.revalidate_freq = 0
opcache.save_comments = 1               ; giữ lại nếu dùng annotation (Doctrine…)

Tính max_accelerated_files cho đúng

Tham số này phải lớn hơn tổng số file PHP của ứng dụng (kể cả vendor). Nếu nhỏ hơn, OPcache không cache hết và một phần code vẫn bị biên dịch lại mỗi request. Đếm số file thật:

find /var/www/app -type f -name "*.php" | wc -l
# Ví dụ trả về 11542 → đặt max_accelerated_files = 20000 cho thoải mái
# Lưu ý: PHP tự làm tròn lên số nguyên tố gần nhất (vd 16229, 21701…)

🚫 Đánh đổi của validate_timestamps = 0. Đặt = 0 cho hiệu năng tốt nhất, nhưng OPcache sẽ không nhận ra code mới sau khi deploy. Bắt buộc nạp lại cache mỗi lần deploy, nếu không người dùng vẫn chạy code cũ:

> sudo systemctl reload php8.2-fpm (hoặc gọi opcache_reset())

> Trên môi trường dev thì ngược lại: đặt validate_timestamps = 1 để sửa file là thấy ngay.

JIT — không phải "viên đạn bạc"

PHP 8 thêm JIT. Nó tăng tốc rõ rệt với code nặng CPU (xử lý ảnh, tính toán, mã hoá), nhưng với web app điển hình — vốn nghẽn ở I/O và database — lợi ích thường nhỏ. Bật có chọn lọc và đo lường, đừng bật theo phong trào.

opcache.jit = tracing
opcache.jit_buffer_size = 64M

Kiểm tra OPcache có thực sự hiệu quả không

Đừng tin "cấu hình xong là ổn". Hãy đo hit rate — tỷ lệ request lấy được opcode từ cache. Mục tiêu > 99%.

<?php
$s = opcache_get_status();
// hit_rate nên > 99%. Nếu thấp hoặc num_cached_keys chạm trần
// max_accelerated_files → cần tăng giới hạn / memory_consumption.
printf("Hit rate: %.2f%%\n", $s['opcache_statistics']['opcache_hit_rate']);
printf("Bo nho: %d / %d MB\n",
    $s['memory_usage']['used_memory'] / 1048576,
    ($s['memory_usage']['used_memory'] + $s['memory_usage']['free_memory']) / 1048576);

💡 Bonus ít người biết: realpath cache. Với framework nhiều file (Laravel, Symfony), PHP liên tục phân giải đường dẫn file. Nâng realpath_cache_size = 4096krealpath_cache_ttl = 600 giúp giảm số lần truy cập đĩa — một cải thiện nhỏ nhưng cộng dồn đáng kể ở traffic cao.


06. Ráp lại: một ví dụ end-to-end

Tổng hợp tất cả vào một tình huống cụ thể để thấy các con số ăn khớp với nhau.

Đầu bài: Server 2 vCPU · 4GB RAM, Ubuntu, chạy đồng thời Nginx + PHP-FPM 8.2 + MySQL trên một máy. Ứng dụng Laravel, đo được mỗi tiến trình PHP ≈ 55MB, có ~11.500 file PHP.

Bước tính

# PHP-FPM
RAM_cho_PHP = 4096 − 512 (OS) − 1024 (MySQL) = 2560 MB
pm.max_children = 2560 / 55 ≈ 46  →  đặt an toàn 40

# Nginx (2 vCPU)
max_connections = 2 × 2048 = 4096  →  ~2048 client thực tế

# OPcache
số file = 11500  →  max_accelerated_files = 20000

Cấu hình cuối

# ── PHP-FPM ─────────────────────────────
pm = dynamic
pm.max_children = 40
pm.start_servers = 10
pm.min_spare_servers = 8
pm.max_spare_servers = 20
pm.max_requests = 500

# ── Nginx ───────────────────────────────
worker_processes auto;
worker_rlimit_nofile 8192;
events { worker_connections 2048; }
# upstream php_fpm { server ...; keepalive 32; }
fastcgi_read_timeout 60s;

# ── OPcache ─────────────────────────────
opcache.enable = 1
opcache.memory_consumption = 192
opcache.max_accelerated_files = 20000
opcache.validate_timestamps = 0


★ Checklist khi website 5xx giờ cao điểm

In ra dán cạnh màn hình. Theo thứ tự, từ chẩn đoán đến chỉnh sửa.

  • [ ] Mở log PHP-FPM, tìm dòng server reached pm.max_children setting — bằng chứng số một.

  • [ ] Phân loại lỗi: 504 → đi tìm query/đoạn code chậm; 502 → pool cạn hoặc PHP crash; 429/503 → rate limit hoặc quá tải.

  • [ ] Đo RAM thực tế mỗi tiến trình PHP, rồi tính lại pm.max_children theo công thức — không lấy con số mặc định.

  • [ ] Kiểm tra pm.max_children đã chừa đủ RAM cho MySQL/Redis/OS, tránh OOM-killer.

  • [ ] Bật keepalive tới upstream PHP-FPM trong Nginx — thay đổi một dòng, lợi ích lớn.

  • [ ] Đảm bảo worker_rlimit_nofilenet.core.somaxconn đủ cao cho lượng kết nối cao điểm.

  • [ ] Bật OPcache, đặt max_accelerated_files > tổng số file PHP thực tế.

  • [ ] Production để validate_timestamps = 0 và nhớ reload php-fpm mỗi lần deploy.

  • [ ] Đo opcache_hit_rate — phải > 99%; nếu thấp thì tăng bộ nhớ OPcache.

  • [ ] Thêm pm.max_requests để worker tự làm mới, chống rò rỉ bộ nhớ chậm.


→ Tóm lại

Phần lớn sự cố 5xx giờ cao điểm không cần nâng cấp phần cứng — chúng cần một phép tính. PHP-FPM quyết định bao nhiêu request chạy song song, và con số đó phải được suy ra từ RAM thực tế, không phải để mặc định. Nginx cần keepalive cùng giới hạn kết nối hợp lý để không tự bóp nghẽn mình. OPcache là khoản tăng tốc gần như miễn phí, miễn là bạn đặt đúng giới hạn và nhớ nạp lại cache khi deploy.

Khi ba mảnh ghép này được tính toán ăn khớp, dashboard giờ cao điểm sẽ chuyển từ đỏ rực sang một màu xanh yên ả — và bạn biết chính xác vì sao nó xanh.


Các con số trong bài là điểm khởi đầu để tính toán, không phải giá trị áp dụng máy móc. Hãy luôn đo trên chính hệ thống của bạn (RAM mỗi process, số file PHP, đặc tính traffic) rồi điều chỉnh dần và quan sát log.