Trace một request xuyên nhiều service
Trace ID và span đi qua header W3C traceparent thế nào, xuất OTLP sang Jaeger hay Tempo, rồi nối trace ID vào log để nhảy từ log sang trace.
TL;DR: P99 của endpoint đặt hàng TaskFlow tăng từ 180ms lên 2,4 giây, nhưng P99 riêng của bốn service nó gọi ra đều dưới 150ms — đúng chỗ metric hết tác dụng. Trace ghi lại toàn bộ hành trình một request qua nhiều service dưới dạng cây span cha con, và header traceparent theo chuẩn W3C Trace Context mang trace ID đi cùng request qua từng lời gọi mạng. Bài này dựng tracing thật với micrometer-tracing-bridge-otel cộng opentelemetry-exporter-otlp, cấu hình sampling, nối trace ID vào log JSON đã học ở spring-core, rồi đọc hình dạng một trace thật để tìm nút thắt.
1. Vì sao P99 xấu mà bốn service đều báo bình thường?
TaskFlow có endpoint POST /api/orders. Trong khoảng 20 phút giờ cao điểm, P99 của nó tăng từ 180ms lên 2,4 giây — con số này bạn đã biết cách đọc từ bài percentile và SLO: đuôi phân bố xấu đi, một phần nhỏ request chịu độ trễ rất lớn trong khi trung bình cộng vẫn trông ổn.
Endpoint này gọi ra bốn service: inventory-service kiểm tồn kho, pricing-service tính giá, payment-service charge thẻ, notification-service gửi email. Dashboard Micrometer của cả bốn đều xanh, P99 riêng từng service dưới 150ms. Không service nào "có tội" — nhưng request tổng vẫn chậm gấp mười ba lần bình thường.
Không phải nghịch lý. P99 của inventory-service gộp trên mọi request nó phục vụ, từ mọi caller. Một request TaskFlow cụ thể có thể vướng đúng lúc service đó chậm bất thường, nhưng chỉ là một hạt trong hàng nghìn request khác, không đủ kéo P99 tổng lệch. Metric aggregate theo service, còn câu bạn cần trả lời là của một request cụ thể: chuỗi bốn cuộc gọi này, chỗ nào chiếm 2,2 giây chênh lệch?
Ranh giới giữa hai nhánh dưới đây là điều bài Observation API đã nói tới: mỗi lời gọi ra ngoài đã bọc trong một Observation, rẽ ra hai nhánh độc lập. Một nhánh gộp theo tag ít giá trị (method, status, service) thành metric — biết P99 xấu đi. Nhánh còn lại, nếu có tracing bridge trên classpath, giữ nguyên chi tiết đúng request thành một span — cần nhánh này để biết chênh lệch nằm ở đâu.
2. Trace và span là gì?
Trace là toàn bộ hành trình xử lý một request, từ lúc vào hệ thống tới lúc trả kết quả, xuyên qua mọi service nó đi qua. Một trace của POST /api/orders gồm cả phần xử lý ở order-service lẫn phần uỷ quyền cho bốn service kia.
Span là một chặng bên trong hành trình đó — một khoảng thời gian có tên, có mốc bắt đầu và kết thúc, gắn với một đơn vị công việc cụ thể: một request HTTP gửi đi, một query DB, một block code được đánh dấu. Mỗi span có span ID riêng; span con giữ tham chiếu tới span ID của span cha, tạo thành một cây, và mọi span cùng trace chia sẻ chung một trace ID.
flowchart TB
C[Client request] --> S1[order-service<br/>span S1, root]
S1 --> S2[inventory-service<br/>span S2, con cua S1]
S1 --> S3[pricing-service<br/>span S3, con cua S1]
S2 --> S4[(DB query<br/>span S4, con cua S2)]Ba span S2, S3, S4 đều mang cùng trace ID với S1, nhưng mỗi span có span ID khác nhau và biết rõ span cha của mình. Nhờ vậy công cụ đọc trace (Jaeger, Tempo) dựng lại được đúng cây phân cấp: request nào gọi request nào, cái nào lồng bên trong cái nào.
3. Context propagation — traceparent đi cùng request
Trace và span vô nghĩa nếu mỗi service tự sinh trace ID riêng — cần cơ chế mang đúng một trace ID xuyên qua network, vì mỗi service là một process riêng, đôi khi viết bằng ngôn ngữ khác, không share bộ nhớ. Cơ chế đó gọi là context propagation: đóng gói trace ID cộng span ID của span cha vào header HTTP, service nhận đọc lại để biết mình là mắt xích trong trace nào.
W3C Trace Context chuẩn hoá việc này bằng header traceparent, gồm bốn phần cách nhau bởi dấu gạch ngang: phiên bản, trace ID (32 ký tự hex), parent ID tức span ID của span gọi tới (16 ký tự hex), và cờ điều khiển lấy mẫu:
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Trước chuẩn này, mỗi vendor tự định nghĩa header riêng, trace qua nhiều hãng không nối được, và thành phần trung gian không biết header nào cần giữ nên có thể âm thầm drop. W3C Trace Context giải quyết đúng chỗ đó: một định dạng chung cho mọi ngôn ngữ, mọi vendor.
Điểm mấu chốt với Spring: RestClient và @HttpExchange ở bài RestClient và bài HTTP interface đã instrument sẵn — miễn dựng qua builder auto-config, mỗi lời gọi ra ngoài tự chèn traceparent. Nhưng không tự động ở mọi nơi: đẩy việc sang thread khác hoặc hàng đợi tin nhắn thì context không tự đi theo — mục Pitfall và bài @Async mổ tiếp chỗ này.
4. Dựng tracing thật trong Spring Boot
Để một span đi được tới Jaeger hay Tempo, cần đúng ba mảnh: thứ dịch dữ liệu observation của Micrometer sang mô hình vendor tracing, thứ đóng gói span rồi gửi đi, và nơi nhận lưu trữ. Thử đoán trước: mỗi mảnh tương ứng dependency hay property nào trong Spring Boot?
Ba mảnh đó lần lượt là:
- Bridge — dịch mô hình
Observationsang mô hình span của một vendor cụ thể. Với OpenTelemetry:io.micrometer:micrometer-tracing-bridge-otel. - Exporter — đóng gói span theo giao thức OTLP rồi gửi qua HTTP/gRPC tới nơi nhận.
io.opentelemetry:opentelemetry-exporter-otlp. - Backend — nơi lưu trữ và hiển thị trace, ví dụ Jaeger hoặc Tempo, nhận dữ liệu qua OTLP.
OTLP (OpenTelemetry Protocol) là giao thức chuẩn hoá cách gửi span, metric và log tới collector.
dependencies {
implementation "org.springframework.boot:spring-boot-starter-actuator"
implementation "io.micrometer:micrometer-tracing-bridge-otel"
implementation "io.opentelemetry:opentelemetry-exporter-otlp"
}
Cấu hình endpoint để exporter biết gửi đi đâu:
management:
otlp:
tracing:
endpoint: http://localhost:4318/v1/traces
Property management.otlp.tracing.endpoint trỏ tới địa chỉ HTTP của collector — Jaeger, Tempo, hoặc một OpenTelemetry Collector đứng trước để gom trace từ nhiều nguồn. Chỉ cần ba mảnh này cộng Actuator đã có từ bài Actuator: request nào chạm RestClient/@HttpExchange auto-config, hoặc @Observed đã đặt ở bài Observation API, đều tự sinh span và tự gửi đi.
5. Sampling — không lưu được hết
Lưu từng span của từng request không khả thi ở quy mô production. Một endpoint xử lý vài trăm request mỗi giây, mỗi request sinh bốn năm span — lưu 100% tốn cả băng thông lẫn chi phí lưu trữ ngang chính traffic sản phẩm.
Spring Boot mặc định chỉ lấy mẫu 10% số request:
management:
tracing:
sampling:
probability: 0.1
Đây là đánh đổi thật: với probability 0,1, chín trên mười request không được lưu trace — kể cả đúng request lỗi bạn cần điều tra nhất. Quyết định lấy mẫu xảy ra ngay đầu request, trước khi biết request có lỗi hay chậm, nên nó vốn "mù" trước hậu quả.
Lối ra ở tầng OpenTelemetry Collector: tail-based sampling — collector giữ toàn bộ span một trace trong bộ nhớ đệm tới khi trace kết thúc, lúc đó mới quyết định giữ hay bỏ dựa trên việc trace có lỗi hay chậm bất thường. Cấu hình nằm ở collector, ngoài property của Boot, nhưng giải đúng lỗ hổng của head-based.
6. Nối trace ID vào log
Structured logging JSON đã học kỹ ở spring-core giờ thêm một việc: khi tracing bật, Spring Boot tự thêm correlation ID — gồm traceId và spanId — vào định dạng log mặc định, chỉ cần bridge tracing có mặt trên classpath.
traceId trở thành field tìm kiếm được trong log JSON:
{"timestamp":"2026-07-29T09:14:22.103Z","level":"ERROR","service":"inventory-service","traceId":"4bf92f3577b34da6a3ce929d0e0e4736","spanId":"00f067aa0ba902b7","message":"Timeout calling warehouse API"}
Đường đi hai chiều giá trị nhất khi điều tra sự cố: từ một dòng log lỗi, copy traceId, dán vào Jaeger hoặc Tempo để xem trọn hành trình qua mọi service. Ngược lại, từ một span chậm trong trace, lấy traceId, search ngược lại toàn bộ log của mọi service liên quan tới đúng request đó.
7. Đọc một trace để chẩn đoán
Hình dạng của một trace tự nó đã kể một câu chuyện. Ba hình dạng đáng nhận ra:
- Chuỗi span dài liên tiếp — công việc chạy tuần tự. Nếu các bước không phụ thuộc kết quả của nhau, đây là chỗ song song hoá được (
CompletableFuturehoặc virtual thread thay vì gọi lần lượt). - Nhiều span ngắn giống hệt nhau lặp lại — dấu hiệu N+1: vòng lặp gọi lại đúng một kiểu truy vấn cho từng phần tử, thay vì gộp một lời gọi duy nhất. Cùng bản chất N+1 đã nói ở bài cascade và N+1, chỉ khác là nhìn qua span thay vì log SQL.
- Khoảng trống giữa các span — không có công việc nào được đo trong khoảng đó, vì việc chờ (hàng đợi thread pool, lấy connection pool) thường xảy ra trước khi observation bước tiếp theo kịp bắt đầu.
Ví dụ một trace thật của POST /api/orders, tổng thời gian 2400ms:
gantt
title Trace POST /api/orders (truc: giay.mili-giay, tong 2.400)
dateFormat x
axisFormat %S.%L
section order-service
Root span xu ly request : active, 0, 2400
section inventory-service
Kiem tra ton kho (HTTP) : crit, 100, 900
section pricing-service
Tinh gia (HTTP) : 950, 1050
section payment-service
Charge card (HTTP) : 1100, 1300
section notification-service
Gui email (HTTP) : 1350, 1450Bốn span con cộng lại chỉ chiếm 1200ms (800 + 100 + 200 + 100), nhưng root span kéo dài 2400ms — 1200ms còn lại rải thành nhiều khoảng trống không được đo: 100ms trước inventory-service, 50ms giữa mỗi cặp service kế tiếp (inventory→pricing, pricing→payment, payment→notification), và 950ms sau notification-service tới lúc trả response. Riêng span inventory-service chiếm 800ms trong khi P99 bình thường của chính nó dưới 150ms — đây là request cá biệt đã kéo P99 tổng lên 2,4 giây, và giờ bạn biết chính xác chặng cần điều tra tiếp: vì sao inventory-service chậm bất thường đúng lúc này.
Pitfall thường gặp
❌ Tự dựng RestClient bằng new RestClient.Builder() thay vì inject RestClient.Builder:
// SAI - tu tao builder, khong qua auto-config
RestClient client = RestClient.builder().baseUrl(url).build();
✅ Auto-config đăng ký observation convention lên đúng builder nó tạo ra. Chỉ constructor injection RestClient.Builder mới có instrumentation, mới tự chèn traceparent. Tự new builder bỏ qua auto-config — trace bị đứt ngay tại chỗ này mà không lỗi nào báo ra.
❌ Đẩy việc sang thread tự tạo hoặc hàng đợi tin nhắn mà không tự truyền context:
// SAI - thread tu tao khong ke thua trace context
new Thread(() -> inventoryService.reserve(orderId)).start();
✅ Context propagation hoạt động qua interceptor gắn trên TaskExecutor auto-config và các HTTP client kể trên. new Thread(...), hoặc publish message qua Kafka/RabbitMQ mà không tự nhét traceparent vào header rồi đọc lại ở consumer, đều làm trace gãy thành hai trace rời rạc.
Đào sâu
- W3C Trace Context — spec chính thức header
traceparent, cấu trúc bốn phần, lý do tồn tại một chuẩn chung xuyên vendor. - Spring Boot 3.4 Reference — Tracing — dependency bridge/exporter, property endpoint và sampling, lưu ý chỉ builder auto-config mới tự propagate trace.
- Micrometer Tracing Reference — facade trung lập vendor phía sau bridge, tích hợp với
ObservationHandlerđã học ở bài trước.
Liên hệ các bài khác
- Bài 04 — Percentile và SLO — P99 báo có vấn đề; bài này là bước tiếp theo để biết vấn đề nằm ở đâu.
- Bài 05 — Observation API — một
Observationsinh ra cả metric (low-cardinality) lẫn span (high-cardinality); bài này khai thác nhánh span. - Bài RestClient /
@HttpExchange— nơitraceparentđược chèn tự động qua auto-configured builder. - Bài
@Async— chỗ context propagation không tự động khi tự đẩy việc sang thread khác. - Bài cascade và N+1 — cùng triệu chứng N+1, nhìn qua span thay vì log SQL.
Tóm tắt
- Metric aggregate theo service có thể "sạch" trong khi một request cụ thể vẫn chậm bất thường — trace giữ chi tiết per-request mà metric đã bỏ lúc gộp.
traceparentmang ba giá trị qua network: phiên bản,trace ID32 hex,parent ID16 hex.- Ba tên cần nhớ đúng:
micrometer-tracing-bridge-otel(bridge),opentelemetry-exporter-otlp(exporter),management.otlp.tracing.endpoint(property). - Sampling mặc định 10% là head-based, mù trước lỗi; tail-based sampling ở collector giữ lại đúng trace có lỗi mà không lưu toàn bộ.
- Ba hình dạng trace: chuỗi tuần tự dài (song song hoá được), nhiều span ngắn lặp lại (N+1), khoảng trống giữa các span (thời gian chờ không được đo).
Tự kiểm tra
Q1Metric P99 của bốn service TaskFlow gọi ra đều xanh, nhưng P99 endpoint tổng vẫn xấu. Giải thích cơ chế vì sao điều này xảy ra được, và vì sao thêm metric chi tiết hơn (breakdown theo caller) cũng không giải quyết triệt để bằng trace.▸
Q2Vì sao trace ID cần đi qua một header HTTP tiêu chuẩn (traceparent) thay vì mỗi công ty tự định nghĩa header riêng của mình?▸
Q3Bạn dựng một service gọi ra ngoài bằng đoạn code RestClient.builder().baseUrl(url).build() thay vì inject RestClient.Builder. Request vẫn chạy đúng, response vẫn về đúng dữ liệu, nhưng khi tra trace lại thấy service này đứng tách rời, không nối vào trace của caller. Giải thích cơ chế gây ra hiện tượng này.▸
RestClient.builder().baseUrl(url).build() thay vì inject RestClient.Builder. Request vẫn chạy đúng, response vẫn về đúng dữ liệu, nhưng khi tra trace lại thấy service này đứng tách rời, không nối vào trace của caller. Giải thích cơ chế gây ra hiện tượng này.Q4Với management.tracing.sampling.probability đặt 0.1, giải thích trade-off mà quyết định này tạo ra, và vì sao tail-based sampling ở collector giải quyết được trade-off đó mà chỉnh riêng property này không giải quyết được.▸
Q5Nhìn vào một trace với hình dạng: bốn span con kích thước gần bằng nhau, mỗi span đại diện một query DB nhỏ, tất cả xếp liên tiếp nhau bên trong một vòng lặp gọi tới cùng một repository method. Đây là dấu hiệu của vấn đề gì, và cách đọc trace giúp chẩn đoán nhanh hơn việc chỉ nhìn log SQL ra sao?▸
Bài tiếp theo: Mini-challenge TaskFlow v5.
Bài này đáng gửi cho bạn học cùng?
Copy link đã gắn nguồn — dán group, chat, hoặc LinkedIn.
Bài này có giúp bạn hiểu bản chất không?
Hỏi đáp về bài này
Chưa có câu hỏi
Có gì chưa rõ trong bài? Đặt câu hỏi đầu tiên — câu trả lời từ cộng đồng giúp bạn (và người sau).
Đặt câu hỏi đầu tiên