Bài 15 vừa tách một use case thành bốn service và tạo ra một vấn đề mới: khi request đi qua bốn tiến trình, không tiến trình nào nhìn thấy toàn bộ đường đi của nó. Hop nào ăn hết thời gian? Nó hỏng ở đâu? Không trả lời được hai câu đó thì mọi cơ chế chống chịu ở Bài 17 đều là đoán mò.
Lab của bài này tự viết cả ba trụ cột trong app.js — histogram, correlation ID, span — không
dùng thư viện nào. Ba phép đo đáng chú ý. Thứ nhất, với sự cố làm 1% request chậm 300 ms,
p99 đo được là 0,90 ms — nó hoàn toàn không thấy sự cố; phải tới p99,9 mới hiện
ra 474 ms. Thứ hai, thêm đúng một nhãn user_id vào metric làm số chuỗi thời gian nhảy từ
2 lên 49.317 và bộ nhớ từ 330 byte lên 8,4 MB — chỉ trong 8 giây chạy
tải, trên một instance. Thứ ba, quên truyền correlation ID qua một chặng làm waterfall rơi từ 4 span xuống
1 span, và toàn bộ thời gian bị quy sai cho service gọi.
node:22-alpine (app1/app2/app3), mỗi container 1 CPU và 256 MB; bộ đo tải
loadgen.js tự viết, 20 kết nối, closed-loop.Metrics/log/trace đều viết tay trong
app.js: histogram vành đai cố định
[1, 2, 5, 10, 25, 50, 100, 250, 500, 1000, 2500, ∞] ms, span giữ trong ring buffer 200
trace, correlation ID sinh ở chặng đầu và truyền qua header X-Correlation-Id.Một hệ quả của lựa chọn này cần nói trước: vành đai đầu tiên là 1 ms, nên
/metrics báo p50 = 0,5 ms trong khi loadgen đo được 0,13 ms. Đó không phải lỗi
— đó là giới hạn thật của mọi hệ metrics dạng histogram, và mục 16.2 nói kỹ về nó.
16.1 Monitoring trả lời câu hỏi cũ, observability trả lời câu hỏi mới
Hai chữ này hay được dùng thay nhau, nhưng chúng mô tả hai năng lực khác hẳn. Monitoring là chuẩn bị sẵn câu trả lời cho những câu hỏi bạn đã biết trước sẽ hỏi: CPU bao nhiêu, tỉ lệ lỗi bao nhiêu, có vượt ngưỡng không. Nó cho ra dashboard và alert. Observability là khả năng trả lời câu hỏi bạn chưa từng lường trước — thứ luôn xuất hiện trong mọi sự cố thật, vì nếu bạn đã lường trước được thì bạn đã vá rồi.
Ba trụ cột dưới đây khác nhau ở ba chiều cùng lúc: độ chi tiết, chi phí, và loại câu hỏi trả lời được. Hiểu sai chiều nào cũng dẫn tới hoá đơn lớn mà vẫn không debug được.
Cách thay thế: quyết định trước những câu hỏi bạn muốn trả lời được trong sự cố, rồi log đúng các trường cần cho những câu hỏi đó — có schema, có kiểu dữ liệu. Đó là khác biệt giữa log có cấu trúc và log tự do.
16.2 Metrics đúng: RED, USE, và hai cái bẫy
Hai bộ chỉ số phủ gần hết nhu cầu. RED cho mỗi service: Rate (lưu lượng), Errors (tỉ lệ lỗi), Duration (phân bố thời gian). USE cho mỗi tài nguyên: Utilization, Saturation (độ dài hàng đợi — Bài 1 đã cho thấy đây mới là chỉ số cảnh báo sớm), Errors. Một dịch vụ có đủ RED cho từng route và USE cho từng tài nguyên thì đã trả lời được đa số câu hỏi thường gặp.
Nhưng chữ D trong RED phải là phân bố, không phải một con số. Lab tái tạo một sự cố có hình dạng điển hình nhất — đại đa số request nhanh, một tỉ lệ nhỏ chậm 300 ms — rồi hỏi: percentile nào nhìn thấy được sự cố đó?
| Tỉ lệ request chậm | mean | p50 | p95 | p99 | max |
|---|---|---|---|---|---|
| 0,5% | 1,61 ms | 0,13 ms | 0,27 ms | 0,49 ms | 305,82 ms |
| 1% | 3,06 ms | 0,13 ms | 0,22 ms | 0,90 ms | 306,29 ms |
| 2% | 6,28 ms | 0,20 ms | 0,78 ms | 300,01 ms | 314,29 ms |
| 5% | 15,60 ms | 0,39 ms | 10,57 ms | 303,05 ms | 313,99 ms |
Cột p99 có một vách đứng giữa 1% và 2%: từ 0,90 ms nhảy lên 300 ms. Lý do rất đơn giản khi nói ra — p99 là ngưỡng mà 99% request nằm dưới, nên khi đúng 1% request chậm, ngưỡng đó rơi ngay tại biên và có thể rơi về phía nhanh. p99 mù với sự cố ảnh hưởng đúng 1% người dùng. Ở mức 1%, phải tới p99,9 mới thấy — histogram trong app đo được p99,9 = 474 ms.
Còn cột mean thì tệ theo kiểu khác: nó tăng đều đặn (1,61 → 3,06 → 6,28 → 15,60) nên trông có vẻ hữu ích, nhưng không request nào thật sự mất 3,06 ms cả. Với 1% chậm, mean cao gấp 23 lần median. Nó không mô tả trải nghiệm của ai hết: không mô tả người dùng bình thường (0,13 ms), cũng không mô tả người dùng gặp sự cố (300 ms).
loadgen báo p50 = 0,13 ms còn /metrics của app báo
p50 = 0,5 ms. Không cái nào sai: loadgen giữ từng mẫu nên tính percentile chính xác,
còn app dùng histogram vành đai cố định mà vành đai đầu tiên là
[0, 1] ms — mọi request dưới 1 ms rơi chung một ô, và percentile chỉ có thể nội suy tuyến
tính bên trong ô đó, cho ra 0,5.Đây là đánh đổi cố hữu, không phải lỗi: giữ từng mẫu thì tính đúng nhưng bộ nhớ tỉ lệ với số request (đúng vấn đề của sliding window log ở Bài 13), còn histogram thì bộ nhớ cố định nhưng độ chính xác bị chặn bởi vành đai. Hệ quả thực dụng: đặt vành đai quanh ngưỡng SLO của bạn. Nếu SLO là 250 ms thì cần vành đai dày quanh 200–300 ms; đặt vành đai thưa ở đúng vùng đó thì con số SLI sẽ sai theo cách bạn không phát hiện được.
Cái bẫy thứ hai của metrics không nằm ở giá trị mà nằm ở nhãn. Mỗi tổ hợp nhãn là một chuỗi thời gian riêng, và mỗi chuỗi tốn bộ nhớ riêng. Lab chạy cùng một tải với hai cấu hình nhãn khác nhau, 50.000 người dùng phân biệt, trong 8 giây:
| Nhãn | Số chuỗi thời gian | Bộ nhớ |
|---|---|---|
route, status |
2 | 330 byte |
route, status, user
|
49.317 | 8.855.160 byte (8,4 MB) |
user_id hoặc request_id làm nhãn
Kết cục quen thuộc: hệ giám sát sập trước cả hệ thống nó đang giám sát, và nó sập đúng lúc tải cao — tức đúng lúc bạn cần nó nhất. Quy tắc: nhãn phải có tập giá trị hữu hạn và biết trước (route, status, region, phiên bản). Mọi thứ có số lượng tăng theo người dùng thuộc về log hoặc trace, không thuộc về metrics.
16.3 Correlation ID: một header, và chỗ nó hay bị đánh rơi
Log có cấu trúc nghĩa là mỗi dòng là một object có schema thay vì một chuỗi tự do — nhờ đó truy vấn được theo trường, thay vì phải viết biểu thức chính quy lúc 3 giờ sáng. Nhưng trường quan trọng nhất trong đó không phải thông báo lỗi mà là correlation ID: mã định danh sinh ra ở chặng đầu tiên (thường là gateway, Bài 4) và đi theo request qua mọi service.
// Generated at the first hop, reused if already present. These 3 lines are the whole "generate" part.
function corrIdOf(req) {
return req.headers['x-correlation-id'] || `c-${INSTANCE}-${Date.now().toString(36)}-${seq++}`;
}
// Returned to the client: when they report a bug, they attach this ID and you find it instantly.
res.setHeader('X-Correlation-Id', corrId);
// And here is the part that gets forgotten — PROPAGATE it when calling another service:
function callPeer(peer, path, corrId) {
const headers = corrId ? { 'X-Correlation-Id': corrId } : {};
return new Promise((resolve) => {
http.get({ host, port, path, headers }, ...);
});
}
Phần "sinh" thì ai cũng làm đúng. Phần "truyền tiếp" là chỗ hỏng, và nó hỏng
im lặng: không có lỗi nào, mọi request vẫn chạy, chỉ là trace bị cắt làm hai mảnh không nối được.
Lab đo hậu quả bằng cách chạy cùng một chuỗi ba hop với propagate=1 và
propagate=0:
$ ./tools/observe-test.sh trace
--- propagate=1 (CO truyen correlation ID)
so chang: 4
app1/traced-chain 292.52 ms 52.0%
app2/slow-async 256.98 ms 45.7% (!) thu pham hien ra ngay
app3/slow-async 7.46 ms 1.3%
app1/slow-async 5.71 ms 1.0%
--- propagate=0 (QUEN truyen)
so chang: 1
app1/traced-chain 289.32 ms 100.0% (!) quy HET cho app1
Đọc kỹ dòng cuối: khi không truyền ID, hệ thống báo app1 chiếm 100% thời gian. Đó không phải "thiếu thông tin" — đó là thông tin sai, và nó sai theo hướng nguy hiểm nhất, chỉ thẳng vào service vô tội. Một đội đọc waterfall đó sẽ đi tối ưu app1 trong khi thủ phạm thật (app2, ăn 45,7% thời gian) ngồi yên.
Hậu quả: trace đứt đúng tại ranh giới bất đồng bộ, tức đứt ở chỗ khó debug nhất, nơi công việc chạy ngầm và không ai ngồi nhìn. Cách vá là bắt buộc và phải làm ngay từ khi thiết kế message: đưa correlation ID vào chính nội dung message như một trường bắt buộc, rồi consumer khôi phục nó vào ngữ cảnh log của mình trước khi xử lý. Nếu message của bạn hôm nay chưa có trường đó, mọi job nền đang chạy đều là hộp đen.
16.4 Tracing: span nằm rải rác, phải có người thu gom
Một trace là toàn bộ hành trình của một request; mỗi span là một chặng trong đó, có thời điểm bắt đầu, thời lượng, và span cha. Ghép lại thành waterfall — thứ trả lời câu hỏi "hop nào ăn hết thời gian" bằng cách chỉ ra, không phải bằng cách suy đoán.
Có một chi tiết kiến trúc mà lab bắt buộc phải đối mặt và nó rất đáng nói: span của mỗi service nằm trong
bộ nhớ của chính service đó. Không tiến trình nào tự nhiên nhìn thấy toàn bộ. Vì vậy mọi hệ
tracing thật đều cần một bộ thu gom — trong lab này là endpoint /trace-all,
đi hỏi từng instance rồi ghép span lại. Đây chính là vai trò của collector trong các hệ thật, và nó giải
thích vì sao tracing luôn kéo theo thêm một thành phần hạ tầng phải vận hành.
./tools/observe-test.sh trace — cùng một chuỗi ba hop, khác đúng một header.
| Quyết định | Head-based sampling | Tail-based sampling |
|---|---|---|
| Quyết định lúc nào | Ngay đầu request, trước khi biết kết quả | Sau khi request xong, đã biết nó chậm hay lỗi |
| Chi phí | Rẻ — bỏ ngay từ đầu, không sinh span | Đắt — phải giữ đệm mọi span rồi mới quyết định |
| Điểm yếu | Với tỉ lệ 1%, trace của sự cố 1% gần như chắc chắn bị bỏ | Bộ thu gom phải chịu toàn bộ lưu lượng span |
Bảng này nối thẳng với mục 16.2: nếu sự cố của bạn chỉ ảnh hưởng 1% request, thì head-based sampling ở tỉ lệ 1% sẽ giữ lại đúng 1% của 1% — tức một phần vạn. Bạn sẽ có metrics báo p99,9 xấu nhưng không có trace nào của một request chậm để xem. Đó là lý do các hệ trưởng thành thường dùng tail sampling cho đúng nhóm trace đáng giữ: chậm và lỗi.
Triệu chứng rất dễ nhận ra khi biết cần nhìn gì: waterfall có một khoảng trống lớn giữa hai span HTTP, nghĩa là service đã nhận request và đã trả lời, nhưng 200 ms ở giữa không thuộc về span nào. Khoảng trống đó gần như luôn là một truy vấn database. Một waterfall đầy khoảng trống không giúp gì hơn một dòng log.
16.5 SLI, SLO và error budget: biến độ tin cậy thành con số ra quyết định được
Ba khái niệm này thường bị coi là thủ tục quản lý, nhưng chúng giải một vấn đề rất kỹ thuật: quyết định bao giờ thì dừng ship tính năng để đi vá độ tin cậy. Không có chúng, cuộc tranh luận đó diễn ra bằng cảm tính và thường thắng bởi người nói to hơn.
- SLI — chỉ số đo từ góc nhìn người dùng, dạng "tỉ lệ sự kiện tốt trên tổng sự kiện". Ví dụ: tỉ lệ request thành công và dưới 250 ms.
- SLO — mục tiêu có số cho SLI đó, ví dụ 99,9%.
- Error budget — phần được phép hỏng: 100% − SLO. Với SLO 99,9% thì ngân sách là 0,1%, tức khoảng 43 phút mỗi tháng.
Lab tính SLI trực tiếp từ histogram — đúng cách các hệ thật làm, và chỉ làm được vì có histogram chứ không phải trung bình:
| Tỉ lệ request chậm | SLI (dưới 250 ms) | SLO | Error budget đã đốt | Đạt SLO? |
|---|---|---|---|---|
| 0,1% | 99,899% | 99,9% | 0,9% | Sát mép |
| 1% | 99,034% | 99,9% | 865,5% | Không |
| 2% | 98,110% | 99,9% | 1.790,4% | Không |
Hàng giữa là hàng đáng nhớ nhất, và nó khép lại vòng lặp của cả bài: đúng cái sự cố mà p99 không nhìn thấy (1% chậm, p99 = 0,90 ms) lại đốt hết 8,7 lần ngân sách lỗi tháng đó. Nếu bạn chỉ alert theo p99, bạn sẽ ngủ ngon suốt trong khi ngân sách của cả quý bốc hơi.
Cả hai đều có tính chất chung: chúng bám vào tác động, nên chúng vẫn đúng khi hệ thống thay đổi. Alert theo nguyên nhân thì ngược lại — mỗi lần đổi hạ tầng là một lần phải chỉnh lại ngưỡng.
Ở đầu kia, đặt SLO 100% cũng hỏng theo cách riêng: ngân sách lỗi bằng 0 nghĩa là mọi lần deploy đều vi phạm, nên quy tắc đó bị bỏ qua ngay tuần đầu và cả hệ thống SLO mất hiệu lực. SLO phải để lại một khoảng thở đủ để còn thay đổi được hệ thống — đó là chức năng của nó, không phải khiếm khuyết.
Tự làm lại các phép đo
cd blog/sysdesign/sysdesign-lab
# --- Which percentile actually SEES the incident? ---
./tools/observe-test.sh percentile
# 0.5% slow -> p99 0.49ms 1% slow -> p99 0.90ms (p99.9 = 474ms)
# 2% slow -> p99 300.01ms 5% slow -> p99 303.05ms
# p99's cliff edge sits between 1% and 2%.
# --- What happens if we add one user_id label? ---
./tools/observe-test.sh cardinality
# route,status -> 2 time series · 330 bytes
# route,status,user -> 49,317 time series · 8,855,160 bytes
# --- Correlation ID: propagated versus dropped ---
./tools/observe-test.sh trace
# propagate=1 -> 4 spans, app2 eats 45.7% of the time
# propagate=0 -> 1 span, 100% blamed on app1 (WRONG, not just missing)
# --- SLI and error budget computed from a real histogram ---
./tools/observe-test.sh slo
# 1% slow -> SLI 99.034% against a 99.9% target -> 865.5% of the month's budget burned
Tóm lại
Ba trụ cột không thay thế được nhau: metrics cho biết có chuyện, trace cho biết ở đâu, log cho biết vì sao. Chi phí của chúng tăng dần theo đúng thứ tự đó, và mỗi cái có một cái bẫy riêng.
Bẫy của metrics là tưởng rằng chọn p99 là đủ. Phép đo cho thấy điều ngược lại: với sự cố ảnh hưởng đúng 1% người dùng, p99 = 0,90 ms — hoàn toàn mù — trong khi p99,9 = 474 ms và ngân sách lỗi tháng đó đã bị đốt 8,7 lần. Percentile phải chọn theo tỉ lệ người dùng bạn cần bảo vệ, không phải theo thói quen. Còn trung bình thì tệ theo kiểu khác: cao gấp 23 lần median mà không mô tả trải nghiệm của bất kỳ ai.
Bẫy thứ hai của metrics nằm ở nhãn, và nó đắt bằng tiền thật: thêm đúng một nhãn
user_id làm số chuỗi thời gian nhảy từ 2 lên 49.317 — hơn 24 nghìn lần — chỉ
trong 8 giây trên một instance. Nhãn phải có tập giá trị hữu hạn và biết trước.
Về tracing, bài học đắt nhất không phải kỹ thuật mà là hậu quả của một thiếu sót nhỏ: quên truyền correlation ID không làm bạn thiếu dữ liệu, nó làm bạn có dữ liệu sai — 100% thời gian bị quy cho service vô tội, trong khi thủ phạm thật ăn 45,7%. Và chỗ hay quên nhất là ranh giới queue, tức đúng chỗ khó debug nhất.
Bài 17 dùng chính bộ đo này để nhìn một hiện tượng mà nếu không đo thì không bao giờ tin được: hệ thống tự đánh sập mình bằng lưu lượng retry của chính nó, và ba tầng retry ba lần tạo ra hai mươi bảy lần tải lên service yếu nhất.
📖 Tài liệu tham khảo
- Google SRE Book — Service Level Objectives: nguồn gốc SLI/SLO/error budget, nguồn cho mục 16.5
- Brendan Gregg — The USE Method: định nghĩa Utilization/Saturation/Errors cho mỗi tài nguyên, nguồn cho mục 16.2
- Tom Wilkie (Grafana Labs) — The RED Method: định nghĩa Rate/Errors/Duration cho mỗi service, nguồn cho mục 16.2
- Prometheus — Metric and label naming: khuyến cáo chính thức về cardinality của nhãn, nguồn cho cạm bẫy cardinality ở mục 16.2
- W3C Trace Context: chuẩn hoá việc truyền correlation ID/trace context qua header HTTP, nguồn cho mục 16.3
- OpenTelemetry — Traces: định nghĩa chính thức về trace, span và span cha, nguồn cho mục 16.4
Tải mã nguồn thực hành
Bộ đo observability dùng trong lab: histogram vành đai cố định, sinh và truyền correlation ID, thu gom span thành trace, tính SLI/error budget — mọi con số trong bài đến từ file này:
Tải về observe-test.sh (4 phép đo observability)
Bình luận