Hai phần trước đo trace: cái nó chỉ ra được, và cái nó tốn. Cả hai đều giả định một điều mà không ai kiểm — rằng ngữ cảnh trace đi được từ đầu chuỗi tới cuối chuỗi. Bài này bỏ giả định đó: cho đúng một dịch vụ ở giữa quên chuyển tiếp một header, rồi đo xem cái gì gãy.

Một chặng quên truyền traceparent

Bảng số liệu

Bốn dịch vụ HTTP thật gọi nối tiếp: c-ac-bc-cc-d. Ngữ cảnh truyền theo chuẩn W3C bằng header traceparent. Hai kịch bản khác nhau đúng một biến môi trường.

A — mọi chặng đều truyền:

Chặng trace_id Là gốc trace
c-a e5ef2756…
c-b e5ef2756… không
c-c e5ef2756… không
c-d e5ef2756… không

Một trace_id duy nhất, một cây bốn chặng.

B — c-b quên truyền:

Chặng trace_id Là gốc trace
c-a c6c3a4ff…
c-b c6c3a4ff… không
c-c ecc5a13b…
c-d ecc5a13b… không

Hai trace riêng biệt cho một yêu cầu: c-a → c-bc-c → c-d.

Hệ quả khi cộng thêm lấy mẫu 10%, mô phỏng trên 200 000 yêu cầu:

Tỷ lệ yêu cầu có trace đầy đủ
Trace liền mạch 9,93%
Trace gãy làm 2 mảnh 1,01%

Giảm 9,8 lần.

Điều đáng nhớ

Nói lại theo cách khác:

Không có gì gãy, không có lỗi nào. Cả bốn dịch vụ vẫn trả về 200. Cả hai trace đều là trace hợp lệ, đúng định dạng, đầy đủ span. Chỉ số của từng dịch vụ vẫn đúng. Log vẫn đủ. Độ trễ tổng ở c-a vẫn chính xác.

Cái mất là mối liên hệ. c-c trở thành gốc của một trace mới — nó không còn biết ai gọi nó, và không ai truy ngược được từ một lỗi ở c-d về đúng yêu cầu của người dùng đã gây ra nó. Trace của c-a trông như thể chuỗi chỉ có hai chặng, và đó là một lời nói dối rất thuyết phục.

Và lấy mẫu biến chuyện khó chịu thành chuyện vô vọng. Hai mảnh được lấy mẫu độc lập với nhau, nên xác suất giữ được cả hai là 0,10 × 0,10 = 1%. Đo được 1,01% trên 200 000 yêu cầu. Với ba mảnh thì còn 0,1%; với bốn mảnh là 0,01%.

Công thức đáng nhớ: k mảnh với tỷ lệ lấy mẫu p cho ra pk. Mỗi chỗ gãy trong chuỗi không cộng vào chi phí — nó nhân với nó.

Vì sao

Một trace được nối lại với nhau chỉ nhờ đúng một thứ: trace_id giống nhau giữa các span. Không có cơ chế nào khác. Dịch vụ nhận được traceparent thì lấy trace_id trong đó; không nhận được thì nó sinh một trace_id mới và tự coi mình là gốc.

Hành vi đó đúng và cần thiết — đó chính là cách một yêu cầu từ bên ngoài bắt đầu một trace. Vấn đề là dịch vụ không có cách nào phân biệt "tôi là điểm vào thật sự của hệ thống" với "ai đó gọi tôi nhưng quên gửi header". Hai tình huống trông giống hệt nhau từ bên trong.

Đây là lý do lỗi này khó phát hiện đến vậy: nó không tạo ra trạng thái sai, nó tạo ra trạng thái đúng của một câu chuyện khác. Hệ trace của bạn sẽ hiển thị hai trace khoẻ mạnh và tự tin rằng đó là hai yêu cầu riêng biệt.

Về phần lấy mẫu: quyết định lấy mẫu được truyền qua cờ trong chính traceparent — bit cuối của chuỗi 00-<trace_id>-<span_id>-01. Khi header không đi qua, quyết định đó cũng không đi qua, nên mảnh thứ hai tự quyết định lại từ đầu. Hai lần tung đồng xu độc lập thay vì một lần, và đó là nguồn gốc của phép nhân.

Nghĩa là gì trong thực tế

  • Kiểm tra chỗ gãy bằng cách đếm gốc trace. Trong một hệ thống mà chỉ một dịch vụ là điểm vào, số trace có gốc ở dịch vụ khác phải bằng 0. Nếu con số đó lớn hơn 0, bạn đã tìm ra danh sách chính xác các chặng đang mất ngữ cảnh.
  • Nghi ngờ mọi chỗ dữ liệu đổi hình dạng. Ngữ cảnh hay rơi nhất ở ranh giới giữa các giao thức: HTTP sang hàng đợi tin nhắn, đồng bộ sang bất đồng bộ, luồng này sang luồng khác. Ở những chỗ đó, thư viện tự động thường không bao được và phải truyền tay.
  • Tự động hoá việc truyền, đừng làm bằng tay. Mỗi client HTTP viết tay là một chỗ có thể quên. Bộ công cụ tự động của OpenTelemetry tồn tại chính vì lý do này.
  • Nếu đang lấy mẫu, hãy xem chỗ gãy là sự cố nghiêm trọng chứ không phải phiền toái nhỏ. Với 10% lấy mẫu, một chỗ gãy biến "một phần mười số yêu cầu có trace" thành "một phần trăm", và bạn sẽ chỉ nhận ra điều đó vào đúng lúc đang cần một trace cụ thể mà không tìm thấy.
  • Trước khi tin một trace ngắn, hãy hỏi nó có bị cắt không. Một trace hai chặng có thể là chuỗi hai chặng, cũng có thể là nửa đầu của chuỗi bốn chặng.

Có một cách nghĩ giúp nhớ chuyện này. Chỗ gãy ngữ cảnh không phải là mất dữ liệu — dữ liệu vẫn còn nguyên, cả bốn chặng đều ghi span đầy đủ. Cái mất là thông tin về quan hệ, và quan hệ là toàn bộ lý do người ta dựng hệ trace. Một đống span không có quan hệ thì chỉ là log có cấu trúc đắt tiền hơn.

Đó cũng là lý do chỗ gãy sống sót lâu trong hệ thống thật: mọi bảng điều khiển đếm số span vẫn xanh, dung lượng lưu trữ vẫn đúng như dự tính, và người vận hành chỉ phát hiện ra khi ngồi trước một sự cố và bấm vào một trace mà thấy nó cụt ngang.

Chỗ tôi không kết luận được

Phần lấy mẫu là mô phỏng, không phải đo trên hệ thật. Tôi đo phần gãy trace trên bốn dịch vụ chạy thật, nhưng con số 1,01% đến từ 200 000 lần tung đồng xu trong Python, không phải từ một bộ lấy mẫu thật đang chạy. Nó khớp với giá trị lý thuyết 1% nên tôi tin nó về mặt số học — nhưng bộ lấy mẫu thật có thể lấy mẫu theo trace_id một cách tất định, và khi đó hai mảnh có trace_id khác nhau vẫn cho ra hai quyết định độc lập, tức kết quả không đổi. Tôi chưa dựng ra để kiểm điều đó.

Chuỗi của tôi chỉ có một chỗ gãy. Hệ thống thật có thể gãy ở nhiều chỗ, và công thức pk thì tôi suy ra chứ chưa đo cho k bằng 3 hay 4. Phép suy luận đơn giản và tôi tin nó, nhưng nó là suy luận.

Tôi không đo trường hợp gãy một phần — khi ngữ cảnh đi qua được một số lời gọi nhưng không phải tất cả, chẳng hạn một dịch vụ truyền đúng cho lời gọi đồng bộ nhưng đánh rơi ở nhánh đẩy vào hàng đợi. Đó là hình dạng phổ biến hơn trong thực tế và nó tạo ra những cái cây cụt một nhánh, khó nhận ra hơn nhiều so với cái cây bị chặt đôi gọn gàng ở đây.

Và tôi tự cài việc truyền ngữ cảnh chứ không dùng thư viện. Bốn dòng đọc header, sinh trace_id, gắn vào lời gọi sau. Điều đó khiến thí nghiệm sạch và dễ kiểm soát, nhưng nó cũng bỏ qua đúng cái lớp mà trong thực tế thường là nơi phát sinh lỗi: bộ công cụ tự động, với những chỗ nó bao được và những chỗ nó lặng lẽ không bao.

Thử ba mươi giây

docker run --rm python:3.12-slim python - <<'EOF'
# Ti le yeu cau co trace DAY DU, theo so manh va ti le lay mau
print("  %-10s %10s %10s %10s %10s" % ("lay mau", "1 manh", "2 manh", "3 manh", "4 manh"))
for p in (0.01, 0.05, 0.10, 0.50, 1.00):
    print("  %8.0f%% %9.3f%% %9.3f%% %9.3f%% %9.4f%%"
          % (p*100, 100*p, 100*p**2, 100*p**3, 100*p**4))
EOF

Đọc theo hàng: đó là tỷ lệ lấy mẫu bạn đã chọn. Đọc theo cột: đó là số chỗ trong hệ thống đang đánh rơi header. Ô giao nhau là xác suất bạn tìm thấy trace mình cần vào lúc đang có sự cố.