Hình dung một biên tập viên ảnh phải chọn giữ lại một phần mười số ảnh chụp một sự kiện. Có hai cách. Cách thứ nhất: trước khi sự kiện bắt đầu, quyết định "cứ 10 kiểu bấm máy thì giữ 1", rồi bấm mù theo luật đó — nhanh, rẻ, nhưng đúng khoảnh khắc cô dâu vấp ngã lại có thể rơi vào 9 kiểu bị vứt. Cách thứ hai: chụp hết, xem lại cuối buổi, rồi giữ mọi khoảnh khắc đáng nhớ cộng 10% phần thường — tốn thẻ nhớ để chụp hết, nhưng không bỏ lỡ cái gì quan trọng. Lấy mẫu trace đúng là hai cách chọn ảnh đó, và bài này tôi đo cho ra cái giá của mỗi cách.

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ì. (Đo bằng mô phỏng 200.000 yêu cầu, đo bộ nhớ bằng tracemalloc, không có phần cứng đặc biệt.)

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

Hai hàng in đậm nói tất cả

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 — đúng ông biên tập bấm mù trước khi cô dâu vấp.

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ác bản chất

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 công việc hằng ngày

  • 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.

Cùng một đánh đổi "quyết định sớm mù, quyết định muộn tốn bộ nhớ", đổi ngăn xếp

Chuyện "quyết định trước khi có thông tin thì rẻ nhưng mù, quyết định sau khi thấy toàn cảnh thì đúng nhưng phải đệm mọi thứ trong lúc chờ" không riêng gì trace — nó là đánh đổi kinh điển giữa xử lý streaming và xử lý có buffer.

Trong thế giới Java, đúng đánh đổi này hiện ra ở stream processing với Kafka Streams / Flink: một phép biến đổi stateless (map từng bản ghi) rẻ và không tốn bộ nhớ, nhưng một windowed aggregation — kiểu "gom mọi sự kiện của một session rồi mới quyết" — phải giữ state trong bộ nhớ (hoặc RocksDB) suốt window, đúng như lấy mẫu đuôi giữ span. Và cùng cái bẫy: state trong bộ nhớ (đối tượng JVM có header, con trỏ) phình hơn nhiều so với kích thước đã tuần tự hoá — đúng con số 3,6 lần ở đây.

Trong Go — ngôn ngữ viết OpenTelemetry Collector — bộ xử lý tailsamplingprocessor chính thức làm đúng những gì bài này mô tả: đệm span theo trace_id, chờ một decision_wait, rồi áp policy. Và tài liệu của nó cảnh báo đúng hai thứ tôi đo: dùng nhiều bộ nhớ tỷ lệ với throughput × decision_wait, và bắt buộc định tuyến mọi span cùng trace về một collector instance. Bài đo này chính là lý giải cho từng dòng cấu hình đó.

Còn ở cơ sở dữ liệu, họ hàng gần nhất là streaming aggregate so với materialized-on-read. Một INSERT ... SELECT streaming rẻ; còn một window function hay một aggregate cần thấy hết các dòng trong nhóm (như PERCENTILE_CONT hay một self-join theo session) phải giữ dữ liệu lại — và optimizer đôi khi phải spill ra đĩa vì state không vừa RAM, đúng cái nỗi lo OOM của lấy mẫu đuôi.

Mẫu số chung: quyết định càng cần nhiều bối cảnh thì càng phải trì hoãn tới khi bối cảnh đó đủ, và mọi sự trì hoãn đều phải trả bằng bộ nhớ để đệm cái đang chờ. Lấy mẫu đầu chọn nhanh và mù vì nó không đợi; lấy mẫu đuôi chọn đúng vì nó đợi thấy kết quả, và cái giá là giữ mọi trace dở dang trong RAM. Nên câu hỏi đúng khi thiết kế bất kỳ hệ chọn-lọc-dữ-liệu nào không phải "giữ bao nhiêu phần trăm" mà là "quyết định của tôi cần biết điều gì mà chỉ có sau khi việc xong, và tôi có chịu được cái giá bộ nhớ để đợi biết điều đó không".

Muốn tự thấy cột hoá-đơn và cột giá-trị chênh nhau ra sao, chạy thử:

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.