Có một kiểu quyết định kỹ thuật mà nếu bạn đặt lên bàn cân "rẻ hơn hay đắt hơn" là bạn đã cân nhầm thứ. Giống như so một cái khoá cửa tự đóng với một cái then bạn phải nhớ gài mỗi lần ra khỏi nhà: cái then rẻ hơn, nhẹ hơn, và trong chín mươi chín lần bạn gài đúng. Vấn đề nằm ở lần thứ một trăm. Bài này là một ca như vậy, và tôi sẽ đo cho bạn thấy cả con số lẫn cái mà con số không nói ra.
Phần trước đo traceparent bị đánh rơi khi một chặng quên chuyển tiếp nó, và kết luận rằng cách chữa là dùng bộ công cụ tự động thay vì truyền tay. Bài này đo cái giá của chính lời khuyên đó: opentelemetry-instrumentation-requests đắt hơn việc tự viết span bao nhiêu, và đổi lại được gì. (Đo trong container Docker, một client Python gọi HTTP tới dịch vụ cùng mạng — không có phần cứng hay perf counter đặc biệt nào.)
Bảng số liệu
Một client Python gọi HTTP tới một dịch vụ trong cùng mạng Docker. Ba mươi vòng, mỗi vòng 400 lời gọi cho mỗi biến thể; lấy trung vị của ba lần chạy toàn bài.
| Cách làm | ns mỗi lời gọi | Chi phí thêm | So với nền |
|---|---|---|---|
| Không trace | 221 993 | — | — |
| Gắn tay (tự tạo span, tự đặt 3 thuộc tính) | 241 797 | +19 804 ns | +8,9% |
Gắn tự động (RequestsInstrumentor) |
270 544 | +48 551 ns | +21,9% |
Gắn tự động đắt gấp 2,45 lần gắn tay.
Đổi lại được gì:
| Gắn tay | Gắn tự động | |
|---|---|---|
| Số thuộc tính | 3 | 4 |
| Thuộc tính | http.method, http.url, http.status_code |
thêm user_agent.original |
| Tên span | HTTP GET (tôi tự đặt) |
GET (theo chuẩn) |
| Số span thu được | 12 300 | 12 300 |
Cái bẫy của việc đọc bảng này quá nhanh
Nhìn con số thì kết luận có vẻ hiển nhiên đến mức khỏi nghĩ: gắn tự động đắt gấp 2,45 lần, mà thứ nó cho thêm chỉ vỏn vẹn một thuộc tính. Viết tay đi, rẻ hơn hai lần rưỡi. Xong.
Nhưng đó là một so sánh sai, và tôi muốn nói thẳng vì sao. Chi phí không phải là thứ phân biệt hai cách làm — cái phân biệt chúng là cái giá của việc quên. Gắn tay mà lỡ sót đúng một chỗ thì bạn mất một span ở đó, và không có một dòng cảnh báo nào. Phần trước của sê-ri đã đo hậu quả bằng số: một chặng đánh rơi ngữ cảnh làm tỷ lệ có trace đầy đủ rơi từ 9,93% xuống 1,01%. Đó không phải một cái sứt mẻ nhỏ, đó là mất gần hết dữ liệu trace.
Còn gắn tự động thì không thể quên. Một dòng instrument() áp lên mọi lời gọi trong tiến trình, kể cả những lời gọi nằm sâu trong thư viện bên thứ ba mà bạn còn chẳng sửa được. Đây mới là điểm mấu chốt, và nó không xuất hiện ở bất cứ ô nào trong bảng.
Và một điều làm nhẹ lòng: ở quy mô thật, cả hai đều nhỏ. Một lời gọi HTTP thật tốn 222 µs; 48,5 µs thêm vào là 21,9%. Nhưng với lời gọi qua mạng thật, độ trễ nền là hàng mili-giây, và cùng 48,5 µs đó co lại còn dưới 5%. Chi phí này hiếm khi là lý do chính đáng để chọn bên nào.
Vì sao con số lại thế
Gắn tay chỉ làm đúng những gì tôi viết: mở span, đặt ba thuộc tính, đóng span. 19,8 µs cho toàn bộ chuyện đó — khớp với con số 8,4 µs đo được ở phần 21 cho việc tạo span thuần, cộng thêm phần đặt thuộc tính và quản lý ngữ cảnh.
Bộ công cụ tự động làm nhiều hơn hẳn, và phần lớn là đúng những việc bạn sẽ quên nếu viết tay. Nó vá vào Session.send, kiểm xem có đang bị tắt tạm thời không, dựng tên span theo đúng quy ước ngữ nghĩa của OpenTelemetry, tiêm traceparent vào header của lời gọi đi ra, đọc lại mã trạng thái để đặt trạng thái span, và gọi các hook do người dùng cấu hình. Cộng lại thành 48,5 µs.
Đáng chú ý nhất: bản gắn tay của tôi không tiêm traceparent. Tôi chỉ tạo span cho phía client; dịch vụ được gọi sẽ chẳng biết gì về span đó và sẽ bắt đầu một trace mới toanh. Nói cách khác, bản gắn tay rẻ hơn một phần chính vì nó làm ít hơn — và cái nó lười bỏ qua lại đúng là thứ phần trước của sê-ri đã chứng minh là đắt nhất khi thiếu. Cái rẻ ấy là rẻ giả.
Nghĩa là gì trong công việc hằng ngày
- Dùng gắn tự động làm nền, gắn tay để bổ sung. Bộ công cụ lo phần khung — tạo span, truyền ngữ cảnh, đặt thuộc tính chuẩn. Bạn thêm span thủ công cho những đoạn nghiệp vụ mà không thư viện nào biết là quan trọng.
- Đừng chọn gắn tay để tiết kiệm 28,7 µs mỗi lời gọi. Với lời gọi HTTP qua mạng thật, đó là phần nghìn của độ trễ. Chọn nó chỉ khi bạn có lý do khác — ví dụ thư viện không có bộ công cụ tự động.
- Nếu viết tay, đừng quên tiêm
traceparent. Đây là phần dễ bỏ sót nhất và là phần duy nhất thực sự quan trọng. Span phía client mà không truyền ngữ cảnh đi thì chỉ là một cái đồng hồ bấm giờ đắt tiền. - Kiểm tên span và thuộc tính sau khi bật gắn tự động. Tên chuẩn là
GET, không phảiHTTP GET— nếu bảng điều khiển của bạn lọc theo tên span, việc chuyển sang gắn tự động sẽ làm chúng trống trơn.
Có một cách đọc bảng này khiến quyết định trở nên dễ hẳn. Hãy tự hỏi: nếu một lập trình viên mới vào đội thêm một lời gọi HTTP ở đâu đó trong mã, span của lời gọi đó có tự xuất hiện không? Với gắn tự động, câu trả lời là có, không cần ai nhắc. Với gắn tay, câu trả lời phụ thuộc vào việc người đó có biết quy ước của đội không, có nhớ không, và có ai review không. Đó là khác biệt thật, và nó không nằm ở bất kỳ đâu trong bảng đo: chi phí 28,7 µs là thứ đo được và nhỏ; xác suất một chỗ bị bỏ sót là thứ không đo được và lớn dần theo thời gian, theo số người, theo số lần mã bị sửa.
Cùng một đánh đổi, đổi hệ sinh thái
Cái căng thẳng giữa "tường minh, rẻ, nhưng dễ quên" và "tự động, đắt hơn chút, nhưng không bỏ sót" không riêng gì OpenTelemetry Python — nó lặp lại ở mọi tầng có chuyện "gắn công cụ quan sát".
Trong thế giới Java, đây gần như là chân lý đã được thừa nhận: agent APM (Datadog, New Relic, OpenTelemetry Java agent) gắn bằng bytecode instrumentation qua -javaagent, tự động vá mọi client HTTP, mọi driver JDBC, mọi consumer Kafka mà không cần lập trình viên đụng một dòng. Cái giá là một chút overhead khởi động và runtime; cái được là bạn không thể quên trace một lời gọi, vì con người không còn tham gia vào việc quyết định gắn hay không. Đúng cùng một logic của bảng đo trên, chỉ ở quy mô cả JVM.
Trong Go, câu chuyện thú vị ở chỗ Go không có runtime cho phép vá kiểu monkey-patch như Python hay agent Java, nên hệ sinh thái buộc phải tường minh: bạn bọc http.Client bằng otelhttp.NewTransport, truyền context.Context qua từng lời gọi bằng tay. Rẻ và minh bạch — nhưng đúng như bảng đo cảnh báo, nó dời gánh nặng "đừng quên" lên vai lập trình viên, và một ctx bị đánh rơi giữa đường là một trace đứt đoạn không lời cảnh báo.
Còn ở cơ sở dữ liệu, cùng đánh đổi hiện ra dưới dạng logging và audit. Bạn có thể để mỗi truy vấn tự log lấy (tường minh, dễ sót), hay bật một lớp audit/pg_stat_statements ở tầng engine bắt mọi câu lệnh tự động (đắt hơn chút, nhưng không câu nào lọt lưới). DBA nào cũng biết: cái audit tường minh dựa vào lập trình viên nhớ là cái audit có lỗ.
Mẫu số chung: khi việc gắn công cụ phụ thuộc vào con người nhớ làm, vùng phủ của bạn không bằng chi phí bạn đo được, mà bằng kỷ luật kém nhất trong đội qua thời gian dài nhất. Cách tự động đắt hơn trên giấy vì nó tính cả cái phần bạn hay quên; cách thủ công rẻ hơn vì nó im lặng bỏ qua đúng cái phần đó. Nên câu hỏi đúng không phải "cái nào tốn ít nano-giây hơn" mà là "cái nào vẫn hoạt động vào ngày người viết nó đã rời đội và không ai nhớ quy ước nữa".
Muốn tự thấy một góc lắt léo của gắn tự động, chạy thử đoạn này:
docker run --rm python:3.12-slim sh -c '
pip -q install requests opentelemetry-sdk opentelemetry-instrumentation-requests
python - <<EOF
import requests
from opentelemetry.instrumentation.requests import RequestsInstrumentor
s1 = requests.Session()
print("truoc instrument, Session.send la:", requests.Session.send.__qualname__)
RequestsInstrumentor().instrument()
s2 = requests.Session()
print("sau instrument, Session.send la:", requests.Session.send.__qualname__)
print()
print("s1 tao TRUOC khi instrument, nhung no dung ham nao?")
print(" s1.send is s2.send ve cung mot ham lop:", s1.send.__func__ is s2.send.__func__)
EOF'
Dòng cuối in True. Đó là lý do không thể giữ một Session làm đối chứng sạch — việc vá diễn ra ở tầng lớp, không ở tầng thể hiện — và cũng là cái bẫy đã làm hỏng hai lần đo đầu của tôi.