Hãy nghĩ về một trace như một sợi chỉ đỏ xuyên qua bốn căn phòng nối nhau, buộc mọi thứ xảy ra trong một yêu cầu vào chung một mối. Chỉ cần một người gác cửa giữa chừng quên cầm sợi chỉ đưa sang phòng kế, sợi chỉ đứt — và điều xảo quyệt là hai đoạn chỉ còn lại trông vẫn nguyên vẹn, thẳng thớm, chẳng ai thấy có gì sai. Bài này tôi làm đúng chuyện đó: cho 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.
Hai phần trước đo trace: cái nó chỉ ra được, và cái nó tốn. Cả hai đều ngầm giả định một điều mà chẳ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 đó xuống. (Đo bằng bốn dịch vụ HTTP thật trong container, khác nhau đúng một biến môi trường — không có phần cứng hay perf counter đặc biệt nào.)
Bảng số liệu
Bốn dịch vụ HTTP thật gọi nối tiếp: c-a → c-b → c-c → c-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ó |
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ó |
c-b |
c6c3a4ff… |
không |
c-c |
ecc5a13b… |
có |
c-d |
ecc5a13b… |
không |
Hai trace riêng biệt cho một yêu cầu: c-a → c-b và c-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.
Ba điều đáng gằn giọng
Không có gì gãy, không có lỗi nào. Đây là chỗ khiến tôi thấy rợn nhất. 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. Nhìn từ mọi góc thông thường, hệ thống hoàn toàn khoẻ mạnh.
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 cực kỳ thuyết phục, vì nó là một trace đúng — chỉ là đúng của một câu chuyện khá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, khớp gần như tuyệt đối. Với ba mảnh thì còn 0,1%; với bốn mảnh là 0,01%. Công thức đáng khắc vào đầu: k mảnh với tỷ lệ lấy mẫu p cho ra p mũ k. Mỗi chỗ gãy trong chuỗi không cộng vào chi phí — nó nhân với nó. Đó là khác biệt giữa một phiền toái và một thảm hoạ.
Vì sao lại thế
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, không có mạng lưới an toàn nào. 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à thản nhiên tự coi mình là gốc.
Hành vi đó vừa đúng vừa 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 đề nằm ở chỗ 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 khi nhìn 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.
Còn về 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 lại từ đầu. Hai lần tung đồng xu độc lập thay vì một lần — và đó chính là nguồn gốc của phép nhân.
Nghĩa là gì trong công việc hằng ngày
- 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 lâu. 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ệ, mà quan hệ mới 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 dai trong hệ thống thật: mọi bảng điều khiển đếm span vẫn xanh, dung lượng vẫn đúng 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ố, bấm vào một trace, và thấy nó cụt ngang.
Cùng một cái đứt chỉ, đổi ngăn xếp
Chuyện "ngữ cảnh chỉ sống nhờ được truyền tay qua từng ranh giới, và một chỗ quên là đứt" không riêng gì tracing phân tán — nó là một mẫu hỏng lặp lại ở mọi nơi có ngữ cảnh ngầm phải đi theo luồng thực thi.
Trong Java, ai từng dùng ThreadLocal để mang ngữ cảnh (MDC của SLF4J cho logging, SecurityContext của Spring Security) đều biết nỗi đau này: mọi thứ chạy trơn trên một luồng, nhưng ngay khi bạn đẩy việc sang một ExecutorService hay CompletableFuture khác luồng, ThreadLocal không tự đi theo — và traceId trong log biến mất đúng ở ranh giới đó, y như c-b đánh rơi header. Đó là lý do các thư viện phải bọc executor để chép ngữ cảnh qua, và vì sao Project Loom với ScopedValue sinh ra một phần để chữa đúng chuyện này.
Trong Go, ngôn ngữ chọn cách tường minh đến mức khắc nghiệt: context.Context phải được truyền bằng tay qua mọi lời gọi hàm trên đường đi. Nghe phiền, nhưng chính sự phiền đó làm chỗ đứt lộ ra ngay — quên truyền ctx là compiler hoặc linter càu nhàu. Đổi lại, chỉ cần một chỗ ai đó dùng context.Background() giữa chuỗi thay vì truyền tiếp ctx cha, sợi chỉ đứt hệt như bài này, mà lần này không có lỗi nào cả.
Còn ở cơ sở dữ liệu, họ hàng gần nhất là truyền ngữ cảnh giao dịch. Một Connection/transaction đang mở là một ngữ cảnh ngầm; gọi một hàm con mà nó lặng lẽ mở connection mới thay vì dùng lại cái đang trong transaction, thế là hai câu lệnh tưởng cùng một giao dịch lại nằm ở hai giao dịch khác nhau — cùng kiểu "vỡ đôi âm thầm", và cùng kiểu chỉ lộ ra khi có sự cố.
Mẫu số chung: ngữ cảnh ngầm chỉ mạnh bằng đúng cái mắt xích yếu nhất trên đường nó phải đi, và mọi chỗ nó đổi luồng hay đổi giao thức là một chỗ nó có thể lặng lẽ rơi. Không chỗ nào trong số đó báo lỗi khi đứt, vì mỗi mảnh, xét riêng, vẫn hoàn toàn hợp lệ. Nên câu hỏi đúng khi dựng bất kỳ hệ nào dựa vào ngữ cảnh truyền dọc theo luồng không phải "nó có chạy không" mà là "ở mỗi ranh giới, ai chịu trách nhiệm cầm sợi chỉ đưa sang phía bên kia".
Muốn tự thấy phép nhân đáng sợ đó thành con số, chạy thử:
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 đúng lúc đang có sự cố.