Ba phần trước đều dẫn tới cùng một chỗ. Trace đầy đủ thì đắt — 134,9 byte mỗi span, 217 GB mỗi ngày ở quy mô vừa. Lấy mẫu là câu trả lời hiển nhiên, và phần trước cho thấy nó còn rẻ hơn cả việc tạo span đầy đủ. Bài này hỏi câu tiếp theo: lấy mẫu kiểu nào, và mỗi kiểu vứt đi đúng cái gì.
Bảng số liệu
200 000 yêu cầu với phân bố thực tế: 2 042 lỗi (1,02%), 7 945 chậm (3,97%), còn lại bình thường.
| Cách lấy mẫu | Trace giữ | Chi phí lưu | Trace lỗi giữ được |
|---|---|---|---|
| Giữ tất cả | 200 000 | 100,00% | 100,00% |
| Lấy mẫu đầu 10% | 19 943 | 9,97% | 10,48% |
| Lấy mẫu đầu 1% | 2 070 | 1,03% | 1,03% |
| Lấy mẫu đuôi, nền 1% | 11 851 | 5,93% | 100,00% |
| Lấy mẫu đuôi, nền 5% | 19 400 | 9,70% | 100,00% |
Bộ nhớ mà lấy mẫu đuôi phải giữ trong lúc chờ, đo bằng tracemalloc:
| 5 000 yêu cầu/giây × 5 giây chờ | 25 000 trace phải đệm |
| Bộ nhớ thực đo | 46,3 MB |
| Mỗi trace | 1,90 KB |
| Ước theo dạng đã tuần tự hoá (135 B × 4 span) | 12,9 MB |
| Biểu diễn trong bộ nhớ đắt gấp | 3,6 lần |
Điều đáng nhớ
So sánh đáng giá nhất nằm ở hai hàng in đậm:
Lấy mẫu đầu 10% giữ 9,97% số trace và 10,48% số trace lỗi. Lấy mẫu đuôi với nền 5% giữ 9,70% số trace và 100% số trace lỗi. Gần như bằng nhau về chi phí lưu trữ, chênh 9,5 lần về thứ bạn thực sự cần.
Và lấy mẫu đuôi với nền 1% còn rẻ hơn lấy mẫu đầu 10% — giữ 5,93% thay vì 9,97% số trace — mà vẫn giữ trọn vẹn mọi trace lỗi và mọi trace chậm.
Lý do đơn giản tới mức dễ bỏ qua: lấy mẫu đầu quyết định trước khi biết kết quả. Nó tung đồng xu ở span đầu tiên, lúc yêu cầu còn chưa chạy. Nó không thể ưu tiên trace lỗi, vì tại thời điểm quyết định thì thông tin đó chưa tồn tại.
Cái giá của lấy mẫu đuôi là bộ nhớ, và nó lớn hơn ước tính. Phải giữ mọi span của mọi trace đang dở dang cho tới khi biết trace đã xong. Ở 5 000 yêu cầu mỗi giây với cửa sổ chờ 5 giây, đó là 25 000 trace và 46,3 MB — gấp 3,6 lần so với ước tính dựa trên kích thước đã tuần tự hoá.
Vì sao
Hai cách này không phải hai mức độ của cùng một thứ. Chúng trả lời hai câu hỏi khác nhau: lấy mẫu đầu hỏi "giữ bao nhiêu phần trăm", lấy mẫu đuôi hỏi "giữ những cái nào".
Lấy mẫu đầu quyết định tại điểm vào, gắn kết quả vào cờ trong traceparent, và mọi chặng sau tuân theo. Ưu điểm rất thật: quyết định một lần, không tốn bộ nhớ, không cần thành phần tập trung, và các chặng không được lấy mẫu thì không tốn gì cả — như phần trước đã đo, span không lấy mẫu còn rẻ hơn span thường. Nhược điểm là nó mù với chính thứ khiến trace có giá trị.
Lấy mẫu đuôi đảo ngược thứ tự: thu mọi span vào một bộ đệm theo trace_id, đợi tới khi không còn span mới trong một khoảng thời gian, rồi mới quyết định dựa trên toàn bộ nội dung. Lúc đó nó thấy được mã lỗi, thấy được độ trễ tổng, và có thể áp luật kiểu "giữ hết trace có lỗi, giữ hết trace trên 500 ms, giữ 1% phần còn lại".
Cái giá nằm ở chữ "đợi". Bộ thu gom phải giữ mọi thứ trong bộ nhớ suốt cửa sổ chờ, và bộ nhớ đó là thường trực chứ không phải đỉnh nhất thời. Con số 3,6 lần so với dạng tuần tự hoá là phần tôi không đoán trước — mỗi span trong bộ nhớ là một cấu trúc có con trỏ, có từ điển thuộc tính, có chi phí đối tượng của ngôn ngữ, chứ không phải một chuỗi byte gọn.
Còn một điều nữa mà lấy mẫu đuôi đòi hỏi: mọi span của một trace phải tới cùng một chỗ. Với nhiều bộ thu gom chạy song song, bạn phải định tuyến theo trace_id, nếu không mỗi bộ chỉ thấy một phần và quyết định trên thông tin thiếu.
Nghĩa là gì trong thực tế
- Nếu đang lấy mẫu đầu ở tỷ lệ thấp, hãy hiểu rằng bạn đang vứt đi phần lớn bằng chứng về sự cố. Ở 1%, bạn giữ được 1,03% số trace lỗi. Khi có người báo lỗi và bạn đi tìm trace của họ, xác suất tìm thấy là một phần trăm.
- Lấy mẫu đuôi gần như luôn đáng, nếu bạn chịu được bộ nhớ. Cùng ngân sách lưu trữ mà giữ được toàn bộ trace lỗi thay vì một phần mười.
- Tính bộ nhớ theo công thức: tốc độ × cửa sổ chờ × kích thước trace trong bộ nhớ. Và dùng con số trong bộ nhớ, không dùng con số đã tuần tự hoá — chênh 3,6 lần là đủ để một dự tính đúng thành một sự cố hết bộ nhớ.
- Rút ngắn cửa sổ chờ là cách rẻ nhất để giảm bộ nhớ, vì nó nhân tuyến tính. Nhưng cửa sổ ngắn hơn thời gian sống của trace dài nhất sẽ cắt cụt chính những trace chậm mà bạn muốn giữ — tức là hỏng đúng mục đích ban đầu.
- Có thể dùng cả hai. Lấy mẫu đầu ở tỷ lệ cao để giảm tải cho ứng dụng, rồi lấy mẫu đuôi ở bộ thu gom để chọn cái nào giữ lại. Nhưng nhớ rằng phần bị đầu vứt thì đuôi không bao giờ thấy.
Chỗ tôi không kết luận được
Đây là mô phỏng, không phải một bộ thu gom thật đang chạy. Tôi sinh 200 000 yêu cầu với phân bố cho trước rồi áp luật lấy mẫu lên chúng. Phép đo bộ nhớ thì thật — tracemalloc trên cấu trúc dữ liệu thật — nhưng nó là bộ đệm tôi tự dựng, không phải bộ đệm của OpenTelemetry Collector với các tối ưu riêng của nó. Con số 1,90 KB mỗi trace là của Python; ngôn ngữ khác sẽ khác nhiều.
Tỷ lệ lỗi và tỷ lệ chậm là do tôi chọn. 1,02% lỗi và 3,97% chậm là con số hợp lý cho một dịch vụ khoẻ mạnh, nhưng chúng quyết định trực tiếp cột "chi phí lưu" của lấy mẫu đuôi. Một dịch vụ đang có 20% lỗi thì lấy mẫu đuôi sẽ giữ ít nhất 20% số trace, và ưu thế về chi phí biến mất — đúng vào lúc sự cố nghiêm trọng nhất. Đó là một tính chất đáng lo mà tôi chưa đo.
Tôi không đo chi phí CPU của việc lấy mẫu đuôi. Bộ thu gom phải nhóm span theo trace_id, giữ hẹn giờ cho từng trace, và chạy luật đánh giá khi trace hoàn tất. Tôi chỉ đo bộ nhớ.
Ba lần chạy của tôi cho kết quả giống hệt nhau tới từng chữ số — vì tôi cố định hạt giống ngẫu nhiên để tập dữ liệu không đổi giữa các lần. Điều đó tốt cho việc so sánh các cách lấy mẫu trên cùng một dữ liệu, nhưng nó có nghĩa là ba lần chạy không phải ba phép đo độc lập: chúng không nói gì về độ biến thiên. Phần duy nhất thực sự được lặp lại ba lần là phép đo bộ nhớ, và nó cho 46,3 MB cả ba lần.
Thử ba mươi giây
docker run --rm python:3.12-slim python - <<'EOF'
import random
random.seed(9)
N = 200_000
reqs = [(random.random() < 0.01) for _ in range(N)] # 1% loi
n_loi = sum(reqs)
def dau(p):
g = gl = 0
for loi in reqs:
if random.random() < p: g += 1; gl += loi
return g, gl
def duoi(nen):
g = gl = 0
for loi in reqs:
if loi or random.random() < nen: g += 1; gl += loi
return g, gl
for ten, (g, gl) in (("DAU 10%", dau(0.10)), ("DUOI nen 5%", duoi(0.05))):
print("%-12s giu %5.2f%% so trace nhung giu %6.2f%% so trace LOI"
% (ten, 100*g/N, 100*gl/n_loi))
EOF
Hai dòng ra có cột đầu gần bằng nhau và cột sau chênh gần mười lần. Cột đầu là hoá đơn bạn trả; cột sau là thứ bạn nhận được.