Observability 3 — PromQL truy vấn metrics

13 thg 7, 2026 3 lượt xem
#monitoring
#devops
#prometheus
#promql
#query

PromQL là gì và vì sao phải học riêng

Prometheus lưu mọi thứ dưới dạng time-series: một chuỗi các cặp (timestamp, value) được định danh bởi tên metric cộng một tập nhãn (label). Ví dụ http_requests_total{service="core-banking", method="POST", status="500"} là một series riêng biệt; đổi một nhãn bất kỳ là ra một series khác. Bản thân dữ liệu thô này gần như vô dụng nếu không có cách tổng hợp, biến đổi, và so sánh — và đó chính là việc của PromQL (Prometheus Query Language).

PromQL không phải SQL. Không có SELECT ... FROM ... WHERE, không có JOIN theo khóa ngoại, không có bảng. Đơn vị làm việc là vector các time-series, và mọi toán tử đều thao tác trên vector đó. Với người làm dữ liệu ngân hàng đã quen SQL, đây là cú "gãy tư duy" đầu tiên cần vượt qua: bạn không truy vấn hàng (row), bạn truy vấn chuỗi thời gian.

Trong bài trước (Kiến trúc Prometheus) ta đã thấy Prometheus scrape metric và lưu vào TSDB. Bài này tập trung vào việc đọc dữ liệu đó: đây là kỹ năng dùng hằng ngày khi dựng dashboard Grafana, viết alert, và điều tra sự cố lúc 2 giờ sáng khi cổng thanh toán chậm.

Bốn kiểu dữ liệu trong PromQL

Mọi biểu thức PromQL trả về một trong bốn kiểu. Hiểu rõ kiểu là điều kiện tiên quyết để biết hàm nào nhận đầu vào gì.

KiểuBản chấtVí dụ biểu thức
Instant vectorTập các series, mỗi series một giá trị tại một mốc thời gianhttp_requests_total
Range vectorTập các series, mỗi series một dãy giá trị trong một khoảnghttp_requests_total[5m]
ScalarMột số thực đơn lẻ, không có nhãn42, time()
StringMột chuỗi (hiếm dùng, chỉ trong vài hàm)"POST"

Instant vector — ảnh chụp tại một thời điểm

Viết trần tên metric là bạn có một instant vector: với mỗi series khớp, Prometheus lấy giá trị gần nhất (trong vòng 5 phút mặc định) trước mốc đánh giá.

process_resident_memory_bytes{job="loan-api"}

Kết quả có thể là nhiều series — mỗi instance (pod) của loan-api một dòng, mỗi dòng một con số bytes tại thời điểm hiện tại. Đây là kiểu bạn hiển thị trên panel "Stat" hay "Gauge" của Grafana.

Range vector — dãy giá trị trong khoảng

Thêm hậu tố [khoảng] (gọi là duration) biến nó thành range vector: với mỗi series, Prometheus trả về tất cả các điểm dữ liệu trong khoảng lùi về quá khứ đó.

http_requests_total{job="core-banking"}[5m]

Với mỗi series, đây có thể là 20 điểm (nếu scrape mỗi 15s trong 5 phút). Điểm mấu chốt: bạn không thể vẽ trực tiếp range vector lên đồ thị, cũng không thể cộng/trừ hai range vector. Range vector chỉ tồn tại để làm đầu vào cho các hàm như rate(), increase(), max_over_time() — những hàm "gộp" dãy giá trị thành một con số duy nhất, biến nó trở lại thành instant vector vẽ được.

Đơn vị duration hợp lệ: ms, s, m, h, d, w, y (có thể ghép như 1h30m).

Chọn series bằng label matcher

Phần trong {} là bộ lọc nhãn. Có bốn toán tử so khớp:

Toán tửÝ nghĩaVí dụ
=Nhãn bằng đúng giá trị{status="500"}
!=Nhãn khác giá trị{status!="200"}
=~Nhãn khớp regex (RE2){status=~"5.."}
!~Nhãn không khớp regex{path!~"/health.*"}

Regex trong PromQL luôn neo toàn phần (fully anchored): status=~"5.." ngầm hiểu là ^5..$, khớp mọi mã 5xx. Vài mẹo hay dùng:

# Mọi mã lỗi 5xx của API lõi
http_requests_total{job="core-banking", status=~"5.."}

# Nhiều service cùng lúc bằng regex hoặc
up{job=~"loan-api|core-banking|payment-gw"}

# Bỏ các endpoint health-check khỏi thống kê
http_requests_total{path!~"/health|/metrics"}

Có thể để trống tên metric và chỉ lọc bằng nhãn đặc biệt __name__:

{__name__=~"http_.*", job="payment-gw"}

Toán tử: số học, so sánh, logic

Số học

+ - * / % ^ áp dụng theo cặp series khớp nhãn (vector-to-vector) hoặc giữa vector và scalar.

# Chuyển bytes sang MB
process_resident_memory_bytes / 1024 / 1024

# Tỷ lệ RAM đã dùng trên tổng RAM (khớp theo instance)
node_memory_active_bytes / node_memory_MemTotal_bytes

Khi cộng/chia hai vector, Prometheus chỉ ghép các series có bộ nhãn giống hệt. Nếu nhãn không khớp một-một, dùng on(...) / ignoring(...)group_left / group_right để chỉ định cách ghép — đây là nguồn lỗi phổ biến nhất khi biểu thức trả về rỗng.

So sánh — dùng để lọc và để alert

== != > < >= <= mặc định hoạt động như bộ lọc: giữ lại series thỏa điều kiện, loại bỏ phần còn lại.

# Chỉ giữ các instance có latency p99 > 1 giây
job:http_p99_seconds > 1

Thêm từ khóa bool để trả về 1/0 thay vì lọc (cần khi vẽ trạng thái true/false):

(node_filesystem_avail_bytes / node_filesystem_size_bytes < 0.1) 
# → chỉ giữ ổ gần đầy; thêm 'bool' → 1 nếu đầy, 0 nếu không

Logic tập hợp: and, or, unless

Ba toán tử này thao tác trên tập hợp series (khớp theo nhãn), không phải trên giá trị:

  • a and b: giữ series của a nếu có series nhãn tương ứng ở b.
  • a or b: hợp của hai vector.
  • a unless b: series của a trừ những cái có mặt trong b.
# Cảnh báo latency cao NHƯNG chỉ khi service đang nhận tải thật
(rate(http_request_duration_seconds_sum[5m]) > 2)
  and (rate(http_requests_total[5m]) > 10)

Ví dụ trên tránh báo động giả lúc nửa đêm khi không có giao dịch — latency cao mà lưu lượng ~0 thì vô nghĩa.

Counter và câu chuyện rate / irate / increase

Đây là phần quan trọng nhất và cũng hay sai nhất. Prometheus phân biệt hai loại metric cơ bản:

  • Counter: chỉ tăng, reset về 0 khi process khởi động lại. Ví dụ http_requests_total, errors_total, số byte đã gửi.
  • Gauge: lên xuống tự do. Ví dụ nhiệt độ, RAM đang dùng, số kết nối DB hiện tại.

Vì sao KHÔNG bao giờ đọc counter thô

Giá trị tuyệt đối của một counter gần như vô nghĩa. http_requests_total = 4.812.334 cho bạn biết gì? Không gì cả — nó là tổng tích lũy từ lần khởi động cuối, phụ thuộc vào việc pod chạy được bao lâu. Điều bạn thật sự quan tâm là tốc độ tăng: bao nhiêu request mỗi giây ngay lúc này. Đó là lý do counter gần như luôn phải bọc trong rate().

Thêm nữa, counter bị reset về 0 khi pod restart. Nếu tự trừ hai điểm bằng tay bạn sẽ ra số âm khổng lồ. Các hàm rate/irate/increase được thiết kế để tự phát hiện và bù reset — đây là lý do thứ hai bắt buộc dùng chúng.

HàmTính gìĐặc tínhDùng khi
rate(c[5m])Tốc độ tăng trung bình/giây trong 5mMượt, chống nhiễu, đã bù resetĐồ thị, alert (mặc định nên dùng)
irate(c[5m])Tốc độ tức thời từ 2 điểm cuối cùngNhạy, dao động mạnhXem đỉnh nhọn ngắn hạn, debug
increase(c[5m])Tổng lượng tăng trong 5m (không chia cho giây)= rate × số giây"Có bao nhiêu lỗi trong 5 phút qua"
# Số request/giây của API lõi, gộp mọi instance
sum(rate(http_requests_total{job="core-banking"}[5m]))

# Tổng số lỗi 5xx trong 1 giờ qua (con số nguyên, dễ đọc cho báo cáo)
sum(increase(http_requests_total{status=~"5.."}[1h]))

Quy tắc vàng cần khắc cốt

  • Chỉ dùng rate/irate/increase cho counter, KHÔNG dùng cho gauge. Với gauge (RAM, số kết nối), giá trị tự nó đã có nghĩa; muốn xu hướng thì dùng deriv(), delta(), hay *_over_time().
  • Khoảng của range vector phải ≥ 4 lần chu kỳ scrape. Nếu scrape mỗi 15s, dùng tối thiểu [1m]; nên [5m]. Khoảng quá ngắn khiến rate() không đủ 2 điểm và trả về rỗng.
  • Trong alert, hầu như luôn dùng rate chứ không irate. irate dao động mạnh, dễ gây alert nhấp nháy (flapping).

Aggregation operators — gộp series

Sau khi có instant vector (thường là kết quả của rate), bạn thường muốn gộp nhiều series thành ít hơn. Các toán tử tổng hợp: sum, avg, min, max, count, stddev, topk, bottomk, quantile.

Điểm cốt lõi là hai bổ ngữ ngược nhau:

  • by (nhãn...): giữ lại các nhãn liệt kê, gộp phần còn lại. Giống GROUP BY trong SQL.
  • without (nhãn...): bỏ các nhãn liệt kê, giữ mọi nhãn khác.
# Tổng request/giây, tách theo mã status (giữ nhãn status)
sum by (status) (rate(http_requests_total{job="core-banking"}[5m]))

# Tổng theo service, gộp bỏ instance và pod
sum without (instance, pod) (rate(http_requests_total[5m]))

# 5 endpoint chậm nhất (topk chọn theo giá trị)
topk(5, sum by (path) (rate(http_request_duration_seconds_sum[5m])))

Sai lầm kinh điển: sum(rate(...)) không có by sẽ gộp tất cả thành một dòng — mất hết ngữ cảnh service/status. Ngược lại quên sum mà so sánh trực tiếp sẽ ra hàng trăm dòng theo từng pod.

histogram_quantile() — tính p95/p99 cho latency

Với độ trễ, trung bình (avg) là kẻ nói dối. Trung bình 200ms có thể che giấu việc 1% khách hàng chờ 8 giây. Cái ta cần là percentile: p99 = 1200ms nghĩa là 99% request nhanh hơn 1,2s. Đây là chỉ số phản ánh trải nghiệm tệ nhất mà đa số người dùng còn chịu được.

Prometheus tính percentile từ metric kiểu histogram. Một histogram như http_request_duration_seconds tự sinh ra nhiều series _bucket với nhãn le (less-than-or-equal): mỗi bucket đếm số request có latency ≤ ngưỡng đó (cộng dồn).

Công thức chuẩn tính p99, đúng theo tài liệu Prometheus:

histogram_quantile(
  0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket{job="payment-gw"}[5m]))
)

Đọc từ trong ra ngoài:

  1. ..._bucket[5m] — lấy range vector của các bucket đếm tích lũy.
  2. rate(...) — vì bucket là counter, phải rate để ra tốc độ theo giây.
  3. sum by (le) — gộp mọi instance nhưng bắt buộc giữ nhãn le (mất le là hàm sai hoàn toàn).
  4. histogram_quantile(0.99, ...) — nội suy tuyến tính giữa các bucket để ước lượng phân vị thứ 99.

Muốn tách p99 theo từng service, thêm nhãn vào by:

histogram_quantile(
  0.95,
  sum by (le, service) (rate(http_request_duration_seconds_bucket[5m]))
)

Lưu ý: kết quả là ước lượng, độ chính xác phụ thuộc cách chọn bucket. Nếu p99 rơi vào bucket cuối cùng le="+Inf", con số vô nghĩa — đó là dấu hiệu cần thêm bucket ở dải cao.

Các hàm hữu ích khác

HàmCông dụngGhi chú
rate(v[d])Tốc độ/giây, trung bìnhChỉ cho counter
increase(v[d])Tổng lượng tăng trong khoảng= rate × giây; cho counter
delta(v[d])Hiệu giá trị đầu–cuốiCho gauge (không bù reset)
deriv(v[d])Đạo hàm (độ dốc) bằng hồi quy tuyến tínhCho gauge
predict_linear(v[d], t)Ngoại suy giá trị sau t giâyDự báo cạn ổ đĩa
label_replace(v, dst, repl, src, regex)Thêm/sửa nhãn dựa trên regexChuẩn hóa nhãn trước khi join/hiển thị
clamp_max/minChặn trần/sàn giá trịLàm sạch dữ liệu nhiễu
*_over_time(v[d])avg/max/min/quantile_over_timeGộp range vector của gauge

Hai ví dụ đáng nhớ:

# Cảnh báo: dự báo ổ /data sẽ đầy trong 4 giờ tới (14400 giây)
predict_linear(node_filesystem_avail_bytes{mountpoint="/data"}[6h], 4*3600) < 0

# Thêm nhãn 'team' từ tên job bằng regex để nhóm dashboard
label_replace(up, "team", "$1", "job", "(.*)-api")

Recording rules — tính trước biểu thức nặng

Biểu thức p99 với histogram_quantile chạy trên hàng nghìn series khá tốn CPU. Nếu 20 panel Grafana và 5 alert cùng tính lại nó mỗi lần refresh, Prometheus sẽ è cổ. Giải pháp là recording rule: định kỳ tính sẵn một biểu thức và lưu kết quả thành metric mới.

groups:
  - name: banking-slo
    interval: 30s
    rules:
      - record: job:http_requests:rate5m
        expr: sum by (job, status) (rate(http_requests_total[5m]))

      - record: job:http_request_p99_seconds
        expr: |
          histogram_quantile(0.99,
            sum by (le, job) (rate(http_request_duration_seconds_bucket[5m])))

Sau đó dashboard/alert chỉ cần truy vấn job:http_request_p99_seconds — nhanh, gọn, nhất quán. Quy ước đặt tên: level:metric:operation (ví dụ job:http_requests:rate5m), giúp người khác đọc là hiểu ngay độ chi tiết và phép tính.

Ba mẫu truy vấn thực chiến

1. Tỷ lệ lỗi HTTP (error rate) — dùng cho SLO:

sum(rate(http_requests_total{job="core-banking", status=~"5.."}[5m]))
/
sum(rate(http_requests_total{job="core-banking"}[5m]))

Cả tử và mẫu đều sum(rate(...)) để cùng đơn vị req/giây; kết quả là tỷ lệ 0–1 (nhân 100 ra %). Đây là "availability" theo golden signal.

2. Độ trễ p99 theo endpoint — như phần histogram ở trên, thêm by (le, path).

3. Saturation CPU và RAM:

# %CPU đang dùng của mỗi node (100% − thời gian idle)
100 - (avg by (instance) (rate(node_cpu_seconds_total{mode="idle"}[5m])) * 100)

# % RAM đã dùng
(1 - node_memory_MemAvailable_bytes / node_memory_MemTotal_bytes) * 100

CPU dùng node_cpu_seconds_total (counter theo mode) nên phải rate rồi lấy phần idle trừ ra; RAM là gauge nên tính trực tiếp không cần rate.

Use case thực tế

Bối cảnh: Cổng thanh toán payment-gw của NCB, scrape mỗi 15s. Chiều thứ Sáu, tổng đài báo khách hàng phàn nàn chuyển khoản chậm và thỉnh thoảng lỗi. Kỹ sư trực dùng PromQL điều tra theo thứ tự golden signals.

Bước 1 — Traffic (có tải bất thường không?):

sum(rate(http_requests_total{job="payment-gw"}[5m]))

Ra ~180 req/s, cao hơn nền ~120 req/s nhưng không đột biến. Loại giả thuyết "sập vì quá tải thuần túy".

Bước 2 — Errors (tỷ lệ lỗi):

sum(rate(http_requests_total{job="payment-gw", status=~"5.."}[5m]))
/ sum(rate(http_requests_total{job="payment-gw"}[5m]))

Ra 0,032 = 3,2%, vượt ngưỡng SLO 1%. Xác nhận có sự cố thật.

Bước 3 — Errors tách theo pod (khoanh vùng):

sum by (instance) (rate(http_requests_total{job="payment-gw", status="503"}[5m]))

Phát hiện 1 trong 4 pod chiếm gần như toàn bộ lỗi 503 — nghi vấn một instance hỏng, không phải lỗi hệ thống diện rộng.

Bước 4 — Latency p99 của pod nghi vấn:

histogram_quantile(0.99,
  sum by (le) (rate(http_request_duration_seconds_bucket{job="payment-gw", instance="10.20.1.7:8080"}[5m])))

Ra 6,4 giây (nền ~0,4s). Kết hợp với container_memory_working_set_bytes của pod đó sát limit → chẩn đoán pod bị áp lực RAM, GC dồn dập, sắp OOM.

Hành động: kill pod cho Kubernetes tái tạo (xem K8s production ops), error rate về 0,1% trong 2 phút. Sau đó thêm recording rule job:payment_error_ratio:rate5m và alert khi > 1% liên tục 5 phút, để lần sau hệ thống tự báo trước khi tổng đài gọi. Toàn bộ chẩn đoán mất ~4 phút nhờ chuỗi truy vấn PromQL chuẩn hóa theo golden signals.

Ghi nhớ

  • PromQL thao tác trên vector time-series, không phải bảng — quên tư duy SQL. Bốn kiểu: instant vector (1 giá trị/series), range vector ([5m], một dãy), scalar, string.
  • Range vector không vẽ được; nó chỉ làm đầu vào cho rate/increase/*_over_time để gộp thành instant vector.
  • Label matcher: =, !=, =~ (regex neo toàn phần), !~. Regex 5.. khớp mọi mã 5xx.
  • Không đọc counter thô. Counter chỉ tăng và reset khi restart → luôn bọc trong rate() (mượt, cho alert), irate() (nhạy, để debug), hoặc increase() (tổng lượng). Range vector phải ≥ 4× chu kỳ scrape.
  • rate/irate/increase chỉ cho counter; gauge dùng delta/deriv/*_over_time.
  • Aggregation với by (giữ nhãn) hoặc without (bỏ nhãn); by giống GROUP BY.
  • histogram_quantile(0.99, sum by (le) (rate(..._bucket[5m]))) — nhớ giữ nhãn le, dùng cho p95/p99 latency vì trung bình che giấu đuôi.
  • predict_linear để dự báo cạn tài nguyên; label_replace để chuẩn hóa nhãn.
  • Recording rule tính trước biểu thức nặng, đặt tên theo level:metric:operation, giúp dashboard/alert nhanh và nhất quán.

Bài viết liên quan

Continuous Integration/Delivery/Deployment, cấu trúc pipeline, ví dụ GitHub Actions và chiến lược release.

13 thg 7, 2026 4

Container vs máy ảo, image/layer, Dockerfile, volume, network, Docker Compose và best practices.

13 thg 7, 2026 3

Vì sao cần orchestration; Pod, Deployment, Service, Ingress; scaling, self-healing và cấu hình.

13 thg 7, 2026 3

Bản chất DevOps: phá bỏ rào cản Dev–Ops, vòng lặp vô tận, CALMS, và vì sao tự động hoá.

13 thg 7, 2026 3