Cấu trúc dữ liệu và Giải thuật Bài 100: Distributed Tracing (OpenTelemetry/Jaeger) - Kỹ thuật truy vết Request đi xuyên qua hàng chục Microservices.
Khi hệ thống của Hasaki hoặc trạm điều hành Metro chuyển sang kiến trúc Microservices, một vấn đề cực kỳ đau đầu sẽ xuất hiện: Nỗi ám ảnh mang tên "Lỗi ở đâu?"
Giả sử khách hàng phàn nàn: "Tôi bấm nút Thanh toán, hệ thống quay đều 5 giây rồi báo lỗi". Trong kiến trúc nguyên khối (Monolith) cũ, bạn chỉ cần mở 1 file log duy nhất ra xem là thấy ngay lỗi ở dòng code nào. Nhưng trong Microservices, một cú click chuột của người dùng có thể kích hoạt API Gateway, gọi sang Order Service, Order gọi Inventory, rồi gọi Payment, Payment lại gọi API của ngân hàng.
Bạn có 5 file log nằm rải rác trên 5 máy chủ khác nhau. Làm sao để biết chính xác đoạn log nào thuộc về cú click chuột của vị khách hàng xui xẻo kia? Và trong 5 giây chờ đợi đó, thời gian bị "nghẽn" nằm ở database của Order hay do mạng kết nối sang Payment?
Để phá giải màn sương mù này, chúng ta cần Distributed Tracing (Truy vết phân tán).
1. Giải phẫu một Dấu vết (Trace)
Distributed Tracing hoạt động dựa trên triết lý rất đơn giản: Dán cho mỗi Request một "Cuốn hộ chiếu", và đóng dấu mỗi khi nó đi qua một "Trạm kiểm soát".
Cấu trúc cốt lõi bao gồm hai khái niệm:
-
Trace ID (Mã dấu vết): Một mã định danh duy nhất toàn cầu (UUID) được tạo ra một lần duy nhất ngay tại API Gateway khi Request vừa bước vào hệ thống. Mã này sẽ đi theo Request đến tận cùng. Tất cả các file log của mọi Service đều phải in cái Trace ID này ra.
-
Span ID (Mã chặng): Mỗi khi Request thực hiện một tác vụ cụ thể (như gọi HTTP sang service khác, hoặc thực thi một câu query SQL), hệ thống tạo ra một Span. Mỗi Span có 1 ID riêng, có thời gian Bắt đầu (Start Time) và Kết thúc (End Time).
-
Parent Span ID: Nếu Service A gọi Service B, Span của B sẽ lưu ID của A làm Parent. Nhờ đó, hệ thống vẽ ra được một cái cây gia phả (Tree) của toàn bộ luồng gọi.
2. Trực quan hóa giới hạn hiệu năng với Jaeger
Dữ liệu Trace ID và Span sinh ra hàng nghìn dòng log khó đọc. OpenTelemetry là tiêu chuẩn để thu thập các số liệu này, còn Jaeger (được tạo ra bởi Uber) là công cụ UI xuất sắc nhất để trực quan hóa chúng thành sơ đồ thác nước (Waterfall).

Nhìn vào sơ đồ thác nước trên, bạn lập tức thấy bức tranh toàn cảnh:
-
Trục ngang là thời gian (). Trục dọc là các Services.
-
Thanh màu ngang càng dài, tác vụ đó càng chậm (Longest Span).
-
Nếu tổng Request mất 2 giây, nhưng bạn nhìn thấy thanh ngang của câu lệnh
SELECT * FROM inventorychiếm mất 1.8 giây, bạn ngay lập tức biết phải tối ưu Database thay vì đi đổ lỗi cho network.
3. Context Propagation (Truyền ngữ cảnh qua mạng)
Làm thế nào để Order Service truyền được cái Trace ID sang Payment Service? Đáp án nằm ở HTTP Headers.
Chuẩn W3C Trace Context định nghĩa một Header tên là traceparent. Khi Order Service gửi HTTP Request sang Payment, nó tự động nhúng header này vào:
HTTP
POST /api/v1/payment/charge HTTP/1.1
Host: payment-service
traceparent: 00-4bf92f3577b34da6a3ce929d0e0e4736-00f067aa0ba902b7-01
Chuỗi traceparent mã hóa 4 thông tin:
-
00: Phiên bản (Version) -
4bf92f...: Trace ID (Để Payment biết nó đang nằm trong Trace nào) -
00f067...: Parent Span ID (ID của Span Order Service vừa gọi) -
01: Trace Flags (Có nên record lại Trace này không - Sampling).
Payment Service nhận Header này, đọc Trace ID và tiếp tục viết log dưới cùng một "Hộ chiếu".
4. Đặc sản của Golang: context.Context
Trong các ngôn ngữ như Java hoặc PHP, thư viện Tracing thường dùng "Thread-local storage" để lén lút truyền Trace ID phía sau hậu trường. Nhưng ở Bài 93, ta đã biết Golang sử dụng Goroutines, có thể nhảy lung tung giữa các Thread. Do đó, Golang yêu cầu lập trình viên phải truyền Context một cách rõ ràng (Explicit).
Đây là lý do mọi hàm trong Golang chuẩn đều nhận tham số đầu tiên là ctx context.Context. Nếu bạn làm mất ctx ở giữa đường, chuỗi Trace ID sẽ bị đứt đoạn (Broken Trace)!
Go
package main
import (
"context"
"fmt"
"time"
"go.opentelemetry.io/otel"
)
// Khởi tạo Tracer toàn cục
var tracer = otel.Tracer("order-service")
func ProcessOrder(ctx context.Context, orderID string) {
// Bắt đầu một Span mới từ Context được truyền vào
ctx, span := tracer.Start(ctx, "ProcessOrderLogic")
defer span.End() // Tự động ghi nhận End Time khi hàm kết thúc
fmt.Printf("Đang xử lý đơn hàng: %s\n", orderID)
// CỰC KỲ QUAN TRỌNG: Truyền ctx xuống hàm con
CheckInventory(ctx, "ITEM-100")
CallPaymentAPI(ctx, 500000)
}
func CheckInventory(ctx context.Context, itemID string) {
// Span này sẽ tự động nhận Span "ProcessOrderLogic" làm Parent
_, span := tracer.Start(ctx, "CheckInventory_DB_Query")
defer span.End()
// Giả lập Query DB mất 50ms
time.Sleep(50 * time.Millisecond)
}
func CallPaymentAPI(ctx context.Context, amount int) {
ctx, span := tracer.Start(ctx, "CallPayment_HTTP")
defer span.End()
// Tại đây, HTTP Client của Golang sẽ tự động đọc TraceID từ ctx
// và gắn vào W3C HTTP Headers trước khi bắn sang Payment Service!
time.Sleep(150 * time.Millisecond)
}
Lời kết cho Hành trình 100 Bài học Bài 100 đã khép lại chuỗi kiến thức đồ sộ từ nền tảng đến chuyên sâu. Chúng ta đã bắt đầu từ những mảng bộ nhớ liền kề (Array), đi qua các danh sách liên kết (Linked List), cây nhị phân tìm kiếm (BST, Red-Black Tree), chinh phục ma trận đồ thị (Dijkstra, Max Flow). Chúng ta đã tối ưu bộ nhớ RAM, phân mảnh ổ cứng SSD bằng B+ Tree và LSM Tree. Cuối cùng, chúng ta đưa những kiến trúc đó lên hệ thống phân tán khổng lồ (Raft, CQRS, Saga) và giăng lưới theo dõi chúng bằng OpenTelemetry.
Bất kể công nghệ có thay đổi nhanh đến đâu — dù là Laravel, Golang, Docker, hay Kubernetes — thì phần lõi bên dưới vận hành chúng vẫn luôn là những cấu trúc dữ liệu và giải thuật mà chúng ta đã rèn luyện. Khi đã nắm vững nền tảng, mọi công cụ mới chỉ là lớp vỏ ngôn ngữ.
All Rights Reserved