Scale Laravel với nginx + PHP-FPM
Câu hỏi phỏng vấn hay gặp: "App Laravel đang chạy 1 server, traffic tăng gấp 5, anh làm gì?"
Trả lời "thêm server" là trả lời sai — hoặc đúng nhất một nửa. Muốn trả lời được, phải biết
request nghẽn ở đâu. Với stack nginx + PHP-FPM, chỗ nghẽn gần như luôn là
một con số duy nhất: pm.max_children.
Tài liệu này đi từ đường đi thật của 1 request, tới công thức tính số worker, rồi tới kiến trúc nhiều node và danh sách bẫy khi tách state ra khỏi máy.
Nguồn kiểm chứng. Phần nginx (upstream,least_conn,keepalive,fastcgi_keep_conn) lấy qua Context7 từnginx.org/en/docs. Phần Laravel (optimize, cache driver, Octane worker state) lấy qua Context7 từlaravel/docs 13.xvàlaravel/octane 2.x. Các file config nginx trong bài đã chạynginx -tthật (nginx 1.24.0) — output dán ở mục 5.4. Chưa verify: Context7 không có tài liệu FPM pool (php-fpm.conf), nên các directivepm.*ghi theo tài liệu php.net và kinh nghiệm vận hành, chưa chạy thử trong session này. Các con số RAM/RPS là ví dụ minh hoạ để tính, không phải đo từ hệ thống thật.
1. Nó là gì / chạy thế nào
1.1. Đường đi thật của một request
sequenceDiagram participant C as Client participant N as nginx (worker, event loop) participant M as php-fpm master participant W as php-fpm child (1 process) participant D as PostgreSQL / Redis C->>N: GET /orders N->>N: Thử tìm file tĩnh (try_files) N->>M: FastCGI qua unix socket / TCP :9000 M->>W: Giao cho 1 child đang rảnh W->>W: Bootstrap Laravel (autoload, provider, route) W->>D: Query — BLOCKING, child ngồi đợi D-->>W: Rows W-->>N: HTML / JSON qua FastCGI N-->>C: HTTP 200 Note over W: Hết request → PHP xoá sạch memory,<br/>child quay lại pool
Hai chữ quan trọng nhất trong sơ đồ: BLOCKING và 1 process.
1.2. Vì sao nginx nhanh mà app vẫn nghẽn
nginx và PHP-FPM có hai mô hình xử lý hoàn toàn khác nhau. Đây là gốc của mọi bài toán scale trên stack này:
| nginx | PHP-FPM | |
|---|---|---|
| Mô hình | Event-driven, non-blocking (1 process xử lý xoay vòng nhiều connection) | Process-per-request, blocking (1 process = 1 request, từ đầu tới cuối) |
| Số việc song song | worker_processes × worker_connections — hàng chục nghìn | pm.max_children — thường 20–100 |
| Tốn RAM mỗi "việc" | Vài KB / connection | 50–150 MB / process (Laravel) |
| Lúc đợi DB | Không có khái niệm đợi — nhả CPU đi làm việc khác | Process ngồi im, vẫn giữ nguyên RAM |
nginx nhận 5 000 connection không sao. Nhưng nếu pm.max_children = 40 thì tại một
thời điểm chỉ có 40 request PHP thật sự đang chạy. 4 960 cái còn lại đang xếp
hàng. Toàn bộ việc scale stack này là làm cho hàng đợi đó không dài ra.
1.3. Little's Law — công thức tính số worker cần
Little's Law (định luật Little — số việc đang chạy trong hệ = tốc độ vào × thời gian ở lại):
concurrency = RPS × thời gian xử lý trung bình (giây)
Ví dụ minh hoạ: app bán hàng, giờ cao điểm 200 req/s, thời gian xử lý trung bình 150 ms:
concurrency = 200 × 0.15 = 30 request chạy song song
Vậy 30 children là đủ? Không. Trung bình chỉ nói được phần giữa. Ba thứ phải cộng thêm:
- Traffic không đều. 200 RPS trung bình có thể là 400 RPS trong 5 giây rồi 50 RPS trong 10 giây. Nhân hệ số 1.5–2.
- p95 mới là con số đáng sợ. Trung bình 150 ms nhưng p95 = 800 ms thì các request
chậm chiếm chỗ rất lâu. Tính lại với p95:
200 × 0.8 = 160. - Trần RAM. Muốn 160 children × 100 MB = 16 GB RAM chỉ riêng cho PHP. Máy 8 GB không cõng nổi.
Ba con số này đụng nhau chính là chỗ phải ra quyết định scale — xem mục 1.6.
1.4. Ba chế độ process manager
Directive pm trong pool config quyết định FPM tạo/huỷ child thế nào:
| pm | Cách chạy | Dùng khi | Bẫy |
|---|---|---|---|
static |
Tạo đúng pm.max_children process ngay lúc start, giữ mãi |
Production có traffic đều. Không tốn thời gian fork lúc peak, RAM dùng bao nhiêu biết ngay từ đầu | Ăn RAM cố định kể cả lúc 3h sáng không ai vào |
dynamic |
Giữ pm.start_servers, co giãn giữa min_spare_servers và max_spare_servers, trần là max_children |
Máy chạy nhiều thứ khác, hoặc traffic lên xuống mạnh theo giờ | Lúc traffic dựng đứng, fork process mới tốn thời gian → độ trễ tăng đúng lúc không nên tăng |
ondemand |
Không giữ process nào. Có request mới fork, rảnh quá pm.process_idle_timeout thì giết |
Máy dev, staging, hoặc server chứa nhiều site nhỏ ít traffic | Đừng dùng cho production traffic cao. Mỗi cold request phải trả giá fork |
Cấu hình mẫu cho production (/etc/php/8.3/fpm/pool.d/app.conf):
[app]
user = www-data
group = www-data
listen = /run/php/app.sock
listen.owner = www-data
listen.group = www-data
listen.mode = 0660
; Hàng đợi ở tầng kernel khi hết child. Mặc định 511.
listen.backlog = 511
pm = static
pm.max_children = 40
; Giết child sau N request để chặn memory leak tích luỹ.
; Đây là "băng dán", không phải cách sửa leak.
pm.max_requests = 500
; Giết request chạy quá lâu, trả 504. Phải >= max_execution_time.
request_terminate_timeout = 60s
; Ghi log request chậm KÈM stack trace PHP — thứ quý nhất khi debug.
slowlog = /var/log/php-fpm/app-slow.log
request_slowlog_timeout = 3s
; Bật endpoint đếm số child đang bận + độ dài hàng đợi.
pm.status_path = /fpm-status
1.5. Tính pm.max_children — từ RAM, không từ cảm giác
flowchart TD
A["Đo RSS thật của 1 child (xem lệnh ở mục 5.2)"] --> B["RAM còn lại = Tổng RAM − OS − nginx − Redis − buffer"]
B --> C["max_children = RAM còn lại / RSS trung bình"]
C --> D{"Kết quả >= concurrency cần?"}
D -->|"Có"| E["Đặt số đó, để dư 15% an toàn"]
D -->|"Không"| F["Không đủ RAM → giảm thời gian xử lý,
hoặc thêm RAM, hoặc thêm node"]
Ví dụ minh hoạ, máy 8 GB:
Tổng RAM 8 192 MB
- OS + nginx 512 MB
- Redis 512 MB
- Buffer/OS page cache 1 024 MB
= Còn cho PHP 6 144 MB
RSS trung bình 1 child (Laravel, có opcache): ~110 MB
pm.max_children = 6144 / 110 ≈ 55 → đặt 48 cho an toàn
Sai lầm kinh điển: đặt pm.max_children = 200 "cho chắc". Lúc peak
FPM fork thật 200 process, RAM cạn, kernel OOM killer bắn chết process — thường bắn nhầm
PostgreSQL hoặc chính php-fpm master. Từ chậm thành sập. Đặt thấp thì request xếp hàng;
đặt cao thì máy chết. Xếp hàng luôn tốt hơn chết.
1.6. Khi hết child thì chuyện gì xảy ra
flowchart LR
R["Request tới"] --> Q{"Còn child rảnh?"}
Q -->|"Còn"| P["Xử lý ngay"]
Q -->|"Hết"| B["Xếp hàng ở listen.backlog
(hàng đợi socket của kernel)"]
B --> T{"Backlog đầy?"}
T -->|"Chưa"| W["Đợi → user thấy chậm
(TTFB tăng dần)"]
T -->|"Đầy"| E["Kernel từ chối kết nối
nginx trả 502 Bad Gateway"]
W --> X{"Đợi quá fastcgi_read_timeout?"}
X -->|"Quá"| G["nginx trả 504 Gateway Timeout"]
X -->|"Chưa"| P
Nhớ khác biệt này, phỏng vấn hay hỏi:
- 502 Bad Gateway = nginx không kết nối được tới FPM. Hết child + backlog đầy, hoặc FPM chết, hoặc sai đường dẫn socket.
- 504 Gateway Timeout = kết nối được nhưng FPM trả lời quá chậm. Query chậm, gọi API bên ngoài không đặt timeout, hoặc đang xếp hàng quá lâu.
1.7. Scale dọc và scale ngang
flowchart TB
subgraph V["Scale dọc — máy to hơn"]
V1["1 node
4 vCPU / 8 GB
48 children"] --> V2["1 node
16 vCPU / 32 GB
~200 children"]
end
subgraph H["Scale ngang — nhiều máy"]
H1["Load balancer"] --> H2["node 1"]
H1 --> H3["node 2"]
H1 --> H4["node 3"]
end
V2 -.->|"Đụng trần: RAM đắt dần,
vẫn là 1 điểm chết duy nhất"| H1
Thứ tự làm đúng — làm hết bậc trên rồi mới xuống bậc dưới, vì bậc trên rẻ hơn nhiều lần:
- Giảm thời gian xử lý. Rẻ nhất. 150 ms → 50 ms là tự động tăng 3× sức chứa mà
không tốn thêm đồng nào. Xem Tối ưu Laravel — N+1 query,
index thiếu, chunk thay vì
all(). - Bật opcache +
php artisan optimize. Vài phút làm, cắt được phần bootstrap của mọi request. - Đẩy việc nặng sang queue. Gửi mail, xuất Excel, gọi API bên thứ ba — đừng bắt child ngồi đợi trong request.
- Tăng
pm.max_childrennếu RAM còn chỗ. - Scale dọc — máy to hơn. Đơn giản, không đụng vào code.
- Scale ngang — nhiều node. Mạnh nhất nhưng bắt buộc app phải stateless (không giữ trạng thái trên máy). Đây là chỗ mọi thứ vỡ, xem mục 3.
1.8. Kiến trúc scale ngang
flowchart TB U["Người dùng"] --> CDN["CDN — ảnh, CSS, JS"] U --> LB["Load balancer
(nginx / HAProxy / ALB)
terminate TLS, health check"] LB --> A1["App node 1
nginx + php-fpm"] LB --> A2["App node 2
nginx + php-fpm"] LB --> A3["App node 3
nginx + php-fpm"] A1 --> RD["Redis
session + cache + queue"] A2 --> RD A3 --> RD A1 --> PGW["PostgreSQL primary
(ghi)"] A2 --> PGW A3 --> PGW PGW -.->|"streaming replication"| PGR["PostgreSQL replica
(đọc)"] A1 --> PGR A2 --> PGR A3 --> PGR A1 --> S3["S3 / MinIO
file người dùng upload"] A2 --> S3 A3 --> S3 WK["Worker node
queue:work + scheduler"] --> RD WK --> PGW
Luật của kiến trúc này chỉ có một câu: app node phải vứt đi được bất cứ lúc nào mà không mất gì. Mọi thứ cần sống lâu hơn một request đều phải nằm ngoài node — session, cache, file upload, job. Node chỉ còn code và config.
Config nginx trên app node
server {
listen 80;
server_name app.example.com;
root /var/www/app/current/public;
index index.php;
charset utf-8;
client_max_body_size 20m;
# Health check cho LB — không chạm PHP, luôn trả lời được kể cả khi FPM tắc.
location = /healthz {
access_log off;
return 200 "ok\n";
}
location / {
try_files $uri $uri/ /index.php?$query_string;
}
# File tĩnh: nginx trả thẳng, không tốn 1 child PHP nào.
location ~* \.(?:css|js|jpg|jpeg|png|gif|ico|svg|woff2)$ {
expires 1y;
access_log off;
add_header Cache-Control "public, immutable";
try_files $uri =404;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_pass unix:/run/php/app.sock;
fastcgi_index index.php;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_param DOCUMENT_ROOT $realpath_root;
fastcgi_read_timeout 60s;
fastcgi_buffers 16 16k;
fastcgi_buffer_size 32k;
}
# Chặn PHP ở thư mục khác public — phòng khi upload lọt file .php
location ~ /\.(?!well-known).* {
deny all;
}
}
$realpath_rootthay vì$document_root: khi deploy bằng symlink (current -> releases/2026-08-29),$realpath_roottrả về đường dẫn thật đã resolve. Không có nó, opcache vẫn giữ file của release cũ sau khi đổi symlink — deploy xong mà code không đổi, một trong những lỗi khó đoán nhất.
Config nginx làm load balancer
upstream app_nodes {
least_conn; # gửi tới node đang ít kết nối nhất
server 10.0.1.11:80 max_fails=3 fail_timeout=15s;
server 10.0.1.12:80 max_fails=3 fail_timeout=15s;
server 10.0.1.13:80 max_fails=3 fail_timeout=15s;
server 10.0.1.14:80 backup; # chỉ dùng khi các node trên chết hết
keepalive 32; # giữ sẵn connection, đỡ bắt tay TCP mỗi request
}
server {
listen 443 ssl;
http2 on; # nginx >= 1.25.1. Bản cũ hơn: listen 443 ssl http2;
server_name app.example.com;
ssl_certificate /etc/ssl/app/fullchain.pem;
ssl_certificate_key /etc/ssl/app/privkey.pem;
location / {
proxy_pass http://app_nodes;
proxy_http_version 1.1;
proxy_set_header Connection ""; # bắt buộc để keepalive hoạt động
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_connect_timeout 5s;
proxy_read_timeout 60s;
proxy_next_upstream error timeout http_502 http_504;
}
}
Hai dòng dễ quên nhất. (1)proxy_set_header Connection ""— không có nó thìkeepalive 32vô tác dụng, nginx vẫn mở connection mới mỗi request. (2)X-Forwarded-Proto— không có nó Laravel tưởng request là HTTP, sinh URLhttp://trong trang HTTPS, redirect loop. Phải kèm cảTrustProxiesở phía Laravel (mục 3.7).
2. Lợi ích
2.1. Được gì khi tách nginx và PHP-FPM đúng cách
- File tĩnh không tốn worker. Một trang có 30 file CSS/JS/ảnh: nginx trả 30 file đó bằng vài KB RAM, PHP chỉ chạy đúng 1 lần. Nếu để PHP trả file tĩnh thì mỗi lượt xem trang ăn 31 child.
- Crash được cô lập. PHP fatal error / hết memory chỉ giết 1 child. Master fork child mới, các request khác không biết gì. Đây là điểm mạnh thật của mô hình process-per-request — đổi lấy chi phí RAM.
- Không lo memory leak tích luỹ. Hết request là PHP xoá sạch bộ nhớ. Không phải lo biến static giữ rác giữa các request như Node.js hay Octane.
- Tách pool theo loại traffic. Chạy nhiều pool trên cùng máy, mỗi pool có trần riêng — mục 2.2.
2.2. Tách pool: cái lợi ít người dùng tới
Endpoint xuất báo cáo chạy 20 giây có thể ăn hết sạch child và làm chết cả site. Cách chặn: cho nó một pool riêng, với trần riêng.
; pool.d/web.conf — traffic thường
[web]
listen = /run/php/web.sock
pm = static
pm.max_children = 40
request_terminate_timeout = 30s
; pool.d/report.conf — endpoint nặng, trần thấp
[report]
listen = /run/php/report.sock
pm = static
pm.max_children = 6
request_terminate_timeout = 300s
location ^~ /reports/ {
try_files $uri /index.php?$query_string;
}
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
# Chọn pool theo prefix URI đã lưu ở biến
fastcgi_pass unix:/run/php/web.sock;
fastcgi_read_timeout 30s;
}
Cách sạch hơn là dùng map để chọn socket theo URI:
map $request_uri $fpm_pool {
default "unix:/run/php/web.sock";
~^/reports/ "unix:/run/php/report.sock";
~^/exports/ "unix:/run/php/report.sock";
}
server {
# ...
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass $fpm_pool;
fastcgi_read_timeout 300s;
}
}
Giờ báo cáo có chậm và chiếm hết 6 child thì trang chủ vẫn còn nguyên 40 child. Đây là bulkhead pattern (chia khoang kín như khoang tàu — một khoang ngập nước không làm chìm cả tàu).
2.3. Được gì khi scale ngang
- Hết điểm chết duy nhất. 1 node hỏng còn n−1 node phục vụ.
- Deploy không downtime. Rút node khỏi LB → deploy → health check xanh → thêm lại → sang node kế. Xem Chiến lược deployment.
- Tăng/giảm theo mùa. Peak sale thêm node, hết peak bỏ đi. Trả tiền theo lúc cần.
- Chịu được lỗi phần cứng. Cloud provider reboot 1 máy không thành sự cố.
3. Tác hại / hạn chế — danh sách bẫy đầy đủ
Bảy cái bẫy dưới đây là toàn bộ những thứ hỏng khi đi từ 1 node lên n node. Bẫy 1–4 là loại "chạy vẫn được nhưng sai lúc có lúc không" — loại khó chịu nhất.
3.1. Bẫy 1 — Session lưu trên file: user bị logout ngẫu nhiên
Triệu chứng: user đăng nhập xong, F5 vài lần thì bị đá ra. Với 3 node, tỉ lệ gặp là 2/3.
Vì sao: mặc định SESSION_DRIVER=file ghi vào
storage/framework/sessions/ — trên đúng cái node đó. Request sau rơi vào
node khác, không thấy file session, coi như chưa đăng nhập.
# .env
SESSION_DRIVER=redis
SESSION_CONNECTION=default
REDIS_HOST=10.0.1.20
Đừng chữa bằng sticky session. Sticky session (LB luôn gửi 1 user về 1 node) làm mất cân bằng tải, và node chết là toàn bộ user trên node đó mất session. Nó chỉ là băng dán tạm lúc chưa kịp chuyển sang Redis. Chi tiết ở Canary deployment cho Laravel + Livewire.
3.2. Bẫy 2 — File upload nằm trên đĩa local
Triệu chứng: upload ảnh xong xem được, 10 phút sau vào lại thì 404. Hoặc "lúc thấy lúc không" tuỳ lần F5.
Vì sao: Storage::disk('public')->put(...) ghi vào
storage/app/public của node 1. Request đọc rơi vào node 2 — không có file.
FILESYSTEM_DISK=s3
AWS_ENDPOINT=https://minio.internal:9000 # dùng MinIO nếu không muốn phụ thuộc AWS
AWS_USE_PATH_STYLE_ENDPOINT=true
Nếu chưa chuyển được sang S3 thì NFS là phương án tạm — nhưng NFS chậm hơn đĩa local nhiều lần và tự nó lại thành điểm chết duy nhất mới.
3.3. Bẫy 3 — Cache file: mỗi node một sự thật
Triệu chứng: admin sửa cấu hình, gọi Cache::forget('settings').
Node 1 xoá cache của node 1. Node 2 và 3 vẫn phục vụ giá trị cũ, có khi nhiều ngày.
CACHE_STORE=redis
Áp dụng cho cả rate limiter và lock. Cache::lock() trên driver
file ở môi trường nhiều node không khoá được gì — mỗi node khoá cái
khoá của riêng nó. Đây là chỗ sinh ra double-charge, double-send mail: code có lock, log nhìn
đúng, mà vẫn chạy 2 lần.
3.4. Bẫy 4 — Scheduler chạy trên mọi node
Triệu chứng: job tổng kết cuối ngày chạy 3 lần. Email nhắc nợ gửi 3 bản cho cùng một khách.
Vì sao: đưa dòng php artisan schedule:run vào crontab của cả 3
node.
Hai cách chữa, chọn một:
- Chỉ chạy scheduler trên 1 node riêng (worker node trong sơ đồ 1.8). Đơn giản, dễ hiểu, dễ debug. Đổi lại node đó chết thì scheduler chết theo — phải có alert.
- Dùng
onOneServer(), dựa trên atomic lock qua Redis:
Schedule::command('report:daily')
->dailyAt('01:00')
->onOneServer() // yêu cầu cache driver là redis/memcached/dynamodb
->withoutOverlapping(); // lần chạy trước chưa xong thì bỏ qua lần này
onOneServer()không hoạt động nếu cache driver còn làfilehayarray. Chữa bẫy 4 mà chưa chữa bẫy 3 thì coi như chưa chữa — và nguy hiểm hơn, vì bạn tưởng đã chữa xong.
3.5. Bẫy 5 — Deploy lệch phiên bản giữa các node
Triệu chứng: giữa lúc deploy, user tải HTML từ node đã cập nhật (asset
app-b1c4.js) nhưng request lấy file JS rơi vào node cũ → 404 → trang trắng. Livewire
thì lỗi "component not found".
Cách chữa:
- Deploy từng node một, rút khỏi LB trước khi deploy (rolling).
- Giữ lại asset của 2 release gần nhất, đừng xoá ngay.
- Hoặc đẩy asset lên CDN/S3 trước khi deploy code — asset có sẵn ở nơi chung cho mọi node.
3.6. Bẫy 6 — Opcache giữ code cũ sau khi đổi symlink
Triệu chứng: deploy xong, symlink đã trỏ release mới, mà site vẫn chạy code cũ. Reload nginx không ăn thua.
Vì sao: opcache cache bytecode theo đường dẫn. Đường dẫn
current/app/Http/Controllers/X.php không đổi, nên opcache tưởng file không đổi.
; php.ini production
opcache.enable=1
opcache.memory_consumption=256
opcache.max_accelerated_files=20000
opcache.validate_timestamps=0 ; production: không kiểm tra mtime → nhanh nhất
opcache.revalidate_freq=0
Với validate_timestamps=0, sau mỗi lần deploy bắt buộc:
sudo systemctl reload php8.3-fpm # reload: master giữ nguyên, child cũ xử nốt request rồi thoát
Cộng thêm $realpath_root ở config nginx (mục 1.8) để nginx đưa đường dẫn thật
của release cho FPM.
3.7. Bẫy 7 — Đứng sau LB thì mất IP thật và mất scheme
Triệu chứng: log thấy mọi request đến từ đúng 1 IP (IP của LB). Rate limiter theo IP chặn nhầm toàn bộ user. Redirect loop vô hạn trên HTTPS.
// bootstrap/app.php (Laravel 11+)
->withMiddleware(function (Middleware $middleware) {
$middleware->trustProxies(
at: ['10.0.1.0/24'], // dải IP của LB. KHÔNG dùng '*' nếu LB không phải cửa duy nhất
headers: Request::HEADER_X_FORWARDED_FOR
| Request::HEADER_X_FORWARDED_HOST
| Request::HEADER_X_FORWARDED_PORT
| Request::HEADER_X_FORWARDED_PROTO,
);
})
Đây là lỗ hổng bảo mật, không chỉ là bug. Đặtat: '*'mà app node vẫn nhận request trực tiếp từ Internet (chưa chặn firewall) thì client tự đặt headerX-Forwarded-For: 1.2.3.4để giả IP — qua mặt rate limit, làm bẩn audit log, vượt IP allow-list. Luôn ghi rõ dải IP của LB, và chặn firewall để app node chỉ nhận từ LB.
3.8. Bảng tra nhanh: state nào phải đi đâu
| Thứ cần lưu | Mặc định (sai khi nhiều node) | Phải đổi thành | Không đổi thì hỏng gì |
|---|---|---|---|
| Session | file | redis | Logout ngẫu nhiên |
| Cache + lock | file | redis | Dữ liệu cũ, lock không khoá được gì |
| Queue | sync | redis / SQS | Request treo vì chạy job ngay trong request |
| File upload | local | s3 / MinIO | 404 lúc có lúc không |
| Log | single (file) | stderr + gom về 1 chỗ | Phải SSH vào từng node mới đọc được log |
| Scheduler | cron mọi node | 1 node riêng, hoặc onOneServer() | Job chạy n lần |
| Broadcast (websocket) | log | Reverb/Pusher + Redis | Event chỉ tới user đang nối vào 1 node |
3.9. Giới hạn cứng của mô hình PHP-FPM
- RAM là trần thật. Mỗi request tốn cả một process. Muốn 500 concurrent thì cần ~50 GB RAM. Node.js/Go làm việc đó với 1 GB. Đây là giá của mô hình.
- Bootstrap lại framework mỗi request. Laravel nạp autoload, provider, config, route mỗi lần — 10–40 ms, không làm gì có ích. Octane bỏ được phần này (mục 4.1).
- Không giữ được kết nối. Không có connection pool tới DB ở tầng PHP; mỗi child
tự mở kết nối riêng. 3 node × 48 child = 144 kết nối tới PostgreSQL, trong khi
max_connectionsmặc định chỉ 100 (kiểm chứng trên PostgreSQL 18.4:SHOW max_connections;→100). Scale app tầng trên có thể làm chết DB tầng dưới — phải đặt PgBouncer ở giữa. - Không làm việc song song trong 1 request. Gọi 3 API bên ngoài là cộng dồn thời
gian, không chạy song song (trừ khi dùng
Http::pool()).
4. Trade-off
4.1. PHP-FPM và Laravel Octane
| PHP-FPM | Octane (FrankenPHP / Swoole / RoadRunner) | |
|---|---|---|
| Bootstrap | Mỗi request nạp lại framework | Nạp 1 lần, worker sống lâu, tái dùng |
| Throughput | Nền | Thường 2–4× (số này phụ thuộc app, phải tự đo) |
| Memory leak | Không thể tích luỹ — hết request là xoá sạch | Vấn đề thật. Biến static, singleton, listener giữ rác qua nhiều request |
| Rò state giữa user | Không thể | Có thể. Cache user A trong singleton → user B đọc được. Lỗi bảo mật thật |
| Debug lúc 3h sáng | Ai cũng biết đọc | Cần hiểu vòng đời worker mới debug được |
| Package bên thứ ba | Cái nào cũng chạy | Package cũ giữ state có thể vỡ |
Tài liệu Octane nói rõ hai luật bắt buộc (kiểm chứng qua Context7):
- Không giữ state của request trong singleton hay biến static. Muốn giữ thì đăng ký vào danh sách
flushởconfig/octane.php, hoặc reset khi nghe eventRequestTerminated. - Luôn đặt
--max-requests(hoặcmax_jobs) để worker tự tái sinh — đây là lưới an toàn chống leak, không phải cách sửa leak.
php artisan octane:start --server=frankenphp --workers=8 --max-requests=500
Tiêu chí chọn: nếu bottleneck là query DB chậm thì Octane không cứu được gì — vẫn ngồi đợi DB, chỉ tốn thêm rủi ro. Octane đáng giá khi đã tối ưu DB xong mà thời gian bootstrap chiếm tỉ trọng lớn trong tổng thời gian request. Đo trước, đổi sau.
4.2. Unix socket và TCP
Unix socket (/run/php/app.sock) | TCP (127.0.0.1:9000) | |
|---|---|---|
| Tốc độ | Nhanh hơn chút — không qua TCP stack | Chậm hơn không đáng kể |
| Cùng máy | Bắt buộc | Được |
| Khác máy / container khác | Không được | Bắt buộc |
| Phân quyền | Phải khớp listen.owner/group/mode — sai là 502 | Không cần lo |
| Backlog đầy | Hay gặp lỗi connect() to unix:... failed (11: Resource temporarily unavailable) | Rõ ràng hơn |
Chọn: nginx và FPM cùng máy/cùng container → unix socket. Kiến trúc container
tách rời (nginx 1 container, fpm 1 container) → TCP, và khi đó dùng
upstream + keepalive + fastcgi_keep_conn on để đỡ chi phí
bắt tay TCP:
upstream fpm_backend {
server php-fpm:9000;
keepalive 16;
}
server {
listen 80;
root /var/www/app/public;
location ~ \.php$ {
include fastcgi_params;
fastcgi_param SCRIPT_FILENAME $realpath_root$fastcgi_script_name;
fastcgi_pass fpm_backend;
fastcgi_keep_conn on; # cần dòng này thì keepalive ở trên mới có tác dụng
}
}
4.3. Các đánh đổi còn lại
| Quyết định | Chọn A khi | Chọn B khi |
|---|---|---|
pm = static vs dynamic | static: máy dành riêng cho app, traffic đều, muốn RAM đoán trước được | dynamic: máy chia sẻ với DB/Redis, traffic chênh lệch ngày/đêm lớn |
| Scale dọc vs ngang | Dọc: app còn stateful, team nhỏ, chấp nhận downtime lúc bảo trì | Ngang: cần HA (không được chết), cần deploy zero-downtime, đã tách state xong |
| nginx LB vs cloud LB (ALB/GCLB) | nginx: tự chủ, rẻ, cấu hình sâu — nhưng chính nó thành điểm chết, phải có 2 con + keepalived | Cloud LB: HA sẵn, tự cấp TLS, tốn tiền theo giờ + theo GB |
| 1 pool vs nhiều pool | 1 pool: app đồng nhất, mọi endpoint nhanh như nhau | Nhiều pool: có endpoint nặng (export, report, webhook) cần cách ly |
request_terminate_timeout ngắn vs dài | Ngắn (30s): bảo vệ pool, request treo bị giết sớm | Dài (300s): chấp nhận cho job nặng chạy — chỉ ở pool riêng |
5. Khó khăn khi vận hành thực tế
5.1. Không có pm.status_path thì đang bay mù
Không bật status page thì không có cách nào biết pool sắp đầy. Chỉ biết khi user báo 502 — tức là đã muộn.
; pool config
pm.status_path = /fpm-status
# nginx — chỉ cho mạng nội bộ vào
location = /fpm-status {
allow 127.0.0.1;
allow 10.0.1.0/24;
deny all;
include fastcgi_params;
fastcgi_pass unix:/run/php/app.sock;
fastcgi_param SCRIPT_FILENAME $fastcgi_script_name;
}
Bốn con số phải đưa lên dashboard và đặt alert:
| Chỉ số | Ý nghĩa | Ngưỡng alert |
|---|---|---|
active processes | Số child đang bận | > 80% max_children trong 5 phút |
listen queue | Số request đang xếp hàng ở socket | > 0 kéo dài — đây là cảnh báo sớm nhất |
max children reached | Số lần pool chạm trần từ lúc start | Tăng thêm bất kỳ |
slow requests | Số request vượt request_slowlog_timeout | Tăng đột ngột |
listen queue > 0 nghĩa là request đang phải đợi. User đã thấy chậm rồi,
dù chưa ai thấy lỗi. Alert vào đây, đừng đợi tới lúc có 502.
5.2. Debug lúc 3h sáng: 502 hay 504?
flowchart TD
S["Alert: lỗi 5xx tăng"] --> Q1{"Mã lỗi gì?"}
Q1 -->|"502"| B1["curl unix socket / port 9000 từ chính node"]
B1 --> B2{"FPM còn sống?"}
B2 -->|"Không"| B3["systemctl status php8.3-fpm
rồi kiểm tra dmesg xem có OOM killer không"]
B2 -->|"Còn"| B4["Xem fpm-status: max children reached tăng?
→ pool đầy, backlog tràn"]
Q1 -->|"504"| C1["Xem slowlog — có stack trace PHP"]
C1 --> C2{"Kẹt ở đâu?"}
C2 -->|"PDO::execute"| C3["Query chậm → pg_stat_activity,
tìm lock hoặc thiếu index"]
C2 -->|"curl / Guzzle"| C4["API bên ngoài treo →
đặt timeout, thêm circuit breaker"]
C2 -->|"file_get_contents"| C5["I/O chậm — hay gặp với NFS"]
B4 --> F["Cấp cứu: tăng max_children nếu RAM còn,
hoặc tắt tạm endpoint nặng bằng feature flag"]
C3 --> F
Ba lệnh phải thuộc lòng:
# 1. Pool đang bận bao nhiêu, có ai xếp hàng không
curl -s "http://127.0.0.1/fpm-status?full" | head -20
# 2. RAM trung bình mỗi child (MB) — để tính lại max_children
ps --ppid $(pgrep -o php-fpm) -o rss= | awk '{s+=$1; n++} END {print s/n/1024 " MB, n=" n}'
# 3. Request nào đang treo, treo ở dòng code nào
tail -50 /var/log/php-fpm/app-slow.log
slowloglà công cụ quý nhất và ít người bật nhất. Nó không chỉ ghi URL chậm — nó ghi cả stack trace PHP tại thời điểm chậm, chỉ thẳng ra file và số dòng đang kẹt. Không có nó thì chỉ biết "endpoint này chậm", có nó thì biết "chậm ởOrderRepository.php:142".
5.3. Chi phí và con người
- Scale ngang không chỉ là tiền thuê thêm máy. Phải trả thêm cho: Redis (managed hoặc tự dựng HA), S3/MinIO, LB, hệ thống gom log tập trung, và thời gian người sửa 7 cái bẫy ở mục 3.
- 1 người trực thì đừng vội chuyển sang nhiều node. Một node 32 GB đơn giản, dễ debug, thường phục vụ tốt hơn nhiều so với 3 node mà không ai hiểu tại sao user bị logout.
- Nhiều node = mọi bug đều là "lúc có lúc không". Bug chỉ xuất hiện ở node 2 rất
khó tái hiện. Bắt buộc phải có log tập trung kèm
hostnametrong mỗi dòng log, nếu không thì mò trong bóng tối. - Đừng quên tầng dưới. Thêm node app là thêm kết nối tới DB và Redis. Tính
trước:
số node × max_childrenphải nhỏ hơnmax_connectionscủa PostgreSQL, nếu không thì đặt PgBouncer.
5.4. Kiểm chứng config trước khi reload
Toàn bộ file config nginx trong bài (app node, load balancer, map chọn pool,
fastcgi_keep_conn, và block /fpm-status) đã được gộp lại và chạy
nginx -t thật trên nginx 1.24.0 — output nguyên văn:
$ nginx -t -p ./nginxcheck -c ./nginxcheck/nginx.conf
nginx: the configuration file ./nginxcheck/nginx.conf syntax is ok
nginx: configuration file ./nginxcheck/nginx.conf test is successful
Lần chạy đầu fail thật, và đây là bẫy đáng nhớ:
nginx: [emerg] unknown directive "http2" in nginx.conf:49
http2 on;là directive riêng, chỉ có từ nginx 1.25.1. Trên 1.24.0 (bản mặc định của Ubuntu 24.04) phải viết kiểu cũ:listen 443 ssl http2;. Copy config từ blog viết cho bản mới về máy bản cũ là gặp ngay. Đây chính là lý donginx -tphải chạy trước mọi lần reload.
Quy trình bắt buộc mỗi lần sửa config:
sudo nginx -t # sai cú pháp thì dừng ở đây, chưa ai bị ảnh hưởng
sudo systemctl reload nginx # reload, không restart — connection đang chạy không bị cắt
sudo php-fpm8.3 -t # kiểm tra pool config
sudo systemctl reload php8.3-fpm
reloadkhácrestart.reload: master giữ nguyên, child cũ xử nốt request đang chạy rồi mới thoát — không ai bị cắt giữa chừng.restart: giết sạch, mọi request đang chạy đứt ngang. Trên production luôn dùngreload.
6. Case thực tế và cách xử lý
Case 1 — 12h trưa, 502 Bad Gateway hàng loạt
Bối cảnh (ví dụ minh hoạ): app bán hàng, 1 node 4 vCPU / 8 GB,
pm.max_children = 20. Chạy tốt nhiều tháng. Chạy campaign flash sale, traffic từ
60 lên 250 RPS. 502 tràn ngập.
Xử lý theo thứ tự:
- Xác nhận nguyên nhân.
curl /fpm-status→active processes: 20/20,listen queue: 340,max children reached: 1 204. Pool đầy, không phải FPM chết. - Cấp cứu ngay (2 phút). Đo RSS: 95 MB/child. 20 × 95 = 1.9 GB, máy còn trống
4.5 GB. Nâng
pm.max_childrenlên 40,reload php-fpm. 502 hết, chuyển thành "hơi chậm". - Chặn nguồn gốc (30 phút). Slowlog chỉ ra 70% request chậm nằm ở
ProductController@index, kẹt tạiPDO::execute. Đọc code: N+1 query khi render danh sách sản phẩm. Thêmwith(['category', 'images']). Thời gian trung bình từ 420 ms xuống 90 ms. - Tính lại theo Little's Law.
250 × 0.09 = 23concurrent. 40 children giờ là dư thoải mái. - Chặn tái diễn. Alert khi
active/max > 0.8. Bật slowlog vĩnh viễn. Thêm test đếm số query cho endpoint danh sách.
Bài học: tăng max_children là cầm máu, không phải chữa bệnh. Nếu chỉ
dừng ở bước 2 thì lần sale sau vẫn sập, chỉ là ở mốc traffic cao hơn. Bước 3 mới là chữa.
Case 2 — Một endpoint chậm làm chết cả site
Bối cảnh: thêm tính năng "xuất Excel đơn hàng". Kế toán bấm xuất 50 000 dòng, mất 90 giây. 5 người cùng bấm. Toàn bộ site đứng, kể cả trang chủ.
Vì sao: 5 request × 90 giây chiếm 5 child trong suốt 90 giây. Cộng thêm traffic thường, pool 20 child cạn sạch. Một endpoint ít người dùng nhất làm chết endpoint nhiều người dùng nhất.
Xử lý:
- Đúng nhất — đẩy sang queue. Endpoint chỉ dispatch job rồi trả ngay "đang xử lý, xong sẽ gửi mail link tải". Child được trả lại pool sau 50 ms thay vì 90 giây.
- Nếu chưa kịp sửa code — cách ly bằng pool riêng. Route
/exports/*sang poolreportvớimax_children = 4(mục 2.2). Xuất Excel có nghẽn thì chỉ nghẽn trong 4 child đó. - Đặt trần cứng.
request_terminate_timeout = 30sở pool web — không request web nào được phép chạy quá 30 giây. - Chặn ở tầng ứng dụng. Rate limit
1 lần / phút / usercho endpoint xuất file.
Case 3 — Thêm node thứ 2, user kêu bị đá ra liên tục
Bối cảnh: dựng node 2, thêm vào nginx upstream. 10 phút sau ticket đổ về: "đang thao tác thì bị logout", "giỏ hàng tự trống", "ảnh vừa upload xong đã 404".
Chẩn đoán: ba triệu chứng khác nhau nhưng cùng một gốc — state còn nằm trên đĩa của từng node. Đúng ba bẫy 3.1, 3.2, 3.3.
Xử lý:
- Rút node 2 khỏi LB ngay. Quay về 1 node, hết lỗi. Sửa xong rồi thêm lại — đừng vừa sửa vừa để user chịu trận.
- Chuyển session và cache sang Redis.
SESSION_DRIVER=redis,CACHE_STORE=redis. Lưu ý: đổi session driver là toàn bộ user bị logout một lần — làm vào lúc vắng và báo trước. - Chuyển file upload sang S3/MinIO, và migrate file cũ đang nằm ở node 1 lên trước khi bật.
- Rà lại bảng 3.8 — hết lượt cả 7 dòng, không bỏ dòng nào. Bẫy còn sót lại thường là scheduler (bẫy 4), vì nó chỉ lộ ra sau 24 giờ khi job cuối ngày chạy 2 lần.
- Test lại có kiểm soát: thêm node 2 vào LB nhưng đặt
weight=1so vớiweight=10của node 1 — chỉ ~9% traffic vào node mới. Theo dõi 1 giờ, sạch thì cân bằng lại.
Case 4 — Scale app xong thì PostgreSQL chết
Bối cảnh: từ 1 node lên 3 node, mỗi node max_children = 48. App
tự nhiên báo FATAL: sorry, too many clients already.
Vì sao: 3 × 48 = 144 kết nối tiềm năng tới PostgreSQL, trong khi
max_connections mặc định là 100. Mỗi kết nối PostgreSQL là một process riêng, tốn
~5–10 MB — không thể nâng max_connections lên vô hạn.
Xử lý:
- Đặt PgBouncer trước PostgreSQL, chế độ
transactionpooling: 144 kết nối từ app gom lại còn ~25 kết nối thật tới DB. - Lưu ý với transaction pooling: không dùng được prepared statement dạng
session-level,
LISTEN/NOTIFY, và advisory lock ở mức session. Với Laravel + PDO cần đặtPGBOUNCERđúng chế độ và tắt prepared statement nếu gặp lỗiprepared statement "pdo_stmt_00000001" already exists. - Tách đọc/ghi. Trỏ read query sang replica bằng cấu hình
read/writetrongconfig/database.php. Lưu ý replication lag — ghi xong đọc lại ngay có thể chưa thấy dữ liệu. - Bài học chung: mỗi lần scale tầng trên, kiểm tra lại trần của tầng dưới. App node rẻ và dễ nhân bản; database thì không.
7. Câu hỏi phụ interviewer hay hỏi tiếp
"Tính pm.max_children thế nào?"
Lấy RAM còn trống sau khi trừ OS, nginx, Redis và buffer; chia cho RSS trung bình thật đo được
của 1 child (Laravel thường 80–150 MB). Rồi đối chiếu với Little's Law
(RPS × p95) xem có đủ không. Nếu con số RAM cho phép nhỏ hơn con số cần thì
không tăng children được — phải giảm thời gian xử lý hoặc thêm node.
"502 và 504 khác nhau ở đâu?"
502 = nginx không kết nối được tới FPM (pool đầy + backlog tràn, hoặc FPM chết, hoặc sai
socket). 504 = kết nối được nhưng FPM trả lời quá fastcgi_read_timeout (query chậm,
API ngoài treo). 502 nhìn fpm-status, 504 nhìn slowlog.
"pm = static hay dynamic?"
Máy dành riêng cho app, traffic đều → static: RAM đoán trước được, không tốn thời
gian fork đúng lúc peak. Máy chạy chung nhiều thứ hoặc traffic ngày/đêm chênh lệch lớn →
dynamic. ondemand chỉ cho dev/staging hoặc server nhiều site nhỏ.
"Vì sao pm.max_children quá cao lại nguy hiểm hơn quá thấp?"
Quá thấp thì request xếp hàng — chậm, nhưng còn sống và tự hồi phục khi traffic giảm. Quá cao thì lúc peak FPM fork thật, RAM cạn, OOM killer bắn chết process ngẫu nhiên — có thể là PostgreSQL. Từ "chậm" thành "sập", và không tự hồi phục.
"Có Octane rồi thì bỏ được PHP-FPM chưa?"
Chưa chắc. Octane bỏ được phần bootstrap framework, đáng giá khi bootstrap chiếm tỉ trọng lớn.
Nếu request chủ yếu là đợi DB thì Octane không cứu gì, chỉ thêm rủi ro rò state giữa các user
qua singleton/static và memory leak tích luỹ. Đo trước, đổi sau, và bắt buộc đặt
--max-requests.
"Nhiều node thì lỗi đầu tiên gặp là gì?"
User bị logout ngẫu nhiên — session driver còn là file. Ngay sau đó là cache
file mỗi node một sự thật, file upload nằm local, và scheduler chạy n lần. Cả 4 đều
cùng một gốc: state còn nằm trên đĩa của node.
"Sticky session có ổn không?"
Là băng dán tạm, không phải giải pháp. Nó làm lệch tải, và node chết là toàn bộ user trên node đó mất session. Dùng khi đang trong quá trình chuyển sang Redis, không phải đích đến.
"Scale app node xong, tầng nào chết tiếp?"
Database. số node × max_children kết nối đổ dồn về PostgreSQL, dễ vượt
max_connections. Đặt PgBouncer ở giữa (transaction pooling) và tách read query sang
replica. App node nhân bản dễ, database thì không.
"nginx làm gì để app đỡ tải nhất?"
Trả file tĩnh trực tiếp — mỗi file tĩnh nginx trả là một child PHP được giữ lại. Thêm
gzip/brotli, cache header dài cho asset có hash trong tên, và
fastcgi_cache cho trang public không phụ thuộc user (nhớ bỏ qua cache khi có cookie
đăng nhập).
"Deploy xong mà code không đổi, vì sao?"
Opcache cache theo đường dẫn. Symlink current không đổi tên nên opcache tưởng file
cũ. Chữa bằng $realpath_root trong config nginx và
systemctl reload php-fpm sau mỗi lần deploy.
Checklist trước khi thêm node thứ 2
- [ ]
SESSION_DRIVER=redis(không cònfile) - [ ]
CACHE_STORE=redis— kể cả rate limiter vàCache::lock() - [ ]
QUEUE_CONNECTION=redis, worker chạy ở node riêng - [ ]
FILESYSTEM_DISK=s3hoặc MinIO, file cũ đã migrate lên - [ ] Scheduler chỉ chạy 1 nơi, hoặc dùng
onOneServer()(sau khi đã có Redis cache) - [ ]
TrustProxieskhai đúng dải IP của LB, firewall chặn app node khỏi Internet - [ ] Log ra
stderrvà gom về một chỗ, mỗi dòng cóhostname - [ ] Endpoint
/healthzkhông chạm PHP, LB đã trỏ health check vào đó - [ ]
pm.status_pathbật, dashboard có 4 chỉ số ở mục 5.1, alert ởlisten queue > 0 - [ ]
slowlogbật vớirequest_slowlog_timeouthợp lý - [ ]
số node × max_childrennhỏ hơnmax_connectionscủa PostgreSQL — nếu không thì đã có PgBouncer - [ ] Asset build đẩy lên CDN/S3 trước khi deploy code, giữ lại 2 release gần nhất
- [ ]
opcache.validate_timestamps=0đi kèmreload php-fpmtrong script deploy
Đọc thêm trong repo này
- Tối ưu Laravel — tốc độ và memory — bậc 1 của thang scale, làm trước khi nghĩ tới thêm máy.
- Chiến lược deployment — rolling / blue-green / canary khi đã có nhiều node.
- Canary cho Laravel + Livewire — chia traffic và bài toán sticky session.
- Migration PostgreSQL zero-downtime — đổi schema khi nhiều node chạy nhiều phiên bản code cùng lúc.