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:

  1. Có đang hỏng không, và khách có thấy không?
  2. Hỏng ở tầng nào?
  3. Bắt đầu lúc nào, và lúc đó cái gì thay đổi?
  4. 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
observabilityKhả 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.
metricsSố đo theo thời gian: số request/giây, %CPU. Rẻ, lưu lâu, dùng để cảnh báo.
logsDòng chữ ghi lại từng sự kiện. Đắt, nhưng kể được chi tiết "request đó bị gì".
tracesVế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.
exporterChương trình nhỏ dịch số liệu của một phần mềm sang định dạng Prometheus đọc được.
scrapePrometheus tự đi lấy số theo chu kỳ (pull), không chờ app gửi lên.
cardinalitySố 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 / SLOSLI 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 budgetPhần được phép hỏng trong SLO. SLO 99,9% thì mỗi tháng được hỏng ~43 phút.
p95 / p99Ngưỡng mà 95% (hoặc 99%) request nhanh hơn. Nhìn p95 chứ đừng nhìn trung bình.
RED / USEHai bộ chỉ số chuẩn: RED cho dịch vụ — Rate, Errors, Duration. USE cho tài nguyên — Utilisation, Saturation, Errors.
MTTRThời gian trung bình từ lúc hỏng tới lúc chạy lại được.
alert fatigueCả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 switchCả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 idMộ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.
samplingChỉ giữ lại một phần dữ liệu (vd 10% trace) để giảm chi phí.
golden signalsBố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ạiTrả lời câuVí dụChi phí lưuDùng để cảnh báo?
MetricsCó bất thường không, từ lúc nào?p95 route /products = 820 msRất rẻ (vài byte / điểm) — gần như mọi alert nên đứng trên metric
LogsChuyệ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
Traces820 ms đó tiêu vào đâu?DB 640 ms / Redis 20 ms / PHP 160 msĐắt, nên lấy mẫuKhông
ProfilesCPU cháy ở hàm nào?Collection::map() chiếm 38% CPUChỉ bật khi cầnKhô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:

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ầngCâu hỏi cần trả lờiCông cụTín hiệu chính
Trình duyệtKhách thật có thấy chậm không?Synthetic check, Lighthouse; RUM nếu cóTTFB, LCP
DNS / TLS / mạngCó vào tới nơi không? Cert còn hạn không?blackbox_exporter hoặc Uptime Kuma, đặt ngoài VPSprobe_success, số ngày còn lại của cert
nginxBao nhiêu request, mã lỗi gì, chậm bao nhiêu?access log JSON + prometheus-nginxlog-exporterrate, tỉ lệ 5xx, p95 upstream_response_time
php-fpmHết worker chưa? Có ai xếp hàng không?pm.status_path + slowlogactive processes, listen queue, max children reached
LaravelLỗi gì, ở route nào, ai gặp?Log JSON + GlitchTip/Sentry; Pulse cho ảnh tổng quanexception/phút, route chậm nhất
Queue / workerJob có dồn không? Job nào chết?Metric độ sâu hàng đợi + bảng failed_jobssố job chờ, tuổi job cũ nhất
PostgreSQLQuery nào ăn thời gian? Ai khoá ai?pg_stat_statements, auto_explain, pg_stat_activity, postgres_exportertotal_exec_time, số kết nối, lock chờ, replication lag
RedisCache có ăn không? Có bị đuổi key không?redis_exporter, SLOWLOGhit rate, evicted_keys, RAM đã dùng
Host / containerCPU, RAM, disk, IO còn không?node_exporter, cAdvisorload, disk free, iowait, OOM kill
DeployĐang chạy bản nào, đổi lúc nào?/health trả version + annotation trên Grafanamố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 stdout khi 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_librariespg_stat_statementsBảng xếp hạng query theo tổng thời gianVài MB shared memory; phải restart
log_min_duration_statement500msQuery nào vượt ngưỡng thì vào log kèm tham sốĐặt quá thấp thì log phình
log_lock_waitsonAi chờ khoá quá deadlock_timeout (mặc định 1s) thì ghi rõ ai đang giữGần như bằng 0
log_temp_files0Mọ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_explainpreload, log_min_duration=1sQuery chậm kèm luôn execution plan, khỏi phải dựng lạiBật log_analyze thì tốn CPU thật — xem mục 5
track_io_timingonThời gian đọc/ghi đĩa trong EXPLAIN (BUFFERS) mới có số thậtVà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_analyzelog_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ónBản open-sourceThiếu nó thì phải đoán
1Kho metrics + dashboardPrometheus + Grafana"Chậm từ bao giờ?" — không có đồ thị thì không có câu trả lời
2Cảnh báo tới người thậtAlertmanager → Telegram/SlackKhách báo trước mình biết
3Log tập trung, JSON, có request idpromtail + LokiPhải SSH vào từng container grep; rollback là mất log
4Trình gom lỗi ứng dụngGlitchTip (tương thích Sentry) hoặc SentryLỗi lẻ tẻ của khách không ai thấy; không biết lỗi nào nhiều nhất
5Kiểm tra từ ngoài (uptime + cert)blackbox_exporter hoặc Uptime Kuma, đặt ở máy khácVPS 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
6Metrics host + containernode_exporter + cAdvisor"Hết RAM hay hết CPU?" — đoán; OOM kill diễn ra âm thầm
7Nhìn được vào DBpg_stat_statements + auto_explain + postgres_exporterCâu "chậm do DB à?" không bao giờ có bằng chứng
8Nhìn được vào PHP-FPM + queuepm.status_path, slowlog, metric độ sâu hàng đợi502 mà không biết do hết worker hay do app chết
9Mốc deploy + endpoint version/health trả version + Grafana annotationKhô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:

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 ưuCông cụĐọc số nào
Query nào ăn nhiều thời gian nhất tính tổng?pg_stat_statementstotal_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_tablesidx_scan = 0, seq_tup_read lớn
Bảng có phình không?pg_stat_user_tables, pgstattuplen_dead_tup, dead_tuple_percent
PHP tốn CPU ở hàm nào?SPX / XHProf / Blackfirewall 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 stagingp95 tăng vọt ở mức RPS nào
Cache có ăn không?redis-cli INFO stats, SLOWLOGkeyspace_hits / misses, evicted_keys
Trang nặng ở front-end?Lighthouse, WebPageTestLCP, 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 theo mean_exec_time sẽ đư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:

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áchThời gianBuffer đọcSort tràn đĩa
OFFSET 100000, không index378,7 ms4.095 + temp 4.1757,8 MB
OFFSET 100000, có index338,0 ms100.270không
keyset, có index2,4 ms23khô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 Buffers nó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

Ở 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_path trong pool.conf củ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 MTTRTừ "đ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ỗiMố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ênNhì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ưaSử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ạiChi tiếtGiảm nhẹ thế nào
Log tốn đĩa và tiền1 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_explainlog_analyze làm chậm DBBậ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 ở productionGhi 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êmChỉ 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/SentryBody request có mật khẩu, số thẻ, địa chỉ; log giữ 14 ngày là 14 ngày rủi roDanh sách trường phải che (password, token, card) ở cả Laravel lẫn SDK Sentry; không log body mặc định
Alert fatigueKêu nhiều và sai nhiều → người trực tắt tiếng → lúc kêu thật không ai xemChỉ 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áoXem case 5 ở mục 8: alert "replication lag 42 giây" trong khi replica đã bắt kịp hoàn toànMọ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ụng40 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àoMộ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ôiNó ăn RAM, đầy đĩa, hỏng sau khi nâng cấp, và chết trong im lặngDead 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ặtChỉ tiền VPS, ~1–1,5 GB RAMTính theo host hoặc GB; phình rất nhanh khi thêm service
Công vận hànhMình nuôi: nâng cấp, đầy đĩa, backup dashboardGần như không
Thời gian tới lúc dùng được1–3 ngày cho bộ tối thiểu1–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ầngPhả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ếtCó thể chết theoVẫ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ữaNghiêng về A khiNghiê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 /metricsCron, 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à queryProduction, 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ậmMộ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ấyB 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_statements trướ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ănCụ thể ở chỗ nàoCách sống chung
Đội quá nhỏ để trực1–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à retentionMetrics 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ặngPrometheus OOM lúc 1h sáng, đồ thị đứng hình, sáng ra tưởng đêm qua yên bìnhDead 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áyLog nginx và log app lệch nhau vài giây thì nối theo thời gian ra kết quả saiBậ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 logLog UTC, người đọc nghĩ theo giờ Việt Nam, lệch 7 tiếng khi truy vếtThố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ữaThê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 doanhChỉ mở qua loopback hoặc VPN; Grafana sau basic auth, không mở cổng thẳng ra Internet
Chi phí họcPromQL không giống SQL; người mới nhìn dashboard không biết đọcRunbook 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ướcMở gìThấy gì
1Grafana — p95 theo routeChỉ orders.index chậm, các route khác bình thường → không phải hạ tầng
2Mốc deployKhông có deploy nào trong 3 ngày → không phải do code mới
3/fpm-statusCòn worker rảnh → PHP không phải nút thắt, đang chờ thứ gì đó
4pg_stat_statements sắp theo total_exec_timeMột query chiếm 3.499 ms/10 lần gọi, trung bình 350 ms
5EXPLAIN (ANALYZE, BUFFERS)Sort Method: external merge Disk: 7880kBactual 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%.

  1. Rollback trước, điều tra sau. Blue-green nên chỉ cần trỏ nginx về colour cũ. Xem chiến lược deploymentcache khi rollback — rollback code mà quên cache là tự tạo sự cố thứ hai.
  2. 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ìn status trong access log JSON là biết ngay, khỏi đoán.
  3. 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'xact_age_s lớn dần là báo động.

Xử lý theo thứ tự:

  1. 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).
  2. Đặ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_timeout quan 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.
  3. Bật log_lock_waits = on để lần sau có bằng chứng ngay trong log (mẫu output ở mục 1.5).
  4. 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:

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:

Tuần 2 — có đồ thị để nhìn:

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_ratethreshold 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