Bộ công cụ để vận hành — không đoán mò mỗi khi có sự cố
22h, khách nhắn "web chậm". Không có công cụ thì việc tiếp theo là: SSH vào server,
gõ top, nhìn một lúc, rồi đoán. Đoán sai thì restart php-fpm cho
"nó nhẹ lại", chậm vẫn còn, và không ai biết nguyên nhân thật là gì.
Có công cụ thì trong 10 phút trả lời được: chậm ở tầng nào, bắt đầu từ mấy giờ, do thay đổi nào, và sửa xong đã hết chưa. Bài này liệt kê bộ công cụ tối thiểu để làm được việc đó, cộng thêm bộ công cụ dùng khi tối ưu hệ thống.
Nguyên tắc xuyên suốt bài: mỗi lần sự cố mà phải đoán là một chỗ đang thiếu công cụ. Mục tiêu không phải "có dashboard đẹp", mà là trả lời được đúng 4 câu:
- Có đang hỏng không, và khách có thấy không?
- Hỏng ở tầng nào?
- Bắt đầu lúc nào, và lúc đó cái gì thay đổi?
- Sửa xong đã hết chưa?
Bối cảnh bài viết: stack nginx + PHP-FPM + Laravel 12 / Livewire 4 + PostgreSQL 18 + Redis, chạy Docker trên VPS, deploy blue-green. Hướng công cụ: self-host open-source (Prometheus, Grafana, Loki, Alertmanager) — rẻ, không đẩy dữ liệu ra ngoài, và giải thích được cơ chế khi phỏng vấn.
Về số liệu: mọi output PostgreSQL trong bài đều chạy thật trên
container my-test-pgdb (PostgreSQL 18.4) của repo postgresql-learning.
Chỗ nào chưa chạy được ở máy này sẽ ghi rõ "chưa verify".
Thuật ngữ dùng trong bài
| Thuật ngữ | Nghĩa ngắn |
|---|---|
| observability | Khả năng quan sát — nhìn từ bên ngoài mà suy ra được bên trong hệ thống đang xảy ra gì, không cần thêm code mới. |
| metrics | Số đo theo thời gian: số request/giây, %CPU. Rẻ, lưu lâu, dùng để cảnh báo. |
| logs | Dòng chữ ghi lại từng sự kiện. Đắt, nhưng kể được chi tiết "request đó bị gì". |
| traces | Vết một request đi xuyên các tầng, kèm thời gian từng chặng. |
| profiling | Đo bên trong tiến trình: hàm nào ăn CPU, ngốn RAM bao nhiêu byte. |
| exporter | Chương trình nhỏ dịch số liệu của một phần mềm sang định dạng Prometheus đọc được. |
| scrape | Prometheus tự đi lấy số theo chu kỳ (pull), không chờ app gửi lên. |
| cardinality | Số lượng tổ hợp nhãn của một metric. Nhãn nhận giá trị vô hạn (user_id, order_id) làm nổ bộ nhớ. |
| SLI / SLO | SLI là chỉ số đo chất lượng dịch vụ (vd tỉ lệ request < 500 ms). SLO là mức cam kết cho chỉ số đó (vd 99%). |
| error budget | Phần được phép hỏng trong SLO. SLO 99,9% thì mỗi tháng được hỏng ~43 phút. |
| p95 / p99 | Ngưỡng mà 95% (hoặc 99%) request nhanh hơn. Nhìn p95 chứ đừng nhìn trung bình. |
| RED / USE | Hai bộ chỉ số chuẩn: RED cho dịch vụ — Rate, Errors, Duration. USE cho tài nguyên — Utilisation, Saturation, Errors. |
| MTTR | Thời gian trung bình từ lúc hỏng tới lúc chạy lại được. |
| alert fatigue | Cảnh báo kêu quá nhiều và sai nhiều nên người trực tắt tiếng, tới lúc kêu thật thì không ai xem. |
| dead man's switch | Cảnh báo ngược: hệ thống giám sát phải kêu "tôi còn sống" đều đặn, im lặng mới là báo động. |
| correlation id / request id | Một mã duy nhất gắn vào một request, in ra ở mọi tầng, để nối log của các tầng lại với nhau. |
| sampling | Chỉ giữ lại một phần dữ liệu (vd 10% trace) để giảm chi phí. |
| golden signals | Bốn tín hiệu Google khuyên theo dõi trước tiên: độ trễ, lưu lượng, lỗi, mức bão hoà. |
1. Nó là gì / chạy thế nào
1.1 Bốn loại dữ liệu — mỗi loại trả lời một câu khác nhau
Sai lầm hay gặp: dùng log để làm mọi việc. Log rất hợp để kể chuyện một request, nhưng đếm bằng log thì vừa chậm vừa đắt. Bốn loại dữ liệu không thay nhau được:
| Loại | Trả lời câu | Ví dụ | Chi phí lưu | Dùng để cảnh báo? |
|---|---|---|---|---|
| Metrics | Có bất thường không, từ lúc nào? | p95 route /products = 820 ms | Rất rẻ (vài byte / điểm) | Có — gần như mọi alert nên đứng trên metric |
| Logs | Chuyện gì xảy ra với request cụ thể X? | "SQLSTATE 40P01 deadlock, order_id=8123" | Đắt (GB/ngày) | Hạn chế — chỉ cho mẫu lỗi rõ ràng |
| Traces | 820 ms đó tiêu vào đâu? | DB 640 ms / Redis 20 ms / PHP 160 ms | Đắt, nên lấy mẫu | Không |
| Profiles | CPU cháy ở hàm nào? | Collection::map() chiếm 38% CPU | Chỉ bật khi cần | Không |
Metric để biết "có vấn đề", log để biết "vì sao". Đừng đếm số lỗi bằng cách grep log mỗi phút — cách đó chậm, tốn, và im lặng khi log bị mất.
1.2 Đường đi của dữ liệu
flowchart LR
subgraph SRC["Nguồn số liệu"]
NG["nginx
access log JSON"]
FPM["php-fpm
/fpm-status"]
APP["Laravel
log + exception"]
PG["PostgreSQL
pg_stat_*"]
RD["Redis
INFO"]
HOST["VPS + container"]
end
subgraph COL["Thu thập"]
EXP["exporter:
nginxlog / fpm / postgres / redis / node"]
PT["promtail
đọc file log"]
SDK["SDK Sentry
bắn khi có exception"]
end
subgraph STORE["Lưu"]
PROM["Prometheus
time series"]
LOKI["Loki
log"]
GT["GlitchTip / Sentry
lỗi đã gom nhóm"]
end
NG --> EXP
FPM --> EXP
PG --> EXP
RD --> EXP
HOST --> EXP
NG --> PT
APP --> PT
APP --> SDK
EXP --> PROM
PT --> LOKI
SDK --> GT
PROM --> GRAF["Grafana
dashboard"]
LOKI --> GRAF
PROM --> AM["Alertmanager"]
AM --> TG["Telegram / Slack
người trực"]
GT --> TG
Ba cơ chế khác nhau, nên hỏng cũng khác nhau:
- Prometheus kéo (pull). Nó tự gọi
GET /metricscủa từng target mỗi 15 giây. Không có app nào "gửi" gì cả. Lợi: target chết là Prometheus biết ngay (up == 0), không cần app tự báo. Hại: tiến trình sống ngắn — cron, job hàng đêm — chạy xong đã chết trước lần scrape kế tiếp, nên phải đẩy qua Pushgateway (một chỗ đệm để job ngắn gửi số vào, Prometheus scrape chỗ đệm đó). - Log được đẩy (push).
promtailchạy cạnh app, đọc đuôi file log, gắn nhãn rồi gửi sang Loki. Loki chỉ đánh index phần nhãn, còn nội dung dòng log thì nén và để nguyên — vì thế rẻ hơn Elasticsearch nhiều, nhưng đổi lại tìm theo nội dung phải quét, chậm hơn. - Lỗi được bắn ngay (event). SDK Sentry/GlitchTip nằm trong app, có exception thì gửi một event kèm stack trace và gom các lỗi giống nhau thành một nhóm.
1.3 Bản đồ tầng — mỗi tầng một câu hỏi, một công cụ
Khi có sự cố, việc đầu tiên là khoanh tầng. Đây là bản đồ đường đi của một request và công cụ nhìn được từng chặng:
flowchart TD U["Khách"] -->|"1"| DNS["DNS + TLS + firewall"] DNS -->|"2"| NGINX["nginx edge"] NGINX -->|"3"| FPM["php-fpm pool"] FPM -->|"4"| LAR["Laravel / Livewire"] LAR -->|"5a"| PG["PostgreSQL"] LAR -->|"5b"| RD["Redis: cache, session"] LAR -->|"5c"| Q["Queue + worker"] DNS -.- T1["blackbox_exporter
Uptime Kuma"] NGINX -.- T2["access log JSON
nginxlog-exporter"] FPM -.- T3["/fpm-status
php-fpm slowlog"] LAR -.- T4["log JSON + GlitchTip
Laravel Pulse"] PG -.- T5["pg_stat_statements
auto_explain
postgres_exporter"] RD -.- T6["redis_exporter
SLOWLOG"] Q -.- T7["queue depth
failed_jobs"]
| Tầng | Câu hỏi cần trả lời | Công cụ | Tín hiệu chính |
|---|---|---|---|
| Trình duyệt | Khách thật có thấy chậm không? | Synthetic check, Lighthouse; RUM nếu có | TTFB, LCP |
| DNS / TLS / mạng | Có vào tới nơi không? Cert còn hạn không? | blackbox_exporter hoặc Uptime Kuma, đặt ngoài VPS | probe_success, số ngày còn lại của cert |
| nginx | Bao nhiêu request, mã lỗi gì, chậm bao nhiêu? | access log JSON + prometheus-nginxlog-exporter | rate, tỉ lệ 5xx, p95 upstream_response_time |
| php-fpm | Hết worker chưa? Có ai xếp hàng không? | pm.status_path + slowlog | active processes, listen queue, max children reached |
| Laravel | Lỗi gì, ở route nào, ai gặp? | Log JSON + GlitchTip/Sentry; Pulse cho ảnh tổng quan | exception/phút, route chậm nhất |
| Queue / worker | Job có dồn không? Job nào chết? | Metric độ sâu hàng đợi + bảng failed_jobs | số job chờ, tuổi job cũ nhất |
| PostgreSQL | Query nào ăn thời gian? Ai khoá ai? | pg_stat_statements, auto_explain, pg_stat_activity, postgres_exporter | total_exec_time, số kết nối, lock chờ, replication lag |
| Redis | Cache có ăn không? Có bị đuổi key không? | redis_exporter, SLOWLOG | hit rate, evicted_keys, RAM đã dùng |
| Host / container | CPU, RAM, disk, IO còn không? | node_exporter, cAdvisor | load, disk free, iowait, OOM kill |
| Deploy | Đang chạy bản nào, đổi lúc nào? | /health trả version + annotation trên Grafana | mốc deploy vẽ thẳng lên đồ thị |
Chỗ hay bị bỏ quên nhất là dòng cuối. Không có mốc deploy trên đồ thị thì mỗi lần đồ thị xấu đi lại mất 20 phút đi hỏi "hôm nay ai deploy gì". Có mốc rồi thì nhìn một cái là thấy đường cong gãy đúng lúc deploy.
1.4 Sợi chỉ nối các tầng: request id
Có đủ log của 5 tầng mà không nối được với nhau thì vẫn phải đoán. Thứ nối chúng là một request id — sinh ở nginx, đi kèm suốt đường, in ra ở mọi nơi.
sequenceDiagram participant C as Khach participant N as nginx participant P as php-fpm + Laravel participant L as Loki participant S as GlitchTip C->>N: GET /products Note over N: sinh $request_id = a1b2c3... N->>P: fastcgi_param X-Request-Id = a1b2c3 Note over P: Log shareContext request-id P->>L: log JSON co request_id P->>S: exception co tag request_id P-->>N: response + header Request-Id N->>L: access log co request_id N-->>C: 200 + header Request-Id Note over L,S: Khach gui ma a1b2c3 - tim ra ca 3 noi
Phía nginx — biến $request_id là biến dựng sẵn của nginx đời mới
(proxy đang chạy ở máy này là nginx 1.31.3 — đã kiểm tra; bản tối thiểu cần thiết thì chưa verify):
# trong location ~ \.php$
fastcgi_param HTTP_X_REQUEST_ID $request_id;
# và trong log_format JSON của edge
log_format json_combined escape=json '{'
'"time":"$time_iso8601",'
'"request_id":"$request_id",'
'"method":"$request_method",'
'"uri":"$uri",'
'"status":$status,'
'"bytes":$body_bytes_sent,'
'"request_time":$request_time,'
'"upstream_time":"$upstream_response_time",'
'"ua":"$http_user_agent"'
'}';
access_log /var/log/nginx/access.log json_combined;
Phía Laravel — middleware gắn id vào mọi dòng log sau đó, và trả ngược về client để khách báo lỗi kèm mã (cách viết theo tài liệu Laravel, lấy qua Context7):
class AssignRequestId
{
public function handle(Request $request, Closure $next): Response
{
$requestId = $request->header('X-Request-Id') ?: (string) Str::uuid();
Log::shareContext(['request_id' => $requestId]); // mọi channel, mọi dòng log sau đó
$response = $next($request);
$response->headers->set('Request-Id', $requestId);
return $response;
}
}
Log::shareContext() đẩy context sang mọi channel, kể cả channel tạo ra
sau đó; Log::withContext() chỉ tác động lên channel hiện tại. Với mục đích nối
log xuyên tầng thì dùng shareContext.
Log phải là JSON, nếu không Loki không tách được trường để lọc:
// config/logging.php
'stdout_json' => [
'driver' => 'monolog',
'level' => env('LOG_LEVEL', 'info'),
'handler' => Monolog\Handler\StreamHandler::class,
'handler_with' => ['stream' => 'php://stdout'],
'formatter' => Monolog\Formatter\JsonFormatter::class,
],
Ghi ra
stdoutkhi chạy Docker, đừng ghi vào file trong container. File nằm trong container thì rollback một cái là mất, và mỗi colour blue/green lại một file riêng. Ra stdout thì driver log của Docker gom hộ, promtail đọc một chỗ.
1.5 Sáu dòng cấu hình PostgreSQL — bật lên là bớt một nửa việc đoán
PostgreSQL biết rất nhiều về chính nó, nhưng mặc định gần như không nói gì. Đây là giá trị mặc định trên PostgreSQL 18.4, chạy thật:
SHOW log_min_duration_statement; → -1 (tắt: không ghi query chậm)
SHOW log_lock_waits; → off (tắt: không ghi ai chờ khoá)
SHOW log_temp_files; → -1 (tắt: không ghi sort tràn ra đĩa)
SHOW log_checkpoints; → on
SHOW log_autovacuum_min_duration; → 10min
SHOW track_io_timing; → on (container này đã bật sẵn)
SHOW pg_stat_statements.max; → 5000
SHOW pg_stat_statements.track; → top
Ba dòng đầu đang tắt chính là ba câu hỏi hay phải đoán nhất. Bật lên:
| Tham số | Đặt | Được gì | Cái giá |
|---|---|---|---|
shared_preload_libraries | pg_stat_statements | Bảng xếp hạng query theo tổng thời gian | Vài MB shared memory; phải restart |
log_min_duration_statement | 500ms | Query nào vượt ngưỡng thì vào log kèm tham số | Đặt quá thấp thì log phình |
log_lock_waits | on | Ai chờ khoá quá deadlock_timeout (mặc định 1s) thì ghi rõ ai đang giữ | Gần như bằng 0 |
log_temp_files | 0 | Mọi lần sort/hash tràn ra đĩa đều được ghi — dấu hiệu work_mem nhỏ | Gần như bằng 0 |
auto_explain | preload, log_min_duration=1s | Query chậm kèm luôn execution plan, khỏi phải dựng lại | Bật log_analyze thì tốn CPU thật — xem mục 5 |
track_io_timing | on | Thời gian đọc/ghi đĩa trong EXPLAIN (BUFFERS) mới có số thật | Vài % trên máy có clock chậm |
Bằng chứng chạy thật 1 — log_lock_waits. Một session giữ khoá 5
giây, session kia đợi. Log server ghi:
LOG: process 3057619 still waiting for ShareLock on transaction 313418 after 1031.277 ms
DETAIL: Process holding the lock: 3057617. Wait queue: 3057619.
CONTEXT: while updating tuple (0,4) in relation "tmp_obs_lock"
STATEMENT: UPDATE tmp_obs_lock SET stock = stock - 5 WHERE id = 1;
LOG: process 3057619 acquired ShareLock on transaction 313418 after 4014.012 ms
Một dòng log này thay cho cả buổi đoán: biết ai chờ, chờ bao lâu, ai đang giữ, và câu lệnh nào.
Bằng chứng chạy thật 2 — auto_explain. Ngưỡng 10 ms, có
log_analyze và log_buffers:
LOG: duration: 270.748 ms plan:
Query Text: SELECT count(*) FROM tmp_obs_orders WHERE status = 'paid';
Finalize Aggregate (actual time=222.255..270.725 rows=1.00 loops=1)
Buffers: shared hit=4015
-> Gather (actual time=174.000..270.706 rows=3.00 loops=1)
Workers Planned: 2
-> Partial Aggregate (actual time=83.774..84.103 rows=1.00 loops=3)
-> Parallel Seq Scan on tmp_obs_orders (actual time=0.018..79.391 rows=55673.00 loops=3)
Filter: (status = 'paid'::text)
Rows Removed by Filter: 110994
Buffers: shared hit=4015
Không phải đi dựng lại query trên staging với dữ liệu khác rồi thắc mắc "sao ở đây nó nhanh". Plan của chính lần chạy chậm đó nằm sẵn trong log.
2. Bộ công cụ bắt buộc — 9 món, không hơn
Danh sách này là mức tối thiểu. Thiếu một món là ở đó phải đoán:
| # | Món | Bản open-source | Thiếu nó thì phải đoán |
|---|---|---|---|
| 1 | Kho metrics + dashboard | Prometheus + Grafana | "Chậm từ bao giờ?" — không có đồ thị thì không có câu trả lời |
| 2 | Cảnh báo tới người thật | Alertmanager → Telegram/Slack | Khách báo trước mình biết |
| 3 | Log tập trung, JSON, có request id | promtail + Loki | Phải SSH vào từng container grep; rollback là mất log |
| 4 | Trình gom lỗi ứng dụng | GlitchTip (tương thích Sentry) hoặc Sentry | Lỗi lẻ tẻ của khách không ai thấy; không biết lỗi nào nhiều nhất |
| 5 | Kiểm tra từ ngoài (uptime + cert) | blackbox_exporter hoặc Uptime Kuma, đặt ở máy khác | VPS chết cả cụm thì chính hệ thống giám sát cũng chết theo, không ai báo |
| 6 | Metrics host + container | node_exporter + cAdvisor | "Hết RAM hay hết CPU?" — đoán; OOM kill diễn ra âm thầm |
| 7 | Nhìn được vào DB | pg_stat_statements + auto_explain + postgres_exporter | Câu "chậm do DB à?" không bao giờ có bằng chứng |
| 8 | Nhìn được vào PHP-FPM + queue | pm.status_path, slowlog, metric độ sâu hàng đợi | 502 mà không biết do hết worker hay do app chết |
| 9 | Mốc deploy + endpoint version | /health trả version + Grafana annotation | Không nối được "đồ thị xấu đi" với "vừa deploy" |
Hai thứ không phải phần mềm nhưng bắt buộc ngang vậy:
- Runbook — một trang giấy: triệu chứng nào thì mở gì trước, lệnh gì, ai được phép restart cái gì. Lúc 3h sáng người ta không nghĩ ra được, người ta làm theo.
- Một kênh trực duy nhất — mọi cảnh báo đổ về một chỗ. Cảnh báo rải rác ở 4 nơi thì tương đương không có.
2.1 Toàn bộ stack giám sát gói trong một compose
Chạy trên chính VPS đó cũng được, miễn là uptime check đặt ở nơi khác (mục 5 trong bảng). Ngốn khoảng 1–1,5 GB RAM cho quy mô một vài chục nghìn request/ngày — đây là con số ước lượng minh hoạ, chưa đo trên máy này.
services:
prometheus:
image: prom/prometheus
command: ["--config.file=/etc/prometheus/prometheus.yml",
"--storage.tsdb.retention.time=15d"]
volumes: ["./prometheus.yml:/etc/prometheus/prometheus.yml:ro",
"./rules.yml:/etc/prometheus/rules.yml:ro", "promdata:/prometheus"]
alertmanager:
image: prom/alertmanager
volumes: ["./alertmanager.yml:/etc/alertmanager/alertmanager.yml:ro"]
grafana:
image: grafana/grafana
ports: ["127.0.0.1:3000:3000"] # ra ngoài qua nginx + basic auth, không mở thẳng
volumes: ["grafanadata:/var/lib/grafana"]
loki:
image: grafana/loki
volumes: ["lokidata:/loki"]
promtail:
image: grafana/promtail
volumes: ["/var/lib/docker/containers:/var/lib/docker/containers:ro",
"./promtail.yml:/etc/promtail/config.yml:ro"]
node-exporter:
image: prom/node-exporter
pid: host
volumes: ["/:/host:ro,rslave"]
postgres-exporter:
image: prometheuscommunity/postgres-exporter
environment: { DATA_SOURCE_URI: "db:5432/app?sslmode=disable" }
prometheus.yml tối thiểu — chú ý scrape_interval 15s là giá trị hay dùng
(mặc định của Prometheus là 1 phút): đủ mịn để thấy sự cố, đủ thưa để không phình đĩa.
global:
scrape_interval: 15s
rule_files: ["/etc/prometheus/rules.yml"]
alerting:
alertmanagers: [{ static_configs: [{ targets: ["alertmanager:9093"] }] }]
scrape_configs:
- job_name: node
static_configs: [{ targets: ["node-exporter:9100"] }]
- job_name: postgres
static_configs: [{ targets: ["postgres-exporter:9187"] }]
- job_name: nginx
static_configs: [{ targets: ["nginxlog-exporter:4040"] }]
- job_name: blackbox
metrics_path: /probe
params: { module: [http_2xx] }
static_configs: [{ targets: ["https://shop.example.com/health"] }]
2.2 Bảy cảnh báo đầu tiên — và chỉ bảy
Quy tắc của tài liệu Prometheus: cảnh báo trên triệu chứng khách nhìn thấy, ở tầng cao nhất có thể; đừng page vì một thành phần bên dưới trục trặc trong khi khách vẫn mua hàng bình thường.
groups:
- name: san-pham
rules:
- alert: SiteDown # 1. khách không vào được
expr: probe_success == 0
for: 2m
labels: { severity: page }
- alert: HighErrorRate # 2. tỉ lệ 5xx vượt 2%
expr: |
sum(rate(nginx_http_response_count_total{status=~"5.."}[5m]))
/ sum(rate(nginx_http_response_count_total[5m])) > 0.02
for: 5m
labels: { severity: page }
- alert: SlowP95 # 3. p95 vượt 1s
expr: histogram_quantile(0.95,
sum by (le) (rate(nginx_http_request_duration_seconds_bucket[5m]))) > 1
for: 10m
- alert: FpmNoFreeWorker # 4. hết worker, request bắt đầu xếp hàng
expr: phpfpm_listen_queue > 0
for: 5m
- alert: QueueBacklog # 5. job dồn
expr: laravel_queue_pending > 500
for: 10m
- alert: DiskWillFillIn4h # 6. đĩa sắp đầy, dự báo theo xu hướng 6h
expr: predict_linear(node_filesystem_avail_bytes{mountpoint="/"}[6h], 4*3600) < 0
for: 15m
labels: { severity: page }
- alert: Watchdog # 7. dead man's switch: luôn kêu
expr: vector(1)
labels: { severity: none }
Cảnh báo số 7 nghe vô lý nhưng là cái quan trọng nhất. Nó luôn bật, gửi đều đặn sang một dịch vụ bên ngoài (healthchecks.io, hoặc một cron ở máy khác). Khi Prometheus chết, cảnh báo này ngừng đến — và sự im lặng đó chính là báo động. Không có nó thì hệ thống giám sát chết trong yên lặng, mọi đồ thị đứng hình, và mình vẫn tưởng "hôm nay không có sự cố nào".
Chú ý for: — điều kiện phải đúng liên tục bấy nhiêu lâu mới bắn. Thiếu
for thì mỗi cái nhấp nháy 15 giây cũng đánh thức người trực, và đó là con đường
ngắn nhất tới alert fatigue.
3. Bộ công cụ để tối ưu hoá — khác với bộ để trực sự cố
Bộ ở mục 2 trả lời "đang hỏng ở đâu". Bộ ở đây trả lời "tiền thời gian đang tiêu vào đâu". Luật duy nhất: đo trước khi sửa, đo lại sau khi sửa. Không có số trước-sau thì không gọi là tối ưu, gọi là nghịch.
| Câu hỏi tối ưu | Công cụ | Đọc số nào |
|---|---|---|
| Query nào ăn nhiều thời gian nhất tính tổng? | pg_stat_statements | total_exec_time, không phải max_exec_time |
| Query đó chậm vì gì? | EXPLAIN (ANALYZE, BUFFERS) | Buffers, Sort Method, Rows Removed by Filter |
| Index nào thừa, bảng nào thiếu index? | pg_stat_user_indexes, pg_stat_user_tables | idx_scan = 0, seq_tup_read lớn |
| Bảng có phình không? | pg_stat_user_tables, pgstattuple | n_dead_tup, dead_tuple_percent |
| PHP tốn CPU ở hàm nào? | SPX / XHProf / Blackfire | wall time và số lần gọi của từng hàm |
| Bao nhiêu request/giây thì gãy? | k6 hoặc vegeta, bắn vào staging | p95 tăng vọt ở mức RPS nào |
| Cache có ăn không? | redis-cli INFO stats, SLOWLOG | keyspace_hits / misses, evicted_keys |
| Trang nặng ở front-end? | Lighthouse, WebPageTest | LCP, kích thước bundle |
3.1 Sai lầm kinh điển: tối ưu query chậm nhất thay vì query tốn nhất
Chạy thật: một query ORDER BY ... OFFSET 100000, một query
count(*), một query lọc theo index, mỗi cái 10 lần:
query | calls | total_ms | mean_ms | rows | hit | read
--------------------------------------------------+-------+----------+---------+------+-------+------
SELECT * FROM tmp_obs_orders ORDER BY created_at | 10 | 3499.5 | 349.95 | 200 | 40950 | 0
SELECT count(*) FROM tmp_obs_orders WHERE status | 10 | 671.6 | 67.16 | 10 | 40150 | 0
SELECT * FROM tmp_obs_orders WHERE user_id = $1 | 10 | 3.1 | 0.31 | 120 | 147 | 3
Nhìn cột mean_ms thì query thứ ba (0,31 ms) trông vô hại. Nhưng nếu nó bị gọi
5000 lần trong một request — chính là N+1 query — thì bức tranh đảo ngược. Chạy thật, 5000
lần gọi trong một vòng lặp DO:
query | calls | total_ms | mean_ms
------------------------------------------+-------+----------+---------
DO $$ BEGIN FOR i IN 1..5000 LOOP PERFOR | 1 | 265.4 | 265.440
SELECT * FROM tmp_obs_orders WHERE user_ | 5000 | 63.4 | 0.013
SELECT count(*) FROM tmp_obs_orders WHER | 1 | 58.8 | 58.778
Query "nhanh" 0,013 ms gọi 5000 lần tốn 63 ms — hơn cả query "chậm" 58,8 ms. Và đó mới chỉ tính thời gian trong database: vòng lặp tổng cộng mất 265 ms, phần chênh là chi phí gọi đi gọi lại. Qua ứng dụng thật còn cộng thêm mỗi lần một vòng mạng tới DB, nên 5000 lần gọi dễ thành cả giây.
Luôn sắp xếp theo
total_exec_time. Sắp theomean_exec_timesẽ đưa lên đầu mấy câu báo cáo tháng chạy một lần, còn thủ phạm thật — câu 0,3 ms gọi vạn lần — nằm tít dưới.
3.2 EXPLAIN (ANALYZE, BUFFERS) — nói cho biết tiền tiêu vào đâu
Query phân trang sâu, chạy thật:
EXPLAIN (ANALYZE, BUFFERS, COSTS OFF, TIMING OFF)
SELECT * FROM tmp_obs_orders ORDER BY created_at DESC LIMIT 20 OFFSET 100000;
Limit (actual rows=20.00 loops=1)
Buffers: shared hit=4095, temp read=1263 written=2912
I/O Timings: temp read=5.529 write=177.618
-> Gather Merge (actual rows=100020.00 loops=1)
-> Sort (actual rows=33637.67 loops=3)
Sort Key: created_at DESC
Sort Method: external merge Disk: 7880kB ← tràn ra đĩa
-> Parallel Seq Scan on tmp_obs_orders (actual rows=166666.67 loops=3)
Execution Time: 378.735 ms
Ba manh mối nằm ngay trong output, không cần đoán:
Sort Method: external merge Disk: 7880kB—work_memkhông đủ, sort phải ghi ra đĩa. ChínhI/O Timings: temp write=177.618là gần nửa thời gian.actual rows=100020ở tầng trên nhưng chỉ trả về 20 — đọc 100.020 dòng để vứt 100.000 dòng, đó là bản chất củaOFFSETlớn.Parallel Seq Scan— quét cả bảng vì không có index trêncreated_at.
Thử cách hay bị nghĩ tới đầu tiên: thêm index. Chạy thật:
CREATE INDEX tmp_obs_orders_created_idx ON tmp_obs_orders(created_at DESC);
Limit (actual rows=20.00 loops=1)
Buffers: shared hit=99994 read=276 ← vẫn đọc gần 100.000 buffer
-> Index Scan using tmp_obs_orders_created_idx (actual rows=100020.00 loops=1)
Execution Time: 337.988 ms ← 378 ms → 338 ms, gần như không đổi
Index bỏ được phần sort tràn đĩa, nhưng vẫn phải đi qua 100.020 dòng để nhảy tới
dòng thứ 100.001. Vấn đề không nằm ở index, nằm ở OFFSET. Đổi sang
keyset pagination (phân trang theo con trỏ, lấy mốc của trang trước thay vì
đếm từ đầu):
SELECT * FROM tmp_obs_orders
WHERE created_at < '2025-05-31 ...' -- mốc cuối của trang trước
ORDER BY created_at DESC LIMIT 20;
Limit (actual rows=20.00 loops=1)
-> Index Scan using tmp_obs_orders_created_idx (actual rows=20.00 loops=1)
Index Cond: (created_at < ...)
Buffers: shared hit=21 read=2 ← 23 buffer, không phải 100.000
Execution Time: 2.401 ms
| Cách | Thời gian | Buffer đọc | Sort tràn đĩa |
|---|---|---|---|
OFFSET 100000, không index | 378,7 ms | 4.095 + temp 4.175 | 7,8 MB |
OFFSET 100000, có index | 338,0 ms | 100.270 | không |
| keyset, có index | 2,4 ms | 23 | không |
Đây là lý do phải có công cụ. Nếu chỉ nhìn "query chậm 378 ms" rồi thêm index theo phản xạ, kết quả là 338 ms, một index tốn 11 MB và phải bảo trì, và cảm giác "đã tối ưu rồi mà vẫn chậm". Dòng
Buffersnói thẳng ra rằng vấn đề là số dòng phải đi qua, chứ không phải cách tìm chúng.
3.3 Index thừa, bảng phình — hai chỗ âm thầm ăn tiền
Index không ai dùng vẫn phải cập nhật ở mọi lần INSERT/UPDATE, vẫn chiếm đĩa, vẫn làm chậm ghi. Chạy thật sau khi bảng chạy một lúc:
SELECT relname, indexrelname, idx_scan, pg_size_pretty(pg_relation_size(indexrelid))
FROM pg_stat_user_indexes WHERE relname = 'tmp_obs_orders' ORDER BY idx_scan;
table | index | idx_scan | size
----------------+-------------------------------+----------+---------
tmp_obs_orders | tmp_obs_orders_pkey | 0 | 11 MB
tmp_obs_orders | tmp_obs_orders_never_used_idx | 0 | 11 MB ← 11 MB vô ích
tmp_obs_orders | tmp_obs_orders_user_idx | 10 | 4552 kB
Bẫy: idx_scan = 0 chỉ đúng kể từ lần reset thống kê gần
nhất, và ở đây pkey cũng đang là 0 — không có nghĩa là được xoá primary key.
Trước khi xoá phải: (1) xem stats_reset của
pg_stat_database để biết số liệu đã tích luỹ đủ lâu chưa — ít nhất qua một chu
kỳ đầy đủ gồm cả báo cáo cuối tháng; (2) loại trừ index phục vụ ràng buộc unique/khoá ngoại;
(3) kiểm tra cả trên replica, vì query đọc có thể chạy ở đó.
Bảng phình sau khi update 200.000 dòng, chạy thật:
UPDATE tmp_obs_orders SET total = total + 1 WHERE id <= 200000; -- UPDATE 200000
n_live_tup | n_dead_tup | dead_pct | last_autovacuum | autovacuum_count
------------+------------+----------+------------------------------+------------------
500000 | 200000 | 28.6 | 2026-09-08 09:51:31.20232+00 | 1
heap_size: 44 MB (trước khi update là 33 MB)
pgstattuple: dead_tuple_count = 200000, dead_tuple_percent = 26.64
Một câu UPDATE làm 28,6% số dòng trong bảng thành rác và bảng to thêm 1/3. PostgreSQL
không sửa tại chỗ: mỗi UPDATE viết một phiên bản dòng mới và để dòng cũ lại cho autovacuum
dọn. Không nhìn n_dead_tup thì chỉ thấy hiện tượng "dạo này query chậm dần và
đĩa vơi nhanh" mà không hiểu vì sao.
3.4 Ba công cụ tối ưu ngoài database
- Profiler PHP — SPX (miễn phí, cài bằng extension) hoặc Blackfire (trả
tiền). Dùng khi
pg_stat_statementssạch sẽ mà request vẫn chậm: lúc đó thời gian đang tiêu trong PHP chứ không phải DB. Chưa verify ở máy này. - php-fpm
slowlog— cấu hìnhrequest_slowlog_timeout=5sthì mỗi request quá 5 giây được ghi kèm stack trace PHP tại đúng thời điểm đó. Rất hợp để bắt chỗ treo do gọi API ngoài không đặt timeout. - k6 / vegeta — bắn tải vào staging để biết trước điểm gãy, thay vì để
production tìm hộ vào ngày sale. Đo cùng lúc với
/fpm-statusđể thấy chính xác lúc nàolisten queuebắt đầu tăng. Chưa verify ở máy này.
Ở máy đang viết bài này,
/fpm-statusđã đi dây sẵn trong nginx nhưng trả về 404 — vìpm.status_pathtrongpool.confcủa staging còn đang bị comment. Đây đúng là kiểu thiếu sót hay gặp: hạ tầng có, tính năng chưa bật, và chỉ phát hiện ra vào đúng lúc cần nó nhất. Kiểm tra bằng:docker exec <proxy> wget -qO- localhost/fpm-status.
4. Lợi ích
| Được gì | Cụ thể là gì |
|---|---|
| Rút ngắn MTTR | Từ "đoán 60 phút" xuống "10 phút theo runbook". Xem case ở mục 8 — đường đi từ triệu chứng tới câu query thủ phạm chỉ có 4 bước. |
| Tối ưu đúng chỗ | 378 ms → 2,4 ms nhờ Buffers chỉ ra vấn đề là OFFSET, chứ không phải thiếu index. Không có công cụ thì đã thêm index và dừng ở 338 ms. |
| Biết bản deploy nào gây lỗi | Mốc deploy trên đồ thị + blue-green: thấy đường 5xx gãy đúng lúc đổi colour thì đổi ngược lại, không phải tranh luận. |
| Biết khi nào không cần scale | /fpm-status cho thấy còn worker rảnh nghĩa là chậm không nằm ở PHP — thêm RAM cho VPS chỉ tốn tiền vô ích. |
| Có bằng chứng thay vì cãi nhau | "Chậm do DB hay do code?" — pg_stat_statements trả lời bằng số, không bằng cảm giác. |
| Dự trù được tài nguyên | Nhìn xu hướng 3 tháng biết còn bao lâu thì đầy đĩa; predict_linear báo trước 4 giờ. |
| Biết mình đã hết sự cố chưa | Sửa xong nhìn đúng đồ thị lúc nãy. Không có nó thì chỉ có câu "hình như ổn rồi". |
5. Tác hại / hạn chế
5.1 Cardinality nổ — cách phổ biến nhất để giết Prometheus
Mỗi tổ hợp nhãn là một chuỗi thời gian riêng, và mỗi chuỗi tốn vài KB RAM.
Gắn nhãn user_id vào một metric là tự tay tạo ra vài trăm nghìn chuỗi:
# SAI — mỗi user, mỗi đơn hàng là một chuỗi mới
http_requests_total{route="/orders/12345", user_id="98765"}
# ĐÚNG — nhãn chỉ nhận tập giá trị hữu hạn, biết trước
http_requests_total{route="orders.show", status="5xx", colour="blue"}
Quy tắc: nhãn phải trả lời được câu "tập giá trị có thể của nó là hữu hạn và nhỏ chứ?".
Route tên (orders.show) thì hữu hạn; route đường dẫn
(/orders/12345) thì vô hạn. Prometheus có sẵn label_limit,
sample_limit, label_value_length_limit trong scrape config để chặn
trước khi nổ — nên đặt.
5.2 Những cái giá còn lại
| Tác hại | Chi tiết | Giảm nhẹ thế nào |
|---|---|---|
| Log tốn đĩa và tiền | 1 dòng/request, 50k request/ngày, ~400 byte/dòng ≈ 20 MB/ngày chỉ riêng nginx; cộng log app thì gấp vài lần (ước lượng minh hoạ) | Log JSON gọn, giữ 7–14 ngày, bỏ log của asset tĩnh (access_log off cho /build/) |
auto_explain có log_analyze làm chậm DB | Bật đo thời gian từng node trên mọi query, kể cả query nhanh | Đặt log_min_duration ≥ 1s, và auto_explain.sample_rate < 1 nếu tải cao |
| Telescope ở production | Ghi mọi request, mọi query vào chính database đang phục vụ khách — bảng phình rất nhanh và làm app chậm thêm | Chỉ bật ở local/staging. Ở production dùng Pulse (nhẹ hơn, có sample_rate) hoặc APM |
| Dữ liệu cá nhân lọt vào log/Sentry | Body request có mật khẩu, số thẻ, địa chỉ; log giữ 14 ngày là 14 ngày rủi ro | Danh sách trường phải che (password, token, card) ở cả Laravel lẫn SDK Sentry; không log body mặc định |
| Alert fatigue | Kêu nhiều và sai nhiều → người trực tắt tiếng → lúc kêu thật không ai xem | Chỉ page cho triệu chứng khách thấy; mọi thứ khác vào kênh không đánh thức; luôn có for: |
| Cảnh báo sai công thức còn tệ hơn không cảnh báo | Xem case 5 ở mục 8: alert "replication lag 42 giây" trong khi replica đã bắt kịp hoàn toàn | Mọi alert mới phải tự tay dựng lại tình huống rồi kiểm tra nó có kêu đúng không |
| Dashboard nhiều nhưng vô dụng | 40 biểu đồ không trả lời được câu nào; lúc sự cố không ai biết nhìn cái nào | Một dashboard "tổng quan" đúng 6 ô theo RED + saturation; các dashboard sâu để riêng |
| Bản thân hệ giám sát cũng là hệ thống phải nuôi | Nó ăn RAM, đầy đĩa, hỏng sau khi nâng cấp, và chết trong im lặng | Dead man's switch; đặt uptime check ở nhà cung cấp khác; giới hạn retention |
6. Trade-off
6.1 Self-host hay SaaS
| Tiêu chí | Self-host (Prometheus/Grafana/Loki) | SaaS (Datadog, New Relic, Sentry cloud) |
|---|---|---|
| Tiền mặt | Chỉ tiền VPS, ~1–1,5 GB RAM | Tính theo host hoặc GB; phình rất nhanh khi thêm service |
| Công vận hành | Mình nuôi: nâng cấp, đầy đĩa, backup dashboard | Gần như không |
| Thời gian tới lúc dùng được | 1–3 ngày cho bộ tối thiểu | 1–2 giờ |
| Dữ liệu | Ở lại trong nhà | Ra khỏi biên giới hệ thống — cần cân nhắc nếu có dữ liệu cá nhân |
| Trace xuyên tầng | Phải tự dựng (Tempo/Jaeger + OpenTelemetry) | Có sẵn, thường là điểm mạnh nhất của SaaS |
| Khi hệ thống của mình chết | Có thể chết theo | Vẫn sống, vẫn báo được |
Tiêu chí chọn: đội dưới 5 người, 1–3 VPS, ngân sách nhỏ → self-host, và chỉ mua ngoài một thứ: uptime check (nó phải sống khi mọi thứ khác chết). Đội lớn hơn, nhiều dịch vụ, có người trực 24/7 và cần trace xuyên tầng → SaaS rẻ hơn nếu tính cả lương người nuôi hệ giám sát.
6.2 Các đánh đổi hay phải quyết
| Chọn giữa | Nghiêng về A khi | Nghiêng về B khi |
|---|---|---|
| Metric (A) vs đếm bằng log (B) | Cần cảnh báo, cần đồ thị dài hạn, cần rẻ | Câu hỏi hiếm, một lần, không lặp lại |
| Pull (A) vs push qua Pushgateway (B) | Dịch vụ chạy dài, có endpoint /metrics | Cron, job hàng đêm, tiến trình sống ngắn |
| Log 100% (A) vs sampling (B) | Lưu lượng nhỏ; log là bằng chứng nghiệp vụ | Lưu lượng lớn; giữ 100% lỗi nhưng lấy mẫu request thành công |
| Telescope (A) vs Pulse (B) | Local/staging, cần xem từng request và query | Production, chỉ cần ảnh tổng quan và có sample_rate |
| Trace đầy đủ (A) vs chỉ metric + log (B) | Nhiều service gọi nhau, không biết chặng nào chậm | Một khối monolith — request id + pg_stat_statements đã đủ 90% việc |
| Cảnh báo theo triệu chứng (A) vs theo nguyên nhân (B) | Gần như luôn chọn A: cái khách thấy | B chỉ dùng cho cảnh báo báo trước: đĩa sắp đầy, cert sắp hết hạn |
Về trace trong PHP-FPM: mỗi request là một tiến trình sống rất ngắn, không có tiến trình nền để gom và gửi span, nên phải gửi đồng bộ cuối request hoặc qua một collector chạy cạnh. Đó là lý do trace trong PHP tốn công hơn trong Go/Java, và cũng là lý do với monolith nên đầu tư vào request id +
pg_stat_statementstrước, trace sau. Phần này chưa verify bằng cài đặt thật.
7. Khó khăn khi vận hành thực tế
| Khó khăn | Cụ thể ở chỗ nào | Cách sống chung |
|---|---|---|
| Đội quá nhỏ để trực | 1–2 người thì không có ca kíp. Alert 2h sáng nghĩa là một người mất ngủ | Chỉ 2–3 alert được phép đánh thức (site down, 5xx cao, đĩa sắp đầy). Còn lại chờ sáng |
| Đĩa và retention | Metrics 15 ngày nhẹ; log mới là thứ làm đầy đĩa | Đặt hạn mức ngay từ đầu, và cảnh báo đĩa trước 4 giờ chứ không phải lúc đã đầy |
| Hệ giám sát chết trong im lặng | Prometheus OOM lúc 1h sáng, đồ thị đứng hình, sáng ra tưởng đêm qua yên bình | Dead man's switch bắt buộc; cộng thêm cảnh báo up == 0 cho từng target |
| Lệch đồng hồ giữa các máy | Log nginx và log app lệch nhau vài giây thì nối theo thời gian ra kết quả sai | Bật NTP mọi máy; nối theo request id chứ đừng nối theo mốc thời gian |
| Múi giờ trong log | Log UTC, người đọc nghĩ theo giờ Việt Nam, lệch 7 tiếng khi truy vết | Thống nhất một múi giờ và ghi rõ trong runbook. Project này đã có UseLogTimezone vì lý do đó |
| Công cụ có mà chưa bật | /fpm-status ở staging trả 404 vì pm.status_path còn comment — đã kiểm tra thật | Đưa vào checklist sau deploy: mở thử từng endpoint giám sát một lần |
| Dashboard mục nát sau refactor | Đổi tên route thì metric cũ ngừng có dữ liệu — đồ thị thành đường thẳng đẹp đẽ, alert không bao giờ kêu nữa | Thêm alert absent(metric) == 1 cho vài metric xương sống |
| Quyền truy cập | /fpm-status?full lộ URL của mọi request đang chạy; Grafana lộ số liệu kinh doanh | Chỉ mở qua loopback hoặc VPN; Grafana sau basic auth, không mở cổng thẳng ra Internet |
| Chi phí học | PromQL không giống SQL; người mới nhìn dashboard không biết đọc | Runbook viết sẵn câu truy vấn cho từng tình huống, người trực chỉ copy |
8. Case thực tế và cách xử lý
Trước hết là thứ tự mở công cụ. Không có thứ tự thì mỗi người mở một kiểu và mỗi lần sự cố lại làm lại từ đầu:
flowchart TD
A["Có báo động hoặc khách kêu"] --> B{"Ngoài có vào được không?
blackbox / Uptime Kuma"}
B -->|"Không"| B1["DNS, TLS, firewall, nginx
hoặc cả VPS chết"]
B -->|"Có"| C{"Tỉ lệ 5xx có tăng không?
Grafana - nginx"}
C -->|"Có"| D{"Vừa deploy xong?
mốc deploy trên đồ thị"}
D -->|"Có"| D1["Đổi colour về bản cũ trước,
điều tra sau"]
D -->|"Không"| E["GlitchTip: lỗi mới nhóm nào?
lấy request id, tra log Loki"]
C -->|"Không, chỉ chậm"| F{"php-fpm còn worker rảnh?
/fpm-status"}
F -->|"Hết worker, listen queue lớn"| G["Xem chờ ở đâu:
DB, Redis, hay API ngoài"]
F -->|"Còn rảnh"| G
G --> H{"pg_stat_statements:
query nào tổng thời gian tăng?"}
H -->|"Có"| I["EXPLAIN ANALYZE BUFFERS
hoặc đọc plan trong auto_explain"]
H -->|"Không"| J{"pg_stat_activity:
có ai bị khoá không?"}
J -->|"Có"| K["Tìm phiên đang giữ khoá
rồi quyết định huỷ"]
J -->|"Không"| L["Ra ngoài DB:
profiler PHP, API bên thứ ba, Redis"]
Case 1 — Trang danh sách đơn chậm dần sau 22h
Triệu chứng: khách báo trang /orders quay lâu. Không lỗi,
chỉ chậm. Càng về khuya càng chậm.
| Bước | Mở gì | Thấy gì |
|---|---|---|
| 1 | Grafana — p95 theo route | Chỉ orders.index chậm, các route khác bình thường → không phải hạ tầng |
| 2 | Mốc deploy | Không có deploy nào trong 3 ngày → không phải do code mới |
| 3 | /fpm-status | Còn worker rảnh → PHP không phải nút thắt, đang chờ thứ gì đó |
| 4 | pg_stat_statements sắp theo total_exec_time | Một query chiếm 3.499 ms/10 lần gọi, trung bình 350 ms |
| 5 | EXPLAIN (ANALYZE, BUFFERS) | Sort Method: external merge Disk: 7880kB và actual rows=100020 để trả về 20 dòng |
Kết luận: phân trang bằng OFFSET. Trang càng sâu, càng phải
đi qua nhiều dòng — và ban đêm là lúc nhân viên xuất báo cáo, họ nhảy tới trang cuối.
Xử lý: đổi sang keyset pagination — 378 ms xuống 2,4 ms, số buffer đọc từ hơn 100.000 xuống 23 (số liệu chạy thật ở mục 3.2). Chi tiết cách làm với Livewire nằm ở bài phân trang danh sách sản phẩm.
Phòng ngừa: đặt cảnh báo trên pg_stat_statements —
query nào có mean_exec_time vượt 100 ms thì báo vào kênh không đánh thức. Và
chặn từ đầu: giới hạn số trang tối đa cho phép nhảy tới.
Case 2 — 502 ngay sau khi deploy
Triệu chứng: deploy xong 30 giây, tỉ lệ 5xx nhảy lên 40%.
- Rollback trước, điều tra sau. Blue-green nên chỉ cần trỏ nginx về colour cũ. Xem chiến lược deployment và cache khi rollback — rollback code mà quên cache là tự tạo sự cố thứ hai.
- Phân biệt 502 với 504. 502 là php-fpm không nhận kết nối (container chết,
sai upstream, hết worker); 504 là nhận rồi nhưng quá
fastcgi_read_timeout. Hai nguyên nhân khác hẳn nhau — nhìnstatustrong access log JSON là biết ngay, khỏi đoán. - Ba nghi phạm hay gặp nhất: migration chưa chạy nên code mới truy vấn cột chưa có; cache config còn của bản cũ; container colour mới chưa ready mà nginx đã trỏ vào. Log app + GlitchTip phân biệt được ngay trường hợp đầu (lỗi SQL "column does not exist" nổi lên hàng đầu).
Phòng ngừa: smoke test trên cổng riêng của colour mới trước khi
đổi (project này đã có sẵn :8081/:8082 cho green/blue), và cảnh báo
5xx với for: 2m để phát hiện trong lúc còn kịp.
Case 3 — Database "treo" lúc 3h sáng
Triệu chứng: mọi request đụng tới bảng orders đều timeout.
CPU của DB lại thấp — dấu hiệu điển hình của chờ khoá, không phải quá tải.
Câu truy vấn đầu tiên phải chạy (nên chép sẵn vào runbook), output chạy thật khi dựng lại tình huống hai phiên tranh nhau một dòng:
SELECT pid, state, wait_event_type, wait_event,
pg_blocking_pids(pid) AS blocked_by,
round(extract(epoch from now()-xact_start)::numeric,1) AS xact_age_s,
left(regexp_replace(query,'\s+',' ','g'),44) AS query
FROM pg_stat_activity
WHERE datname = current_database() AND pid <> pg_backend_pid() AND state <> 'idle'
ORDER BY xact_start;
pid | state | wait_event_type | wait_event | blocked_by | xact_age_s | query
---------+--------+-----------------+---------------+------------+------------+------------------------------
3057040 | active | Timeout | PgSleep | {} | 3.1 | SELECT pg_sleep(6);
3057039 | active | Lock | transactionid | {3057040} | 2.1 | UPDATE tmp_obs_lock SET stock
Đọc từ dưới lên: phiên 3057039 đang chờ Lock, và
blocked_by = {3057040} chỉ thẳng thủ phạm. Không phải đoán một giây nào.
Trường hợp còn khó chịu hơn là phiên mở transaction rồi ngồi im — nó không chạy gì cả nhưng vẫn giữ khoá. Output chạy thật:
pid | state | xact_age_s | in_state_s | giu_xmin | last_query
---------+---------------------+------------+------------+----------+------------------------------------
3057490 | idle in transaction | 4.1 | 4.1 | f | UPDATE tmp_obs_lock SET stock = st
Đây thường là app mở transaction rồi đi gọi API bên thứ ba, hoặc một cửa sổ psql của ai đó
để quên. state = 'idle in transaction' mà xact_age_s lớn dần là
báo động.
Xử lý theo thứ tự:
- Xác định thủ phạm bằng truy vấn trên. Nếu là phiên người dùng bỏ quên:
SELECT pg_cancel_backend(pid)(huỷ câu lệnh, lịch sự hơn), không được thìpg_terminate_backend(pid)(ngắt hẳn kết nối, transaction bị rollback). - Đặt hàng rào để lần sau tự khỏi, ở mức vai trò ứng dụng chứ đừng đặt toàn cục:
ALTER ROLE app_web SET statement_timeout = '15s'; ALTER ROLE app_web SET lock_timeout = '3s'; ALTER ROLE app_web SET idle_in_transaction_session_timeout = '30s';lock_timeoutquan trọng nhất: thà một request lỗi nhanh còn hơn cả trăm request xếp hàng sau một cái khoá. Riêng vai trò chạy migration và job nền phải đặt riêng, nếu không migration dài sẽ bị chính hàng rào này giết. - Bật
log_lock_waits = onđể lần sau có bằng chứng ngay trong log (mẫu output ở mục 1.5). - Sửa gốc: không gọi HTTP bên trong transaction; transaction ngắn nhất có thể.
Case 4 — GlitchTip nhận 2.000 lỗi giống nhau trong 10 phút
Triệu chứng: quota lỗi cháy trong đêm; sáng ra lỗi thật không được ghi nữa vì đã hết chỗ.
Đây là mặt trái của "cảnh báo mọi thứ": một endpoint bị quét tự động sinh ra hàng nghìn
TypeError giống hệt nhau. Chi tiết cơ chế và cách bịt nằm ở
bài về public property của Livewire.
Ở góc độ công cụ, có ba lớp phòng:
- Chặn ở SDK —
sample_rate, hoặc bộ lọcbefore_sendbỏ qua các nhóm lỗi do đầu vào rác gây ra. - Chặn ở tầng nhận — GlitchTip/Sentry có giới hạn theo dự án. Đặt trước, đừng chờ tới lúc cháy.
- Phân loại đúng chỗ — lỗi do người dùng nhập sai (validation) là metric, không phải exception. Đẩy hết vào trình gom lỗi là tự làm ngập chính mình.
Case 5 — Cảnh báo "replication lag 42 giây" nhưng không có gì hỏng
Triệu chứng: 3h sáng, alert kêu replica trễ 42 giây. Vào xem thì replica hoàn toàn bình thường. Chạy thật trên replica:
SELECT pg_is_in_recovery() AS is_replica,
now() - pg_last_xact_replay_timestamp() AS lag_time,
pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn() AS caught_up;
is_replica | lag_time | caught_up
------------+-----------------+-----------
t | 00:00:42.364102 | t ← "trễ 42 giây" nhưng đã bắt kịp hoàn toàn
Và trên primary, cùng thời điểm:
SELECT client_addr, state, sent_lsn, replay_lsn,
pg_wal_lsn_diff(sent_lsn, replay_lsn) AS replay_lag_bytes FROM pg_stat_replication;
client_addr | state | sent_lsn | replay_lsn | replay_lag_bytes
-------------+-----------+------------+------------+------------------
172.20.0.2 | streaming | F/83528E08 | F/83528E08 | 0 ← lệch 0 byte
Nguyên nhân: pg_last_xact_replay_timestamp() trả về thời điểm
của giao dịch cuối cùng đã replay. Ban đêm không ai ghi gì, nên con số đó cứ già đi
theo đồng hồ — 42 giây "trễ" thật ra là "42 giây nay không có gì để replay". Cảnh báo dựa
một mình vào nó sẽ kêu mỗi đêm.
Sửa công thức: chỉ coi là trễ khi vừa có dữ liệu chưa replay xong:
SELECT CASE WHEN pg_last_wal_receive_lsn() = pg_last_wal_replay_lsn()
THEN 0
ELSE extract(epoch from now() - pg_last_xact_replay_timestamp())
END AS lag_seconds;
Bài học chung: một cảnh báo sai gây hại nhiều hơn không có cảnh báo. Sau vài đêm bị đánh thức nhầm, người trực sẽ tắt tiếng — và tắt luôn cả những cảnh báo đúng. Mọi alert mới đều phải dựng lại tình huống thật để kiểm tra trước khi cho phép nó đánh thức ai đó.
9. Checklist triển khai — theo thứ tự, dừng đúng chỗ
Tuần 1 — không có mấy thứ này thì chưa gọi là vận hành được:
- Log app ra
stdout, định dạng JSON, córequest_id - nginx
log_formatJSON có$request_id,$request_time,$upstream_response_time - Middleware
AssignRequestId+ trả headerRequest-Idvề client - Endpoint
/healthtrả version của bản đang chạy - Uptime check đặt ngoài VPS, kèm cảnh báo cert sắp hết hạn
- GlitchTip/Sentry cắm vào Laravel, có che các trường nhạy cảm
-
shared_preload_libraries = pg_stat_statements, và extension đãCREATE - PostgreSQL:
log_min_duration_statement=500ms,log_lock_waits=on,log_temp_files=0 - Một kênh Telegram/Slack duy nhất để nhận cảnh báo
Tuần 2 — có đồ thị để nhìn:
- Prometheus + Grafana + Alertmanager chạy được
- node_exporter, postgres_exporter, nginxlog-exporter đã được scrape
- Bật
pm.status_pathcủa php-fpm và gọi thử một lần cho chắc - Loki + promtail, tra được theo
request_id - 7 alert ở mục 2.2, trong đó có dead man's switch
- Một dashboard tổng quan đúng 6 ô: rate, tỉ lệ lỗi, p95, worker fpm, độ sâu hàng đợi, đĩa còn trống
- Mốc deploy tự động vẽ lên Grafana từ pipeline
- Runbook một trang: triệu chứng → mở gì → câu lệnh có sẵn để copy
Chỉ thêm khi đã thật sự đau vì thiếu nó: trace phân tán (OpenTelemetry + Tempo), profiler liên tục, RUM đo từ trình duyệt khách, log dài hạn cho mục đích kiểm toán. Thêm sớm hơn thì chỉ được thêm việc phải nuôi.
10. Câu hỏi phụ interviewer hay hỏi tiếp
1. Chỉ được chọn 3 chỉ số để theo dõi, chọn gì?
Tỉ lệ lỗi (5xx / tổng), p95 độ trễ, và một chỉ số bão hoà của tài nguyên khan hiếm nhất (worker php-fpm rảnh hoặc đĩa còn trống). Ba cái đó trả lời được "khách có đau không" và "còn bao lâu nữa thì đau".
2. Vì sao không đếm lỗi bằng cách grep log?
Log là chuỗi văn bản, đếm phải quét — chậm và tốn. Metric là số cộng dồn, đọc tức thì và lưu được hàng năm với chi phí rất nhỏ. Thêm nữa log có thể bị mất hoặc bị cắt bớt khi tải cao, đúng lúc cần nhất. Log dùng để hiểu vì sao, không dùng để đếm.
3. Cardinality là gì và vì sao nguy hiểm?
Là số tổ hợp nhãn của một metric; mỗi tổ hợp là một chuỗi thời gian riêng tốn RAM. Gắn
user_id hay order_id làm nhãn thì số chuỗi tăng theo số người dùng
và Prometheus sẽ OOM. Nhãn chỉ được nhận tập giá trị hữu hạn, biết trước.
4. Vì sao nhìn p95 chứ không nhìn trung bình?
Trung bình che mất phần đuôi. 95 request 50 ms và 5 request 5 giây cho trung bình 297 ms — trông ổn, trong khi 5% khách đang chờ 5 giây. p95/p99 mô tả đúng trải nghiệm tệ nhất mà một phần khách thật đang chịu.
5. Cảnh báo nên đặt trên triệu chứng hay nguyên nhân?
Trên triệu chứng khách nhìn thấy, ở tầng cao nhất có thể — đúng khuyến nghị của tài liệu Prometheus. CPU 90% mà khách vẫn mua hàng bình thường thì không đáng đánh thức ai. Ngoại lệ là cảnh báo báo trước: đĩa sắp đầy, cert sắp hết hạn — lúc thành triệu chứng thì đã muộn.
6. Hệ thống giám sát chết thì sao?
Dead man's switch: một alert luôn bật, gửi đều đặn ra dịch vụ bên ngoài. Khi tín hiệu đó ngừng đến, bên ngoài báo động. Cộng thêm uptime check đặt ở nhà cung cấp khác, để VPS chết cả cụm vẫn có người biết.
7. Telescope bật ở production được không?
Không nên. Nó ghi mọi request và mọi query vào chính database đang phục vụ khách, bảng
phình rất nhanh và làm app chậm thêm — tự tạo ra sự cố mình đang định đo. Ở production dùng
Laravel Pulse (nhẹ hơn, có sample_rate và threshold cho từng
recorder) hoặc APM. Xem thêm bài tối ưu Laravel.
8. Trace trong PHP-FPM khó ở chỗ nào?
Mỗi request là một tiến trình sống rất ngắn, không có tiến trình nền để gom và gửi span
theo lô, nên phải gửi đồng bộ cuối request (thêm độ trễ) hoặc dựng collector chạy cạnh. Với
monolith, request id + pg_stat_statements giải quyết được phần lớn nhu cầu, nên
trace thường là món đầu tư sau cùng.
9. Sắp xếp pg_stat_statements theo cột nào?
total_exec_time. Sắp theo mean_exec_time sẽ đẩy mấy câu báo cáo
tháng chạy một lần lên đầu, còn thủ phạm thật — câu 0,3 ms gọi 5.000 lần mỗi request — nằm
tít dưới. Số liệu chứng minh ở mục 3.1.
10. Làm sao biết bản deploy nào gây lỗi?
Ba thứ đi cùng nhau: endpoint /health trả version, mốc deploy vẽ tự động lên
Grafana từ pipeline, và nhãn colour (blue/green) trên metric của nginx. Nhìn
đường cong gãy đúng chỗ mốc deploy là đủ kết luận, không cần tranh luận.
11. Giữ log bao lâu?
Tuỳ mục đích: gỡ lỗi thì 7–14 ngày là đủ vì gần như không ai tra log cũ hơn thế; nghĩa vụ kiểm toán thì lưu riêng, dạng nén, không để chung với log vận hành. Điều quan trọng hơn thời hạn là có hạn mức đĩa — log không giới hạn sẽ làm đầy đĩa, và đĩa đầy thì database dừng ghi.
12. Một số đo đúng nhưng cảnh báo vẫn sai, có ví dụ không?
Có — case 5 ở trên. pg_last_xact_replay_timestamp() trả số đúng, nhưng dùng
một mình nó làm công thức lag thì mỗi đêm vắng ghi lại báo động giả. Đo đúng không đồng nghĩa
với cảnh báo đúng; alert nào cũng phải dựng lại tình huống thật để thử.
Đọc thêm trong repo
- Canary với Laravel + Livewire — cách dùng chính bộ Prometheus/Grafana này để quyết định tăng hay dừng canary.
- Tối ưu Laravel — bảng công cụ theo môi trường: cái nào local, cái nào được phép bật ở production.
- CREATE INDEX CONCURRENTLY — thêm index sau khi đã đo, mà không khoá bảng.
- Phân trang danh sách sản phẩm — keyset pagination trong Livewire.