📖 Bản tiếng Anh (English Edition)

Bài trước: Bài 4 — Mở Rộng Cơ Sở Dữ Liệu: Từ Sharded MySQL Sang TiDB NewSQL | Mục lục Series


Answer-first: Shopee vượt qua thách thức giám sát bằng cách thay thế Elasticsearch bằng đường ống thu thập Vector SIMD, Kafka và lưu trữ cột ClickHouse. Việc kết hợp lấy mẫu OpenTelemetry tail-based với eBPF profiling giúp giảm 12 lần chi phí lưu trữ, giữ trọn vẹn mọi lỗi hệ thống với chi phí CPU dưới một phần trăm.


Điều kiện tiên quyết: Nắm vững mô hình dữ liệu quan sát (metrics, logs, traces), cơ chế lưu trữ chỉ mục dạng cột (ClickHouse MergeTree), giao thức lan truyền ngữ cảnh vết OpenTelemetry (W3C) và kỹ thuật phân tích hiệu năng nhân Linux với eBPF.


1. Bài Toán Giám Sát Cực Đại Tại Quy Mô Siêu Lớn

Vận hành hàng ngàn microservices phân tán phục vụ hàng trăm triệu đơn hàng mỗi ngày trên khắp Đông Nam Á đặt ra thách thức giám sát kỹ thuật khổng lồ. Trong các ngày hội mua sắm cao điểm như 11.11, hạ tầng của Shopee phát sinh hơn 100 tỷ bản ghi nhật ký (log records), 500 triệu dấu vết phân tán (trace spans) và 80 triệu chuỗi số liệu thời gian (metric streams) mỗi ngày.

Ở quy mô này, các kiến trúc giám sát truyền thống được xây dựng trên bộ công cụ ELK (Elasticsearch, Logstash, Kibana) chắc chắn sẽ sụp đổ hoàn toàn về cả mặt chi phí hạ tầng lẫn hiệu năng xử lý:

               Chi Phí & Dung Lượng Lưu Trữ Ổ Cứng (SSD NVMe)
                                ▲
                               / \
                              /   \
                             /     \
  Độ Toàn Vẹn Dữ Liệu ◄───────► Độ Trễ Phản Hồi Truy Vấn
  (Lưu 100% Logs & Traces)       (Truy Vấn Sự Cố Dưới 1 Giây)

Ba Hạn Chế Cốt Tử Của Ngăn Xếp ELK Truyền Thống

  1. Bùng Nổ Dung Lượng Chỉ Mục Đảo (Inverted Index Bloat): Elasticsearch bắt buộc phải tạo chỉ mục đảo Lucene cho từng token văn bản trên tất cả các trường dữ liệu JSON. Trong các chiến dịch khuyến mãi lớn, dung lượng chỉ mục này phình to gấp 1,5 lần dung lượng dữ liệu thô ban đầu. Việc lưu trữ 500 Terabyte log mỗi ngày đòi hỏi hàng ngàn ổ cứng NVMe cao cấp, tiêu tốn hàng triệu USD chi phí hạ tầng máy chủ hàng năm.
  2. Áp Lực Bộ Nhớ Heap JVM Và Ngừng Hoạt Động Do Gom Rác (GC Pauses): Cả Logstash và Elasticsearch đều phụ thuộc hoàn toàn vào máy ảo Java (JVM). Dưới áp lực tiếp nhận hơn 2 triệu sự kiện mỗi giây, chu kỳ gom rác của Java thường xuyên kích hoạt các đợt đóng băng toàn hệ thống (Stop-The-World) kéo dài từ 15 đến 30 giây. Hậu quả là các node bị rớt khỏi cụm Elasticsearch, kích hoạt bão tái phân bổ phân mảnh (shard reallocation) làm tê liệt hoàn toàn hệ thống cảnh báo sự cố.
  3. Nghịch Lý Của Cơ Chế Lấy Mẫu Ban Đầu (Head-Based Sampling): Để cắt giảm chi phí lưu vết (tracing), các giải pháp APM truyền thống sử dụng kỹ thuật lấy mẫu tại đầu nguồn — quyết định giữ lại hay loại bỏ trace ngay tại API Gateway trước khi request bắt đầu chạy. Trong một sàn thương mại điện tử nơi 99,9% giao dịch thành công êm đẹp trong 15 mili-giây, việc lấy mẫu ngẫu nhiên 1% tại đầu nguồn sẽ thu về hàng ngàn vết bình thường vô giá trị nhưng lại bỏ sót 99% các trường hợp trễ đuôi p99.9, nghẽn khóa cơ sở dữ liệu và đứt đoạn kết nối cổng thanh toán.

Để vượt qua các giới hạn vật lý này, Shopee đã thiết kế một nền tảng giám sát đa tầng thế hệ mới kết hợp agent Vector viết bằng Rust, hồ chứa đệm Apache Kafka, kho lưu trữ theo cột ClickHouse, cơ chế lấy mẫu vết theo đuôi OpenTelemetry và công nghệ phân tích hiệu năng liên tục không can thiệp bằng eBPF.


2. Kiến Trúc Đường Ống Giám Sát Telemetry Toàn Diện

Hệ thống quan sát hợp nhất của Shopee phân tách độc lập các tầng thu thập dữ liệu, đệm luồng, lưu trữ dạng cột và trực quan hóa phân tích thành các khối chịu tải cao riêng biệt:

flowchart TD
    subgraph MayChuVatLy ["Cụm Máy Chủ Kubernetes Bare-Metal (5.000+ Node)"]
        AppPod["Pod Microservice (Go Kitex / Ghi stdout)"] --> NodeLog["Socket Log Container (/var/log/pods)"]
        NodeLog --> VectorAgent["Vector DaemonSet (Ngôn Ngữ Rust / Phân Tích SIMD)"]
        eBPFAgent["Agent eBPF (Beyla / Coroot Đo Lường Trong Nhân Kernel)"]
    end

    subgraph HoDemLuong ["Hồ Chứa Đệm Giảm Áp Lực Apache Kafka"]
        VectorAgent -->|"Gom Mẻ Đẩy Vào (Nén Snappy)"| KafkaLogs["Topic: 'telemetry.logs.raw' (128 Partition)"]
        eBPFAgent -->|Luồng Số Liệu Bất Đồng Bộ| KafkaMetrics["Topic: 'telemetry.metrics.raw'"]
        OTelApp["OTel Go SDK (Truyền Ngữ Cảnh Vết W3C)|gRPC OTLP (Cửa Sổ 30s)"] --> OTelCol["Nhóm OTel Collector (Bộ Lấy Mẫu Theo Đuôi)"]
        OTelCol -->|"Bảo Lưu 100% Lỗi & Trễ p99"| KafkaTraces["Topic: 'telemetry.traces.sampled'"]
    end

    subgraph KhoLuuTruCot ["Kho Lưu Trữ Dạng Cột Phân Tán (Cụm ClickHouse)"]
        KafkaLogs --> GoIngest["Động Cơ Nạp Mẻ Golang (clickhouse-go/v2)"]
        KafkaTraces --> GoIngest
        GoIngest -->|"Ghi Khối Lớn: 100k Dòng / 2 Giây"| CH1["ClickHouse Shard 1 (MergeTree / Nén ZSTD)"]
        GoIngest -->|"Ghi Khối Lớn: 100k Dòng / 2 Giây"| CH2["ClickHouse Shard 2 (MergeTree / Nén ZSTD)"]
        GoIngest -->|"Ghi Khối Lớn: 100k Dòng / 2 Giây"| CHN["ClickHouse Shard N (ReplicatedMergeTree)"]
    end

    subgraph GiaoDienTrucQuan ["Giao Diện Trực Quan & Cảnh Báo"]
        CH1 & CH2 & CHN --> Grafana["Dashboard Grafana (Truy Vấn Phân Tích Dưới 500ms)"]
        CH1 & CH2 & CHN --> SREAlert["Alertmanager / Phát Hiện Bất Thường Thời Gian Thực"]
    end

    classDef node fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef kafka fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
    classDef ch fill:#e8f5e9,stroke:#2e7d32,stroke-width:2px;
    classDef ui fill:#f3e5f5,stroke:#7b1fa2,stroke-width:2px;
    class MayChuVatLy node;
    class HoDemLuong kafka;
    class KhoLuuTruCot ch;
    class GiaoDienTrucQuan ui;

Các Thành Phần Kiến Trúc Cốt Lõi

  1. Vector DaemonSet Thu Thập Log: Được viết bằng Rust, Vector chạy dưới dạng Kubernetes DaemonSet trên từng máy chủ vật lý. Vector đọc trực tiếp các tệp log container từ thư mục /var/log/pods, loại bỏ hoàn toàn chi phí truyền qua mạng nội bộ. Nhờ tận dụng tập lệnh vector hóa SIMD (simd-json), một agent Vector có thể phân tích cú pháp hơn 150.000 log JSON mỗi giây trong khi chỉ ngốn chưa đầy 1,5% CPU và 45MB RAM của máy chủ.
  2. Hệ Thống Thu Thập OpenTelemetry Collector (Lấy Mẫu Theo Đuôi): Các ứng dụng Go được nhúng SDK OpenTelemetry phát các span vết phân tán về cụm OTel Collector qua giao thức gRPC OTLP. Thay vì quyết định sớm, collector giữ toàn bộ các span của một trace trong bộ đệm vòng tròn (ring buffer) trong vòng 30 giây, đánh giá toàn bộ vòng đời của trace trước khi quyết định lưu hay hủy.
  3. Cơ Sở Dữ Liệu Dạng Cột ClickHouse: ClickHouse thay thế hoàn toàn vai trò của Elasticsearch. Nhờ tổ chức dữ liệu theo từng cột nhị phân riêng biệt nén bằng thuật toán ZSTD và LZ4, ClickHouse đạt tỷ lệ nén lên tới 12:1 so với văn bản JSON gốc. Động cơ thực thi truy vấn vector hóa quét qua hàng tỷ dòng mỗi giây trên các vi xử lý đa nhân, phản hồi các báo cáo phân tích phức tạp trong chưa đầy 500 mili-giây.

3. Cấu Trúc Bảng ClickHouse Lưu Trữ Telemetry Tốc Độ Cao

Dưới đây là cấu trúc bảng DDL chuẩn doanh nghiệp được triển khai trên các cụm ClickHouse của Shopee. Bảng sử dụng động cơ ReplicatedMergeTree, phân vùng theo ngày, chỉ mục thưa (sparse index) và các bộ nén chuyên biệt cho từng kiểu dữ liệu.

-- DDL Chuẩn Doanh Nghiệp Cho Bảng Lưu Trữ Log Phân Tán Trong ClickHouse
CREATE DATABASE IF NOT EXISTS shopee_telemetry ON CLUSTER ch_cluster;

CREATE TABLE shopee_telemetry.application_logs_local ON CLUSTER ch_cluster
(
    timestamp        DateTime64(6, 'UTC') CODEC(DoubleDelta, ZSTD(1)),
    service_name     LowCardinality(String) CODEC(ZSTD(1)),
    environment      LowCardinality(String) CODEC(ZSTD(1)),
    pod_name         LowCardinality(String) CODEC(ZSTD(1)),
    log_level        LowCardinality(String) CODEC(ZSTD(1)),
    trace_id         String CODEC(ZSTD(3)),
    span_id          String CODEC(ZSTD(3)),
    user_id          Int64 CODEC(DoubleDelta, ZSTD(1)),
    sku_id           Int64 CODEC(DoubleDelta, ZSTD(1)),
    duration_ms      Float32 CODEC(Gorilla, ZSTD(1)),
    message          String CODEC(ZSTD(6)),
    attributes       Map(String, String) CODEC(ZSTD(3)),
    INDEX idx_trace_id trace_id TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_user_id  user_id  TYPE minmax GRANULARITY 4
)
ENGINE = ReplicatedMergeTree('/clickhouse/tables/{shard}/application_logs', '{replica}')
PARTITION BY toYYYYMMDD(timestamp)
ORDER BY (service_name, log_level, toUnixTimestamp(timestamp), user_id)
TTL toDateTime(timestamp) + INTERVAL 30 DAY DELETE
SETTINGS
    index_granularity = 8192,
    min_bytes_for_wide_part = 10485760,
    parts_to_throw_insert = 300;

-- Bảng Distributed Để Truy Vấn Toàn Cụm Qua Một Đầu Mối
CREATE TABLE shopee_telemetry.application_logs ON CLUSTER ch_cluster AS shopee_telemetry.application_logs_local
ENGINE = Distributed(ch_cluster, shopee_telemetry, application_logs_local, rand());

Các Kỹ Thuật Tối Ưu Hóa Lưu Trữ Trong ClickHouse

  • LowCardinality(String): Ánh xạ các chuỗi văn bản có tính lặp lại cao (như tên dịch vụ service_name, cấp độ log log_level, môi trường environment) thành các từ điển số nguyên 8-bit hoặc 16-bit trong RAM, giúp tăng tốc độ so khớp chuỗi lên gấp 10 lần và tiết kiệm 80% bộ nhớ.
  • Chuẩn Nén DoubleDelta Và Gorilla: Nén các chuỗi nhãn thời gian tăng dần và số thực độ trễ xuống chỉ còn 1 đến 2 bit cho mỗi giá trị, tiết kiệm 90% dung lượng ổ cứng so với lưu trữ 64-bit thô.
  • Chỉ Mục Bloom Filter: Chỉ mục lọc Bloom filter trên trường trace_id cho phép ClickHouse bỏ qua 99% các khối dữ liệu trên đĩa trong quá trình tra cứu, tìm thấy chính xác một trace trong số 100 tỷ dòng chỉ trong vòng chưa đầy 200 mili-giây.

4. Cài Đặt Thực Tế Động Cơ Nạp Mẻ Bằng Golang (clickhouse-go/v2)

ClickHouse là cơ sở dữ liệu phân tích được tối ưu hóa cho các thao tác chèn dữ liệu theo khối lớn (bulk batch insert). Việc chèn từng dòng đơn lẻ (mỗi log gọi một lệnh INSERT) sẽ gây ra lỗi nghiêm trọng Too many parts in all data parts in table, làm sập hoàn toàn cụm cơ sở dữ liệu.

Đoạn mã Golang dưới đây là dịch vụ nạp dữ liệu mẻ chuẩn sản xuất sử dụng thư viện clickhouse-go/v2. Dịch vụ này gom dữ liệu lấy từ Kafka, quản lý worker đồng thời, thực thi chèn khối nguyên tử hàng ngàn dòng và tự động xả đệm theo chu kỳ thời gian:

package ingest

import (
	"context"
	"crypto/tls"
	"database/sql"
	"fmt"
	"log"
	"sync"
	"sync/atomic"
	"time"

	"github.com/ClickHouse/clickhouse-go/v2"
	"github.com/ClickHouse/clickhouse-go/v2/lib/driver"
)

// TelemetryRecord đại diện cho bản ghi log định kiểu chặt chẽ cần ghi nhận.
type TelemetryRecord struct {
	Timestamp   time.Time
	ServiceName string
	Environment string
	PodName     string
	LogLevel    string
	TraceID     string
	SpanID      string
	UserID      int64
	SKUID       int64
	DurationMS  float32
	Message     string
	Attributes  map[string]string
}

// ClickHouseBatchIngester quản lý bộ đệm có giới hạn và chu kỳ xả dữ liệu.
type ClickHouseBatchIngester struct {
	conn         driver.Conn
	batchQueue   chan *TelemetryRecord
	batchSize    int
	flushTimeout time.Duration
	workerCount  int
	wg           sync.WaitGroup
	activeRows   int64
	insertedRows int64
	droppedRows  int64
}

func NewClickHouseBatchIngester(addr string, dbName, user, password string, batchSize, queueCap, workers int, flushTimeout time.Duration) (*ClickHouseBatchIngester, error) {
	conn, err := clickhouse.Open(&clickhouse.Options{
		Addr: []string{addr},
		Auth: clickhouse.Auth{
			Database: dbName,
			Username: user,
			Password: password,
		},
		Settings: clickhouse.Settings{
			"max_execution_time": 60,
		},
		DialTimeout:     5 * time.Second,
		MaxOpenConns:    workers * 2,
		MaxIdleConns:    workers,
		ConnMaxLifetime: 1 * time.Hour,
		TLS:             nil, // Cấu hình &tls.Config{} trong môi trường production mTLS
	})
	if err != nil {
		return nil, fmt.Errorf("khong the ket noi toi clickhouse: %w", err)
	}

	ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
	defer cancel()

	if err := conn.Ping(ctx); err != nil {
		return nil, fmt.Errorf("ping clickhouse that bai: %w", err)
	}

	ingester := &ClickHouseBatchIngester{
		conn:         conn,
		batchQueue:   make(chan *TelemetryRecord, queueCap),
		batchSize:    batchSize,
		flushTimeout: flushTimeout,
		workerCount:  workers,
	}

	return ingester, nil
}

// Start khởi chạy nhóm worker xử lý gom mẻ đồng thời.
func (ing *ClickHouseBatchIngester) Start(ctx context.Context) {
	for i := 0; i < ing.workerCount; i++ {
		ing.wg.Add(1)
		go ing.workerLoop(ctx, i)
	}
}

// Ingest đưa một bản ghi vào hàng đợi với cơ chế kiểm soát áp lực ngược.
func (ing *ClickHouseBatchIngester) Ingest(record *TelemetryRecord) bool {
	select {
	case ing.batchQueue <- record:
		atomic.AddInt64(&ing.activeRows, 1)
		return true
	default:
		atomic.AddInt64(&ing.droppedRows, 1)
		return false // Tự ngắt bỏ bớt dữ liệu khi bộ nhớ RAM bị quá tải
	}
}

func (ing *ClickHouseBatchIngester) workerLoop(ctx context.Context, workerID int) {
	defer ing.wg.Done()

	buffer := make([]*TelemetryRecord, 0, ing.batchSize)
	ticker := time.NewTicker(ing.flushTimeout)
	defer ticker.Stop()

	flush := func() {
		if len(buffer) == 0 {
			return
		}

		if err := ing.flushBatch(ctx, buffer); err != nil {
			log.Printf("[ERROR] Worker %d ghi batch that bai: %v", workerID, err)
			atomic.AddInt64(&ing.droppedRows, int64(len(buffer)))
		} else {
			atomic.AddInt64(&ing.insertedRows, int64(len(buffer)))
		}

		buffer = buffer[:0]
	}

	for {
		select {
		case record, ok := <-ing.batchQueue:
			if !ok {
				flush()
				return
			}
			buffer = append(buffer, record)
			if len(buffer) >= ing.batchSize {
				flush()
			}
		case <-ticker.C:
			flush()
		case <-ctx.Done():
			flush()
			return
		}
	}
}

func (ing *ClickHouseBatchIngester) flushBatch(ctx context.Context, batch []*TelemetryRecord) error {
	chBatch, err := ing.conn.PrepareBatch(ctx, `
		INSERT INTO shopee_telemetry.application_logs_local (
			timestamp, service_name, environment, pod_name, log_level,
			trace_id, span_id, user_id, sku_id, duration_ms, message, attributes
		)
	`)
	if err != nil {
		return fmt.Errorf("chuan bi batch clickhouse that bai: %w", err)
	}

	for _, rec := range batch {
		err := chBatch.Append(
			rec.Timestamp,
			rec.ServiceName,
			rec.Environment,
			rec.PodName,
			rec.LogLevel,
			rec.TraceID,
			rec.SpanID,
			rec.UserID,
			rec.SKUID,
			rec.DurationMS,
			rec.Message,
			rec.Attributes,
		)
		if err != nil {
			return fmt.Errorf("them dong vao batch that bai: %w", err)
		}
	}

	// Gửi một khối nhị phân gom duy nhất qua giao thức TCP Native
	return chBatch.Send()
}

// Close xả sạch dữ liệu tồn và ngắt kết nối an toàn.
func (ing *ClickHouseBatchIngester) Close() error {
	close(ing.batchQueue)
	ing.wg.Wait()
	return ing.conn.Close()
}

5. Kỹ Thuật Lấy Mẫu Vết Theo Đuôi OpenTelemetry (Tail-Based Sampling)

Mô hình lấy mẫu tại đầu nguồn (head-based sampling) truyền thống đưa ra quyết định giữ hay bỏ trace ngay khi request vừa chạm vào hệ thống. Nếu một request gặp sự cố nghẽn khóa cơ sở dữ liệu kéo dài 5 giây ở chặng thứ 10, nhưng trước đó đã bị đánh dấu ngẫu nhiên là “không lấy mẫu” tại API Gateway, toàn bộ thông tin dấu vết chẩn đoán quý giá sẽ bị biến mất vĩnh viễn.

Shopee giải quyết bài toán này bằng cách triển khai cụm OpenTelemetry Collector với cơ chế lấy mẫu theo đuôi (Tail-Based Sampling):

flowchart LR
    subgraph TiepNhanSpan ["Tiếp Nhận Luồng Span Đầu Vào"]
        direction TB
        App1["Dịch Vụ Đơn Hàng (Kitex)"] -->|gRPC OTLP| LB["Cân Bằng Tải OTel (Băm Theo TraceID)"]
        App2["Cổng Thanh Toán (Go)"] -->|gRPC OTLP| LB
        App3["Lưu Trữ Tồn Kho (TiKV)"] -->|gRPC OTLP| LB
    end

    subgraph DongCoDanhGia ["Động Cơ Đánh Giá Lấy Mẫu Theo Đuôi"]
        direction TB
        LB --> RingBuf["Bộ Đệm Vòng Tròn 30 Giây Trong RAM"]
        RingBuf --> Rule1{"Quy Tắc 1: Trace Có Chứa Mã Lỗi HTTP 5xx?"}
        Rule1 -->|Có| Keep1["Giữ Lại 100% (Ưu Tiên Lưu Trữ Tuyệt Đối)"]
        Rule1 -->|Không| Rule2{"Quy Tắc 2: Độ Trễ Có Vượt 250ms (Đỉnh Nhọn p99)?"}
        Rule2 -->|Có| Keep2["Giữ Lại 100% (Lưu Trữ Bất Thường Độ Trễ)"]
        Rule2 -->|Không| Rule3{"Quy Tắc 3: Lấy Mẫu Xác Suất Nền (Tỷ Lệ 1%)"}
        Rule3 -->|Được Chọn| Keep3["Giữ Lại 1% (Làm Số Liệu Thống Kê So Sánh)"]
        Rule3 -->|Loại Bỏ| Drop["Hủy Bỏ (Giải Phóng RAM Ngay)"]
    end

    classDef span fill:#e3f2fd,stroke:#1565c0,stroke-width:2px;
    classDef engine fill:#fff3e0,stroke:#ef6c00,stroke-width:2px;
    class TiepNhanSpan span;
    class DongCoDanhGia engine;

Cấu Hình OTel Collector Cho Lấy Mẫu Theo Đuôi

Dưới đây là tệp cấu hình YAML triển khai trên cụm OpenTelemetry Collector của Shopee:

# Cấu Hình OpenTelemetry Collector Cho Lấy Mẫu Theo Đuôi
receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  batch:
    send_batch_size: 8192
    timeout: 1s

  tail_sampling:
    decision_wait: 30s
    num_traces: 250000
    expected_new_traces_per_sec: 10000
    policies:
      # Quy tắc 1: Luôn luôn giữ lại 100% trace bị lỗi hoặc panic
      - name: giu-lai-loi
        type: status_code
        status_code: { status_codes: [ ERROR ] }

      # Quy tắc 2: Giữ lại toàn bộ các vết có độ trễ p99 bất thường
      - name: giu-lai-trace-cham
        type: latency
        latency: { threshold_ms: 250 }

      # Quy tắc 3: Lấy mẫu xác suất 1% cho các đơn hàng bình thường
      - name: lay-mau-xac-suat
        type: probabilistic
        probabilistic: { sampling_percentage: 1.0 }

exporters:
  clickhouse:
    endpoint: tcp://clickhouse-cluster.internal:9000?database=shopee_telemetry
    ttl: 720h

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [tail_sampling, batch]
      exporters: [clickhouse]

Nhờ trì hoãn quyết định lấy mẫu cho tới khi toàn bộ chuỗi trace đã kết thúc, Shopee bảo lưu 100% các ngoại lệ, lỗi deadlock cơ sở dữ liệu và các giao dịch chậm p99, trong khi hủy bỏ 99% các giao dịch bình thường không có vấn đề gì. Chiến lược này giúp cắt giảm tới 92% chi phí lưu trữ dấu vết mà vẫn nâng cao tối đa hiệu quả điều tra sự cố của các kỹ sư SRE.


6. Phân Tích Hiệu Năng Bằng eBPF: Giám Sát Không Suy Hao Nhân Linux

Các phương pháp phân tích hiệu năng truyền thống (như công cụ Go pprof lấy mẫu CPU hoặc Java JVMTI sửa đổi mã bytecode) đều gây ra suy hao hiệu năng đáng kể và đòi hỏi phải chỉnh sửa mã nguồn ứng dụng. Trong ngày hội siêu sale 11.11, việc bật pprof trên các pod thanh toán đang chịu tải cực cao có thể làm tăng thời gian gom rác GC và làm giảm từ 4% đến 8% thông lượng giao dịch.

Shopee triển khai công nghệ eBPF (Extended Berkeley Packet Filter) trên toàn bộ các máy chủ Kubernetes:

sequenceDiagram
    autonumber
    actor User as Giao Dịch Thanh Toán Của Khách Hàng
    participant UserSpace as Tiến Trình Dịch Vụ Go (Không Gian Người Dùng)
    participant Kernel as Nhân Hệ Điều Hành Linux (Máy Ảo eBPF)
    participant PerfMap as Vùng Nhớ Đệm eBPF Ring Buffer
    participant Profiler as Daemon Phân Tích Hiệu Năng eBPF

    User->>UserSpace: Gửi Request Đặt Hàng Qua Mạng
    UserSpace->>Kernel: Gọi System Call write() / epoll_wait()
    Note over Kernel: Điểm Móc eBPF Tracepoint (sys_enter_write)
    Kernel->>Kernel: Lấy Mẫu Con Trỏ Lệnh (RIP) & Khung Ngăn Xếp Hàm
    Kernel->>PerfMap: Ghi Khối Stack Trace Vào Kernel Ring Buffer
    Note over PerfMap: Bộ Nhớ Vòng Tròn Zero-Copy Không Cần Khóa
    Profiler->>PerfMap: Đọc Dữ Liệu FlameGraph Đã Được Nén Gọn
    Profiler->>ClickHouse: Đẩy Hồ Sơ Ngăn Xếp Hàm Về ClickHouse Mỗi 60 Giây

Ưu Điểm Đột Phá Của eBPF Trên Môi Trường Production

  • Tuyệt Đối Không Sửa Đổi Mã Nguồn: Chương trình eBPF được nạp thẳng vào các điểm móc kprobe, uretprobe và tracepoint của nhân Linux. Mã nguồn microservice hoàn toàn không cần nhúng thư viện phụ trợ, không cần sidecar và không cần khởi động lại container.
  • Suy Hao Hiệu Năng Dưới 0,5%: Việc lấy mẫu diễn ra ở tần số định sẵn (ví dụ 99Hz hoặc 49Hz) trực tiếp bên trong máy ảo eBPF của nhân Linux. Dữ liệu ngăn xếp hàm được tổng hợp trong các bảng băm nội bộ (BPF_MAP_TYPE_STACK_TRACE) trước khi chuyển lên không gian người dùng, giữ mức chiếm dụng CPU tổng thể dưới 0,5%.
  • Khả Năng Quan Sát Toàn Diện Xuyên Suốt Tầng Nhân: Khác với các profiler người dùng chỉ thấy các hàm ứng dụng, eBPF liên kết chặt chẽ mã ứng dụng với độ trễ điều phối luồng của nhân Linux, các đợt truyền lại gói tin TCP socket, hiện tượng đóng băng ghi đệm trang Page Cache và các đợt tranh chấp khóa trong tầng hệ thống tệp VFS.

7. Ma Trận Đánh Đổi Kỹ Thuật Và Các Giải Pháp Bị Bác Bỏ

Cân bằng bài toán giám sát quy mô hàng Petabyte đòi hỏi các quyết định đánh đổi dứt khoát:

Quyết Định Kiến TrúcPhương Án Bị Bác BỏLý Do Đánh Đổi & Căn Cứ Thực Tế
Lưu trữ cột ClickHouseDùng chỉ mục đảo ElasticsearchChỉ mục đảo làm phình dữ liệu gấp 1,5 lần và gây đóng băng JVM nghiêm trọng dưới tải QPS cao.
Agent Vector SIMD viết bằng RustDùng Sidecar Fluentd hoặc LogstashFluentd/Logstash ngốn tới 15% CPU của pod và thường xuyên bị nhân Linux tiêu diệt do tràn bộ nhớ (OOM).
Lấy mẫu theo đuôi (Tail-Based)Lấy mẫu tại đầu nguồn (Head-Based)Lấy mẫu đầu nguồn bỏ sót 99% các trường hợp trễ đuôi p99 và deadlock trước khi chúng kịp bộc lộ.
Phân tích hiệu năng bằng eBPFBật pprof CPU lấy mẫu trong Gopprof làm suy giảm 5% thông lượng và hoàn toàn mù tịt trước các độ trễ nằm bên trong nhân hệ điều hành.

Câu Hỏi Thường Gặp (FAQ)

ClickHouse xử lý chỉ mục thưa (sparse index) khác biệt như thế nào so với cây B-Tree truyền thống?

Các cơ sở dữ liệu B-Tree truyền thống lập chỉ mục cho từng dòng riêng lẻ, đòi hỏi dung lượng RAM khổng lồ để chứa toàn bộ cây chỉ mục nhằm tránh việc đọc đĩa ngẫu nhiên. Ngược lại, ClickHouse xây dựng chỉ mục thưa, cứ 8.192 dòng dữ liệu mới đánh một dấu chỉ mục (index_granularity = 8192). Khi truy vấn, ClickHouse đọc chỉ mục thưa siêu nhỏ gọn này để xác định những khối dữ liệu 8.192 dòng nào có khả năng chứa kết quả, bỏ qua hoàn toàn 99,9% các khối dữ liệu còn lại trên đĩa. Cơ chế này cho phép quét qua hàng tỷ bản ghi với tốc độ cực nhanh trong khi tiêu tốn lượng RAM vô cùng khiêm tốn.

Điều gì xảy ra với bộ đệm OpenTelemetry nếu pod collector bị cạn kiệt bộ nhớ RAM?

Bộ vi xử lý lấy mẫu theo đuôi của OTel Collector duy trì bộ đệm trong RAM giới hạn bởi tham số num_traces (thường được cấu hình là 250.000 trace). Nếu một đợt bùng nổ lưu lượng bất thường đẩy mức chiếm dụng RAM lên sát ngưỡng giới hạn của container, bộ giới hạn bộ nhớ khẩn cấp (memory-limiter) sẽ tự động kích hoạt: nó hủy các trace chưa hoàn tất cũ nhất và tạm thời chuyển sang chế độ lấy mẫu xác suất 1% tại đầu nguồn. Cơ chế tự vệ này bảo đảm collector không bao giờ bị sập hoặc làm gián đoạn luồng tiếp nhận telemetry trong lúc hệ thống đang xảy ra sự cố lớn.

Tại sao việc chèn log vào ClickHouse theo từng dòng đơn lẻ lại gây sập máy chủ?

Mỗi câu lệnh INSERT trong ClickHouse sẽ tạo ra một phần dữ liệu (data part) vật lý bất biến trên ổ cứng. Các luồng nền của ClickHouse liên tục gom các part nhỏ này thành các part lớn hơn. Nếu ứng dụng gửi 5.000 lệnh insert từng dòng mỗi giây, hàng ngàn part nhỏ sẽ sinh ra nhanh hơn tốc độ gom của động cơ nền. Máy chủ sẽ ném ra lỗi nghiêm trọng Too many parts in all data parts in table và từ chối nhận toàn bộ các lệnh ghi mới. Bằng cách gom ít nhất từ 10.000 đến 100.000 dòng trong Go trước khi gửi một mẻ chèn duy nhất, các part được tạo ra gọn gàng và giữ vững độ ổn định của máy chủ.

Làm thế nào chương trình eBPF liên kết được ngăn xếp nhân Linux với mã nguồn ứng dụng Golang?

Khi một file binary Go thực thi, các khung gọi hàm (stack frames) được lưu trong vùng nhớ stack của không gian người dùng. Khi ngắt định thời của eBPF kích hoạt, chương trình eBPF lần theo con trỏ ngăn xếp và trích xuất các địa chỉ con trỏ lệnh (RIP). Một tiến trình daemon trên không gian người dùng sẽ đọc bảng biểu tượng ELF và metadata gỡ lỗi DWARF (hoặc bảng pclntab nội bộ của Go), dịch các địa chỉ bộ nhớ ảo thô đó thành tên package, tên hàm và số dòng mã nguồn Go rõ ràng (ví dụ: github.com/shopee/order/engine.ProcessOrder:142).

Tài Liệu Tham Khảo Kỹ Thuật

Để nghiên cứu sâu hơn về kiến trúc hạ tầng và kỹ thuật tối ưu hóa hiệu năng hệ thống:


Tổng Kết Toàn Bộ Series

Chúc mừng bạn đã hoàn thành trọn vẹn Series Chuyên Sâu Kiến Trúc Shopee. Qua 5 bài học toàn diện này, chúng ta đã mổ xẻ toàn bộ bản thiết kế kỹ thuật đằng sau sàn thương mại điện tử có lượng truy cập lớn nhất Đông Nam Á:

  1. Bài 1: Nền Tảng Microservices — Go, gRPC và API Gateway
  2. Bài 2: Động Cơ Flash Sale — Redis Lua & Chống Bán Quá Kho
  3. Bài 3: Tấm Khiên Lưu Lượng — WAF, Rate Limiting & Kafka Peak Shaving
  4. Bài 4: Mở Rộng Cơ Sở Dữ Liệu — Từ Sharded MySQL Sang TiDB NewSQL
  5. Bài 5: Giám Sát Cực Đại — OpenTelemetry, ClickHouse & Tracing