Spring Production-Ready/Trace một request xuyên nhiều service
16/26
Bài 16 / 26~13 phútObservability & Production-readyMiễn phí lượt xem

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@HttpExchangebài RestClientbà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

Thử tự liệt kê trước

Để 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 Observation sang 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 traceIdspanId — 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 (CompletableFuture hoặ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, 1450

Bố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 (inventorypricing, pricingpayment, paymentnotification), 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 Observation sinh 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ơi traceparent đượ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.
  • traceparent mang ba giá trị qua network: phiên bản, trace ID 32 hex, parent ID 16 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

Tự kiểm tra
Q1
Metric 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.
P99 của một 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 lần chậm đó chỉ là một điểm dữ liệu trong hàng nghìn request khác nên không đủ kéo P99 tổng lệch. Breakdown theo caller vẫn là gộp theo một chiều, chỉ hẹp hơn — vẫn trộn nhiều request vào một con số. Trace không gộp gì cả: nó giữ nguyên chi tiết từng span của đúng request đang điều tra, trả lời trực tiếp câu hỏi per-request mà mọi phép gộp đều làm mất.
Q2
Vì 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?
Mỗi service trong hệ thống phân tán là một process riêng, có thể viết bằng ngôn ngữ khác, deploy bởi team khác, và request đi qua nhiều thành phần trung gian như API gateway hay load balancer. Nếu mỗi vendor tự định nghĩa tên và định dạng header, request qua service dùng vendor A rồi tới service dùng vendor B sẽ không nối được thành một trace — B không biết đọc header của A. Thành phần trung gian cũng không biết header nào cần giữ lại khi không có chuẩn chung, có thể âm thầm loại bỏ nó. W3C Trace Context giải quyết đúng bài toán đó: mọi vendor, mọi ngôn ngữ đọc và ghi cùng một định dạng, nên trace nối liền xuyên toàn hệ thống.
Q3
Bạ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.
Auto-config chỉ đăng ký observation convention (gồm việc tự chèn traceparent) lên đúng builder chính nó tạo ra và expose như bean. Gọi RestClient.builder() trực tiếp tạo một builder hoàn toàn mới, không qua auto-config, nên không interceptor nào chèn traceparent vào request. HTTP call vẫn thành công bình thường — không exception, không cảnh báo. Nhưng vì header trace context không gửi kèm, service nhận không biết mình thuộc trace nào, tự sinh trace ID mới cho riêng nó. Kết quả: hai đoạn lẽ ra là một trace liên tục bị tách thành hai trace độc lập.
Q4
Vớ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.
Với xác suất 0,1, chín trên mười request không được lưu trace — quyết định xảy ra ngay đầu request (head-based), trước khi biết request có lỗi hay chậm bất thường. Hệ quả: đúng những request giá trị nhất để điều tra có xác suất bị bỏ qua y hệt mọi request bình thường. Tăng property lên gần 1.0 để tránh bỏ sót thì lại đụng đúng vấn đề chi phí và băng thông. Tail-based sampling đổi thời điểm quyết định: 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 xét trace có lỗi hay chậm để giữ hay bỏ — quyết định sau khi biết kết quả, điều một con số probability cố định đặt ở đầu request không bao giờ làm được.
Q5
Nhì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?
Nhiều span ngắn giống hệt nhau lặp lại là dấu hiệu đặc trưng của N+1: một vòng lặp đang gọi lại đúng một kiểu truy vấn cho từng phần tử, thay vì gộp thành một lời gọi duy nhất — cùng bản chất N+1 query đã học ở spring-rest-data, chỉ khác công cụ quan sát. Đọc trace nhanh hơn đọc log SQL ở chỗ: trace hiển thị trực quan số span lặp lại và tổng thời gian cộng dồn ngay trên một hình, còn log SQL phải tự đếm và tự cộng thủ công. Trace cũng cho thấy ngay các span đó thuộc đúng request nào qua span cha, trong khi log SQL trộn lẫn nhiều request nếu không lọc theo traceId trước.

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

Đặt 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

Bài tiếp theo

Mini-challenge: TaskFlow v5 — gắn máy đo