Mục lục Series | Chương trước: Part 4: Đơn Giản Hóa CI/CD & Atomic Deployments → | Chương tiếp theo: Part 6: Lộ Trình Chuyển Đổi (Migration Playbook) →
📖 Xem bản tiếng Anh trên tanhdev.com: Modular Monolith Observability: Logging & Profiling
Answer-first: Observability trong Modular Monolith tận dụng khả năng lan truyền OpenTelemetry span trực tiếp trong bộ nhớ qua context.Context mà không chịu thuế mạng. Kết hợp theo dõi in-memory với structured logging và profiling pprof liên tục giúp cắt giảm chi phí telemetry tới 90% mà vẫn duy trì độ rõ nét giám sát cấp vi dịch vụ.
1. Nỗi Đau Của Giám Sát Phân Tán (Distributed Tracing) Trong Microservices
Khi vận hành một hệ thống sản xuất thực tế, khả năng quan sát (Observability) là yếu tố quyết định liệu một kỹ sư SRE có thể cô lập sự cố trong 5 phút hay phải thức trắng đêm để tìm hiểu nguyên nhân một lỗi phân tán bí ẩn. Trong Microservices, việc phân rã hệ thống thành hàng chục tiến trình mạng độc lập đã biến công tác giám sát thành một cơn ác mộng kỹ thuật tốn kém.
Trong kiến trúc phân tán, một yêu cầu từ khách hàng kích hoạt một chuỗi gọi mạng phức tạp:
- Tiêm tiêu đề mạng (Header Injection): Service A nhận API request, tạo mã định danh chuẩn W3C
traceparentdài 128-bit và khởi tạo một span cục bộ. - Vận chuyển qua mạng (Network Transport): Khi Service A gọi Service B qua HTTP hoặc gRPC, nó phải tuần tự hóa trace context thành chuỗi ký tự hexa đặt trong HTTP header.
- Giải mã và phục hồi: Service B đọc header, giải mã chuỗi và tạo một child span.
- Đẩy dữ liệu ngầm (Out-of-band Emission): Từng service liên tục gửi dữ liệu span qua UDP/HTTP tới các bộ gom OTel Collector hoặc daemon Datadog.
sequenceDiagram
autonumber
participant Gateway as "API Gateway Handler"
participant Orders as "internal/orders"
participant Billing as "internal/billing"
participant OTel as "Local In-Memory OTel Tracer"
Gateway->>Orders: Gọi CreateOrder(ctx)
Orders->>OTel: Khởi tạo Span "orders.CreateOrder" (< 1µs trong RAM)
Orders->>Billing: Gọi ProcessPayment(ctx) qua con trỏ bộ nhớ
Billing->>OTel: Khởi tạo Child Span "billing.ProcessPayment" (< 1µs)
Billing-->>Orders: Trả về kết quả thanh toán
Orders-->>Gateway: Phản hồi HTTP 200 OK
Cái Giá Khổng Lồ Của Telemetry Phân Tán
- Phạt CPU và Độ Trễ: Việc liên tục cấp phát chuỗi (string allocations), mã hóa/giải mã header và ghi dữ liệu qua socket mạng làm tăng từ 2ms đến 10ms độ trễ không cần thiết trên mỗi bước nhảy API.
- Vỡ Dấu Vết (Broken Traces): Nếu một proxy trung gian vô tình quên chuyển tiếp tiêu đề
traceparent, hoặc nếu một pod bị sập giữa chừng, chuỗi trace sẽ bị đứt đoạn, khiến việc truy vết trở nên vô dụng. - Hóa Đơn APM Bùng Nổ: Các nền tảng thương mại như Datadog hay Dynatrace tính tiền dựa trên số lượng span và độ phức tạp của nhãn (tag cardinality). Chi phí giám sát thường xuyên vượt quá chi phí máy chủ chạy ứng dụng!
2. Giám Sát Trong Bộ Nhớ (In-Process Context Propagation): Tốc Độ Tối Đa Với Chi Phí Bằng 0
Trong kiến trúc Modular Monolith, toàn bộ các module giao tiếp với nhau trong cùng một không gian bộ nhớ RAM của tiến trình Go. Việc lan truyền ngữ cảnh giám sát diễn ra chỉ đơn giản là truyền một con trỏ context.Context qua tham số hàm Go:
| Chiến Lược Lan Truyền Dấu Vết | Độ Trễ Trên Mỗi Bước Nhảy | Cấp Phát Bộ Nhớ (Heap Allocs) | Cơ Chế Vận Chuyển |
|---|---|---|---|
Modular Monolith (context.Context) | ~15 nanosecond | 0 allocs/op (Zero Alloc) | Thanh ghi CPU / Con trỏ RAM |
Microservices (W3C traceparent Header) | ~1.200 nanosecond | 12 – 18 allocs/op | Socket mạng TCP Wire qua HTTP/gRPC |
2.1 Khởi Tạo Trace Span Trực Tiếp Không Qua Mạng
Trong Go Modular Monolith, việc tạo span chỉ là một thao tác cập nhật cấu trúc struct trong RAM cục bộ:
// Khởi tạo span OpenTelemetry in-process siêu tốc không tốn chi phí mạng
ctx, span := otel.Tracer("internal/orders").Start(ctx, "CreateOrder")
defer span.End()
Thời gian khởi tạo span giảm từ hàng microsecond xuống mức dưới nanosecond, cho phép bạn gắn trace chi tiết vào từng hàm nghiệp vụ mà không sợ làm chậm hệ thống.
2.2 Stack Trace Đơn Nhất và Khả Năng Gỡ Lỗi Hoàn Hảo
Khi xảy ra một lỗi nghiêm trọng (panic) trong Microservices, stack trace bị chặn lại tại ranh giới mạng của container đó, buộc kỹ sư phải đối chiếu log giữa nhiều máy chủ khác nhau. Trong Modular Monolith, một lỗi panic duy nhất sẽ in ra toàn bộ cây phân cấp lệnh gọi từ tầng API xuống tận câu lệnh CSDL:
goroutine 42 [running]:
main.processOrder(0xc0000a2000)
/app/internal/orders/service.go:84 +0x1a4
main.deductStock(0xc0000a2000)
/app/internal/inventory/service.go:112 +0x24b
main.executeSQLTx(...)
/app/internal/storage/db.go:45 +0x88
Kỹ sư có thể nhìn thấy chính xác giá trị tham số, tên module và số dòng code gây lỗi trên cùng một màn hình điều khiển mà không cần truy vấn phân tán.
3. Lấy Mẫu Theo Vòng Đệm (Circular Ring Buffer Tail-Sampling)
Trong hệ thống phân tán, các microservice đẩy mọi span qua mạng, gây lãng phí băng thông và dung lượng lưu trữ khổng lồ. Modular Monolith cho phép áp dụng kỹ thuật Tail-based Sampling trực tiếp trong bộ nhớ thông qua hàng đợi vòng đệm (Circular Ring Buffer):
flowchart TD
A["Request tiếp nhận tại API Gateway"] --> B["Ghi nhận Spans vào Ring Buffer trong RAM"]
B --> C{"Request hoàn tất: Kết quả là gì?"}
C -->|"Mã lỗi 5xx hoặc P99 > 200ms"| D["FLUSH toàn bộ Buffer Spans ra OTel Collector<br/>(Lưu giữ 100% dữ liệu sự cố)"]
C -->|"HTTP 200 OK & Độ trễ thấp < 10ms"| E["DROP 99% chi tiết Spans nội bộ<br/>(Chỉ giữ lại bộ đếm Counter trong RAM)"]
D --> F["Tiết kiệm 85% chi phí lưu trữ Datadog/Jaeger!"]
E --> F
- Bộ đệm Span trong RAM: Khi request đi qua các module nội bộ (
orders->billing), các span được tạm giữ trong một slice buffer gắn vớitrace_id. - Động cơ quyết định tại điểm kết thúc: Khi request hoàn tất tại tầng HTTP handler:
- Nếu xảy ra lỗi HTTP
5xxhoặc độ trễ vượt ngưỡng P99 (ví dụ > 200ms), toàn bộ buffer được xả (flush) ngay lập tức tới OpenTelemetry Collector. - Nếu request thành công mỹ mãn trong 5ms, 99% chi tiết span được giải phóng khỏi RAM mà không tốn một xu chi phí lưu trữ APM.
- Nếu xảy ra lỗi HTTP
4. Tương Quan Log và Metric Chuẩn Go 1.25: slog & OpenTelemetry
Dưới đây là cài đặt sản xuất tích hợp giữa thư viện logging có cấu trúc chuẩn của Go (log/slog) và OpenTelemetry, tự động trích xuất trace_id và span_id từ context.Context để nhúng vào từng dòng JSON log:
package logger
import (
"context"
"log/slog"
"os"
"go.opentelemetry.io/otel/trace"
)
// TraceHandler là middleware slog tự động gắn trace_id và span_id vào bản ghi log
type TraceHandler struct {
slog.Handler
}
// NewTraceLogger khởi tạo logger định dạng JSON ghi ra chuẩn stdout
func NewTraceLogger() *slog.Logger {
jsonHandler := slog.NewJSONHandler(os.Stdout, &slog.HandlerOptions{
Level: slog.LevelInfo,
})
return slog.New(&TraceHandler{Handler: jsonHandler})
}
// Handle chặn dòng log và tiêm thông tin tracing nếu có trong context
func (h *TraceHandler) Handle(ctx context.Context, r slog.Record) error {
spanCtx := trace.SpanContextFromContext(ctx)
if spanCtx.IsValid() {
r.AddAttrs(
slog.String("trace_id", spanCtx.TraceID().String()),
slog.String("span_id", spanCtx.SpanID().String()),
)
}
return h.Handler.Handle(ctx, r)
}
4.1 Quản Lý Metric Theo Domain Bằng OpenTelemetry Meter
Định nghĩa các chỉ số đo lường nghiệp vụ được gắn thẻ nhãn module rõ ràng:
package metrics
import (
"context"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/metric"
)
type ModuleMetrics struct {
orderCounter metric.Int64Counter
}
func NewModuleMetrics() (*ModuleMetrics, error) {
meter := otel.GetMeterProvider().Meter("modular-monolith")
counter, err := meter.Int64Counter(
"domain_orders_processed_total",
metric.WithDescription("Tổng số lượng đơn hàng được xử lý theo domain"),
)
if err != nil {
return nil, err
}
return &ModuleMetrics{orderCounter: counter}, nil
}
func (m *ModuleMetrics) RecordOrder(ctx context.Context, moduleName string, status string) {
m.orderCounter.Add(ctx, 1, metric.WithAttributes(
attribute.String("module", moduleName),
attribute.String("status", status),
))
}
4.2 Lan Truyền Ngữ Cảnh OpenTelemetry Qua Worker Pool Bằng BoundedTracedEventBus
Trong kiến trúc Modular Monolith, khi các domain event di chuyển bất đồng bộ qua các Go channel giữa các Bounded Context (ví dụ từ module Order sang module Billing hay Inventory), việc lan truyền ngữ cảnh trace (Trace Context Propagation) là tối quan trọng để tránh tình trạng phân mảnh trace. Nếu không gắn kết trace ID của HTTP request gốc vào sự kiện, hệ thống tracing sẽ tạo ra một root trace mới tại worker xử lý nền, phá vỡ biểu đồ thác nước phân tán (distributed waterfall graph).
Do các module cùng chia sẻ chung một tiến trình OS, chúng ta không cần đến các serializer nặng nề như JSON hay Protocol Buffers qua mạng. Thay vào đó, chúng ta triển khai TraceMetadataCarrier kế thừa trực tiếp giao diện propagation.TextMapCarrier của OpenTelemetry SDK (Get, Set, Keys). Khi publisher gọi Publish(), propagator tiêu chuẩn W3C (otel.GetTextMapPropagator()) sẽ tiêm (inject) cặp khóa-giá trị chuẩn W3C traceparent vào metadata map in-memory.
Tại đầu nhận, worker pool có giới hạn (bounded worker pool) được định cỡ dựa trên số lõi CPU vật lý (runtime.GOMAXPROCS(0) * 2) sử dụng errgroup.Group để kiểm soát vòng đời tiến trình. Từng worker trích xuất (extract) trace context từ carrier để tái tạo SpanContext hợp lệ, sau đó mở một child span (tracer.Start(workerCtx, ...)) để ghi nhận thời gian thực thi của handler, bắt và ghi nhận lỗi (span.RecordError) mà không phát sinh hiện tượng nghẽn hay rò rỉ bộ nhớ.
Dưới đây là mã nguồn Go 1.25 hoàn chỉnh chuẩn production-grade:
package main
import (
"context"
"errors"
"fmt"
"runtime"
"time"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/propagation"
"go.opentelemetry.io/otel/trace"
"golang.org/x/sync/errgroup"
)
// TraceMetadataCarrier triển khai giao diện propagation.TextMapCarrier cho hàng đợi in-memory
type TraceMetadataCarrier map[string]string
var _ propagation.TextMapCarrier = TraceMetadataCarrier{}
func (c TraceMetadataCarrier) Get(key string) string { return c[key] }
func (c TraceMetadataCarrier) Set(key string, value string) { c[key] = value }
func (c TraceMetadataCarrier) Keys() []string {
keys := make([]string, 0, len(c))
for k := range c {
keys = append(keys, k)
}
return keys
}
// DomainEvent đóng gói dữ liệu nghiệp vụ kèm theo carrier chứa metadata W3C traceparent
type DomainEvent struct {
Name string
Timestamp time.Time
Payload any
Carrier TraceMetadataCarrier
}
// eventJob kết hợp context gọi, domain event và handler cần kích hoạt trong worker pool
type eventJob struct {
ctx context.Context
event DomainEvent
handler func(ctx context.Context, ev DomainEvent) error
}
// BoundedTracedEventBus điều hướng sự kiện an toàn qua worker pool với OpenTelemetry trace propagation
type BoundedTracedEventBus struct {
jobQueue chan eventJob
workers int
tracer trace.Tracer
eg *errgroup.Group
ctx context.Context
cancel context.CancelFunc
}
// NewBoundedTracedEventBus khởi tạo event bus với worker pool giới hạn dựa trên tài nguyên CPU
func NewBoundedTracedEventBus(parentCtx context.Context, queueCapacity int, tracer trace.Tracer) *BoundedTracedEventBus {
ctx, cancel := context.WithCancel(parentCtx)
eg, groupCtx := errgroup.WithContext(ctx)
// Giới hạn số lượng worker xử lý đồng thời theo số lõi CPU vật lý nhân 2
workers := runtime.GOMAXPROCS(0) * 2
if workers < 4 {
workers = 4
}
bus := &BoundedTracedEventBus{
jobQueue: make(chan eventJob, queueCapacity),
workers: workers,
tracer: tracer,
eg: eg,
ctx: groupCtx,
cancel: cancel,
}
for i := 0; i < bus.workers; i++ {
workerID := i
bus.eg.Go(func() error {
bus.workerLoop(groupCtx, workerID)
return nil
})
}
return bus
}
// workerLoop xử lý liên tục các job trong hàng đợi và trích xuất trace context tương ứng
func (b *BoundedTracedEventBus) workerLoop(ctx context.Context, workerID int) {
propagator := otel.GetTextMapPropagator()
for {
select {
case <-ctx.Done():
// Khi context kết thúc, thoát êm dịu không gây rò rỉ goroutine
return
case job, ok := <-b.jobQueue:
if !ok {
return
}
// Trích xuất trace context từ metadata carrier vào worker context
workerCtx := propagator.Extract(ctx, job.event.Carrier)
childCtx, span := b.tracer.Start(workerCtx, fmt.Sprintf("EventHandler:%s", job.event.Name))
span.AddEvent("Worker started executing handler")
err := job.handler(childCtx, job.event)
if err != nil {
span.RecordError(err)
}
span.End()
}
}
}
// Publish nạp sự kiện vào hàng đợi kèm theo trace context hiện tại của caller
func (b *BoundedTracedEventBus) Publish(ctx context.Context, eventName string, payload any, handler func(ctx context.Context, ev DomainEvent) error) error {
carrier := make(TraceMetadataCarrier)
otel.GetTextMapPropagator().Inject(ctx, carrier)
event := DomainEvent{
Name: eventName,
Timestamp: time.Now().UTC(),
Payload: payload,
Carrier: carrier,
}
job := eventJob{
ctx: ctx,
event: event,
handler: handler,
}
select {
case b.jobQueue <- job:
return nil
case <-ctx.Done():
return ctx.Err()
default:
return errors.New("hàng đợi event bus đã đầy: kích hoạt cơ chế backpressure")
}
}
// Shutdown đóng hàng đợi an toàn và đợi toàn bộ worker pool hoàn tất tác vụ
func (b *BoundedTracedEventBus) Shutdown(ctx context.Context) error {
close(b.jobQueue)
b.cancel()
return b.eg.Wait()
}
5. Cấu Hình Production OpenTelemetry Go SDK & Cảnh Báo Prometheus
Dưới đây là cấu hình hoàn chỉnh để khởi tạo OTel SDK chính thức xuất dữ liệu qua kết nối gRPC nền:
package telemetry
import (
"context"
"fmt"
"go.opentelemetry.io/otel"
"go.opentelemetry.io/otel/exporters/otlp/otlptrace/otlptracegrpc"
"go.opentelemetry.io/otel/sdk/resource"
sdktrace "go.opentelemetry.io/otel/sdk/trace"
semconv "go.opentelemetry.io/otel/semconv/v1.17.0"
"go.opentelemetry.io/otel/trace"
"google.golang.org/grpc/credentials"
)
type TracerConfig struct {
ServiceName string
CollectorURL string
TLSCreds credentials.TransportCredentials
}
func InitTracer(ctx context.Context, cfg TracerConfig) (*sdktrace.TracerProvider, error) {
opts := []otlptracegrpc.Option{
otlptracegrpc.WithEndpoint(cfg.CollectorURL),
}
if cfg.TLSCreds != nil {
opts = append(opts, otlptracegrpc.WithTLSCredentials(cfg.TLSCreds))
}
exporter, err := otlptracegrpc.New(ctx, opts...)
if err != nil {
return nil, fmt.Errorf("khởi tạo OTLP trace exporter thất bại: %w", err)
}
res, err := resource.New(ctx,
resource.WithAttributes(
semconv.ServiceNameKey.String(cfg.ServiceName),
),
)
if err != nil {
return nil, fmt.Errorf("khởi tạo resource metadata thất bại: %w", err)
}
tp := sdktrace.NewTracerProvider(
sdktrace.WithBatcher(exporter),
sdktrace.WithResource(res),
)
otel.SetTracerProvider(tp)
return tp, nil
}
func StartSpan(ctx context.Context, moduleName, operationName string) (context.Context, trace.Span) {
tr := otel.Tracer(moduleName)
return tr.Start(ctx, operationName)
}
5.1 Cấu Hình Quy Tắc Cảnh Báo Prometheus Chuẩn SRE
Tập tin quy tắc cảnh báo Prometheus tự động phát hiện module nào trong Monolith đang bị suy giảm hiệu năng:
groups:
- name: modular_monolith_alerts
rules:
- alert: ModularMonolithHighModuleLatency
expr: histogram_quantile(0.99, sum(rate(http_request_duration_seconds_bucket{job="modular-monolith"}[5m])) by (le, module)) > 0.35
for: 2m
labels:
severity: warning
annotations:
summary: "Phát hiện độ trễ cao tại module {{ $labels.module }}"
description: "Độ trễ P99 của module {{ $labels.module }} vượt quá 350ms liên tục trong hơn 2 phút."
- alert: ModularMonolithModuleErrorRate
expr: sum(rate(domain_orders_processed_total{status="error"}[5m])) by (module) / sum(rate(domain_orders_processed_total[5m])) by (module) > 0.05
for: 1m
labels:
severity: critical
annotations:
summary: "Tỷ lệ lỗi tăng cao bất thường tại module {{ $labels.module }}"
description: "Tỷ lệ lỗi của module {{ $labels.module }} vượt quá 5% trong 1 phút qua."
6. Continuous Profiling Với eBPF (Pyroscope / Parca)
Thay vì cài đặt các Java/Go agent nặng nề làm tốn 10% CPU, kỹ thuật giám sát năm 2026 tận dụng eBPF Continuous Profiling (thông qua Grafana Pyroscope hoặc Parca). eBPF lắng nghe các ngắt phần cứng của CPU tại nhân Linux Kernel, lập bản đồ Flame Graph chi tiết tới từng hàm Go mà mức tiêu thụ CPU chỉ dưới 1%. Bạn có thể thấy chính xác hàm nào trong module Billing đang chiếm dụng CPU hay cấp phát bộ nhớ heap đột biến theo thời gian thực.
7. Phân Tích pprof Differential Profiling & Giám Sát Truy Vấn PostgreSQL
Bên cạnh việc theo dõi thời gian thực qua OpenTelemetry, các kỹ sư hệ thống tải cao cần trang bị kỹ năng phân tích chuyên sâu bộ nhớ và CPU bằng công cụ gốc net/http/pprof của Go:
7.1 Kỹ Thuật Differential Profiling (So Sánh Sai Khác)
Khi hệ thống có dấu hiệu rò rỉ bộ nhớ (memory leak) sau một đợt phát hành, thay vì đoán mò, kỹ sư có thể lấy hai bản snapshot heap cách nhau 30 phút và thực thi lệnh so sánh sai khác (differential profiling):
# Lấy snapshot heap tại thời điểm T0
curl -s http://localhost:6060/debug/pprof/heap > base.heap
# Lấy snapshot heap tại thời điểm T1 (sau khi phục vụ 100.000 requests)
curl -s http://localhost:6060/debug/pprof/heap > current.heap
# So sánh sự gia tăng vùng nhớ heap bằng công cụ go tool pprof
go tool pprof -base base.heap current.heap
Bản đồ đồ thị (Flame Graph) sẽ làm nổi bật chính xác module nào (ví dụ: một map cache không giới hạn trong internal/inventory) đang tích lũy đối tượng mà không được garbage collection dọn dẹp.
7.2 Giám Sát Điểm Nghẽn CSDL Với pg_stat_statements
Trong Modular Monolith, vì các module dùng chung cụm PostgreSQL, việc một module thực thi câu truy vấn chậm có thể làm cạn kiệt connection pool của toàn bộ ứng dụng. Bằng cách kích hoạt extension pg_stat_statements:
-- Kích hoạt extension giám sát hiệu năng truy vấn trên PostgreSQL
CREATE EXTENSION IF NOT EXISTS pg_stat_statements;
-- Truy vấn top 5 câu lệnh tiêu tốn thời gian thực thi CPU lớn nhất
SELECT
query,
calls,
total_exec_time / 1000 AS total_sec,
mean_exec_time AS avg_ms,
rows
FROM pg_stat_statements
ORDER BY total_exec_time DESC
LIMIT 5;
Bảng thống kê này giúp kiến trúc sư phát hiện ngay các câu lệnh truy vấn N+1 hoặc thiếu index thuộc về schema nào để tiến hành bổ sung chỉ mục hoặc áp dụng kỹ thuật caching kịp thời.
❓ Câu Hỏi Thường Gặp (FAQ)
Tại sao việc tracing bằng OpenTelemetry trong Modular Monolith lại nhanh hơn vi dịch vụ?
Stack trace đơn nhất của Monolith giúp ích gì cho việc điều tra lỗi sản xuất?
Chiến lược lấy mẫu Tail-based Ring-Buffer hoạt động như thế nào?
Làm thế nào để gắn nhãn metric OpenTelemetry cho từng module trong Monolith?
🔗 Chuyên Đề Tiếp Theo
- Tiếp theo: Part 6: Lộ Trình Chuyển Đổi (Migration Playbook) – Hợp Nhất Microservices — Áp dụng Reverse Strangler Fig và Dual-Write.
- Xem lại: Part 4: Đơn Giản Hóa CI/CD & Atomic Deployments — Tối ưu hóa kiểm thử có chọn lọc.
- Hệ thống tải cao: Chiến Lược Giới Hạn Tốc Độ Phân Tán với Redis & GCRA — Bảo vệ hệ thống trước lưu lượng đột biến.
