iT邦幫忙

2025 iThome 鐵人賽

DAY 24
0
DevOps

被稱作Server Restart Engineer的我,也想了解如何實踐可觀測性工程系列 第 24

Day 24: 從 Gauge Schema 看 OTLP 到 ClickHouse 的轉換細節

  • 分享至 

  • xImage
  •  

昨天我們了解了 ClickHouse Exporter 如何透過三層迴圈展開 OTLP 的巢狀結構,並將不同類型的 metrics 分流到不同的 table。我們看到了 pushMetricsData 函式對 Gauge、Sum、Histogram 等不同類型呼叫對應的 Add() 方法。今天,我們將以 Gauge 為例,來探討更核心的問題:

  1. ClickHouse 的 table schema 如何設計?哪些欄位是必要的?
  2. OTLP 的巢狀資料如何對應到扁平的 table schema?
  3. Add() 與 insert() 方法如何處理資料收集與轉換?
  4. 這些設計對 Lakehouse 架構的啟發

我們選擇 Gauge 作為範例,是因為它的資料結構相對單純,容易理解轉換邏輯。這裡的重點是「轉換處理流程」,而非 Gauge 本身的特性(關於 metrics 的類型,可以參考 Day 09)。

ClickHouse 的 Gauge Table Schema

在深入轉換邏輯前,我們先來看 ClickHouse 為 Gauge metrics 設計的 table schema。這個 schema 定義在 metrics_gauge_table.sql 中:

CREATE TABLE IF NOT EXISTS "%s"."%s" %s (
    ResourceAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ResourceSchemaUrl String CODEC(ZSTD(1)),
    ScopeName String CODEC(ZSTD(1)),
    ScopeVersion String CODEC(ZSTD(1)),
    ScopeAttributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    ScopeDroppedAttrCount UInt32 CODEC(ZSTD(1)),
    ScopeSchemaUrl String CODEC(ZSTD(1)),
    ServiceName LowCardinality(String) CODEC(ZSTD(1)),
    MetricName String CODEC(ZSTD(1)),
    MetricDescription String CODEC(ZSTD(1)),
    MetricUnit String CODEC(ZSTD(1)),
    Attributes Map(LowCardinality(String), String) CODEC(ZSTD(1)),
    StartTimeUnix DateTime64(9) CODEC(Delta, ZSTD(1)),
    TimeUnix DateTime64(9) CODEC(Delta, ZSTD(1)),
    Value Float64 CODEC(ZSTD(1)),
    Flags UInt32 CODEC(ZSTD(1)),
    Exemplars Nested (
        FilteredAttributes Map(LowCardinality(String), String),
        TimeUnix DateTime64(9),
        Value Float64,
        SpanId String,
        TraceId String
    ) CODEC(ZSTD(1)),
    INDEX idx_res_attr_key mapKeys(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_res_attr_value mapValues(ResourceAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_scope_attr_key mapKeys(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_scope_attr_value mapValues(ScopeAttributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_attr_key mapKeys(Attributes) TYPE bloom_filter(0.01) GRANULARITY 1,
    INDEX idx_attr_value mapValues(Attributes) TYPE bloom_filter(0.01) GRANULARITY 1
) ENGINE = %s
%s
PARTITION BY toDate(TimeUnix)
ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))
SETTINGS index_granularity=8192, ttl_only_drop_parts = 1

Schema 設計的層次結構

這個 schema 清楚地反映了上一篇文章提到的 OTLP Metrics 三層架構,並且可以從欄位命名清楚辨識:

1. Resource 層:記錄 service.name、host.name 等資源屬性,用來標識資料來源

ResourceAttributes Map(LowCardinality(String), String)
ResourceSchemaUrl String

2. Scope 層:記錄產生這個 metric 的 instrumentation library 資訊

ScopeName String
ScopeVersion String
ScopeAttributes Map(LowCardinality(String), String)
ScopeDroppedAttrCount UInt32
ScopeSchemaUrl String

3. Metrics 層:這一層才是實際的指標資料

ServiceName LowCardinality(String)      -- 從 ResourceAttributes 提取的常用欄位
MetricName String                        -- 例如 "cpu.temperature"
MetricDescription String
MetricUnit String
Attributes Map(...)                      -- metric 的標籤,例如 {cpu="0", core="1"}
StartTimeUnix DateTime64(9)
TimeUnix DateTime64(9)                   -- 觀測時間(納秒精度)
Value Float64                            -- Gauge 的實際數值
Flags UInt32

4. Exemplars:負責將 metrics 與 traces 關聯起來

Exemplars Nested (
    FilteredAttributes Map(...)
    TimeUnix DateTime64(9)
    Value Float64
    SpanId String      -- 關聯到 trace 的 span
    TraceId String     -- 關聯到 trace 的 ID
)

Exemplar 讓我們可以將 metrics 與 traces 關聯起來。例如當 HTTP request latency 突然升高時,可以透過 exemplar 找到對應的 trace,快速定位問題。

除此之外, schema 中有幾個值得注意的設計:

1. ServiceName 獨立欄位
雖然 service.name 已經存在於 ResourceAttributes 中,但仍然獨立出一個 ServiceName 欄位。這是因為查詢時最常按服務過濾,獨立欄位可以讓排序和查詢更快。

2. Bloom Filter 索引
針對 Map 類型的欄位建立 Bloom Filter 索引,可以快速判斷某個 key 或 value 是否存在,加速 WHERE 條件的過濾。

3. 排序鍵設計

ORDER BY (ServiceName, MetricName, Attributes, toUnixTimestamp64Nano(TimeUnix))

優先按 ServiceName 和 MetricName 排序,這是因為 observability 的資料查詢模式很明確,幾乎會需要指定服務名稱與 metrics,優先排序可以最大化分區帶來的效能提升。

不過,若是團隊的查詢模式非常多變,可能就要採用 z-ordering 的排序會較適合。可以參考 Day 21的敘述。

OTLP Gauge 到 ClickHouse 的轉換流程

現在讓我們深入了解 Gauge 的資料轉換流程。這個流程分為兩個階段:

階段一:收集資料 - Add() 方法

Add() 方法負責收集 gauge metrics 和相關的 metadata:

func (g *gaugeMetrics) Add(
    resAttr pcommon.Map,
    resURL string,
    scopeInstr pcommon.InstrumentationScope,
    scopeURL string,
    metrics pmetric.Metric,
) {
    gauge := metrics.Gauge()
    g.count += gauge.DataPoints().Len()
    g.gaugeModels = append(g.gaugeModels, &gaugeModel{
        metricName:        metrics.Name(),
        metricDescription: metrics.Description(),
        metricUnit:        metrics.Unit(),
        metadata: &MetricsMetaData{
            ResAttr:    resAttr,
            ResURL:     resURL,
            ScopeURL:   scopeURL,
            ScopeInstr: scopeInstr,
        },
        gauge: gauge,
    })
}

這個方法做了幾件事:

  1. 提取 Gauge 資料:從 pmetric.Metric 中取得 Gauge 類型的資料
  2. 計算 data points 數量:累加這個 gauge 包含的 data points 數量
  3. 封裝成 model:將 metric 的名稱、描述、單位,以及 Resource/Scope 的 metadata 一起封裝成 gaugeModel
  4. 加入收集陣列:將 model 加入 gaugeModels 陣列中,等待後續批次處理

這個設計的好處是延遲轉換:在收集階段只做最少的處理,把耗時的資料轉換留到批次寫入時一次處理。

階段二:轉換與寫入 - insert() 方法

當所有資料收集完成後,insert() 方法會負責實際的資料轉換和批次寫入。讓我們來分段拆解這段程式碼:

1. 效能追蹤與資源管理

processStart := time.Now()
defer func(batch driver.Batch) {
    if closeErr := batch.Close(); closeErr != nil {
        logger.Warn("failed to close gauge metrics batch", zap.Error(closeErr))
    }
}(batch)

使用 defer 確保 batch 資源會被正確釋放,並記錄整個處理過程的耗時。

2. 預先轉換常用欄位

resAttr := AttributesToMap(model.metadata.ResAttr)
scopeAttr := AttributesToMap(model.metadata.ScopeInstr.Attributes())
serviceName := GetServiceName(model.metadata.ResAttr)

在外層迴圈預先轉換 Resource 和 Scope 的 attributes,避免在內層迴圈中重複轉換相同的資料。這是一個重要的效能優化。

3. 展開 Data Points

for i := 0; i < model.gauge.DataPoints().Len(); i++ {
    dp := model.gauge.DataPoints().At(i)
    // 每個 data point 會被轉換成 table 中的一筆 row
}

一個 Gauge metric 可能包含多個 data points(例如監控多個 CPU 核心的溫度),每個 data point 會被轉換成 table 中的一筆 row。

4. 扁平化巢狀結構

batch.Append(
    resAttr,                          // Resource 層
    model.metadata.ResURL,
    model.metadata.ScopeInstr.Name(), // Scope 層
    scopeAttr,
    model.metricName,                 // Metric 層
    model.metricUnit,
    dp.Timestamp().AsTime(),
    getValue(...),
    // ...
)

OTLP 的三層結構(Resource → Scope → Metric)被「攤平」到同一個 row 中:

  • Resource 層的資訊: ResourceAttributes, ResourceSchemaUrl 等欄位
  • Scope 層的資訊: ScopeName, ScopeAttributes 等欄位
  • Metric 層的資料: MetricName, Value, TimeUnix 等欄位

這種「資訊冗餘」的設計雖然會增加儲存空間,但卻能讓查詢時不需要 JOIN,直接從單一 table 取得所有資訊。

5. 類型轉換與處理

Attributes 轉換:將 OTLP 的 pcommon.Map 轉換成 ClickHouse 的 Map(String, String)

resAttr := AttributesToMap(model.metadata.ResAttr)

ServiceName 提取:從 ResourceAttributes 中提取 service.name,因為這是查詢時最常用的欄位

serviceName := GetServiceName(model.metadata.ResAttr)

Value 處理:根據 ValueType 取得 int64 或 float64 的值

getValue(dp.IntValue(), dp.DoubleValue(), dp.ValueType())

Exemplar 轉換:一次性轉換所有 exemplars,回傳五個陣列,對應到 ClickHouse 的 Nested 類型

attrs, times, values, traceIDs, spanIDs := convertExemplars(dp.Exemplars())

6. 批次寫入與效能記錄

err = batch.Send()
logger.Debug("insert gauge metrics",
    zap.Int("records", g.count),
    zap.String("cost", time.Since(processStart).String()),
    zap.String("network_cost", time.Since(start).String()))

使用 batch.Append() 累積多筆資料,最後用 batch.Send() 一次性送出。並記錄總處理時間和網路傳輸時間,方便監控效能瓶頸。

對 Lakehouse 架構的啟發

ClickHouse Exporter 的設計給了我們幾個重要的啟發:

1. Schema 設計的權衡:冗餘 vs JOIN

ClickHouse 選擇將 Resource、Scope、Metric 的資訊都存在同一個 table 中,雖然會有大量重複資料(例如同一個 service 的 ResourceAttributes 會在每一筆 metric 中重複),但換來的是:

  • 查詢時不需要 JOIN,效能更好
  • Schema 更簡單,容易理解和維護

這個設計思維也適用於 Data Lakehouse。在 Parquet/Iceberg 中,我們也可以選擇:

  • 正規化設計:分成多個 table,透過 JOIN 查詢(適合 OLTP 場景)
  • 反正規化設計:把常用資訊都放在同一個 table(適合 OLAP 場景)

對於 observability 資料,通常查詢模式是「分析某個時間範圍內某個服務的 metrics」,而不是「查詢所有 metrics 的 resource 資訊」,反正規化設計可能會更合適這樣的應用場景。

2. 兩階段處理模式

ClickHouse Exporter 透過快速地收集並累積資料來減少處理開銷,而在寫入資料時,則是批次處理來提升寫入效率並降低資料庫的壓力。

在我們前面設計的 data pipeline 架構中,也採用類似的兩階段模式:

  1. 收集階段:Lambda 接收並拆分 OTLP 批次資料,將單一 batch 拆分成多個 records
  2. 寫入階段:Firehose 累積多個 records 後,批次寫入 Parquet file 到 S3 Table
  3. 最佳化階段:S3 Table 自動執行 compaction,合併小檔案並優化 Iceberg metadata

3. 巢狀類型的處理

在講巢狀結構之前,需要先定義什麼是巢狀結構?它是指一筆資料中包含一對多的關係,例如說 OTLP 中的一個 data point 可能包含多個 examplars:

"value": 42.5,
"exemplars": [
{"spanId": "abc123", "traceId": "xyz789", "value": 45.2},
{"spanId": "def456", "traceId": "uvw012", "value": 38.1}
]

Examplar 使用了 ClickHouse 原生支援的 Nested 類型,對應到 Parquet 就是 List<Struct>,這種類型會將巢狀資料轉換成多個平行的陣列:

Exemplars.SpanId   = ["abc123", "def456"]
Exemplars.TraceId  = ["xyz789", "uvw012"]
Exemplars.Value    = [45.2, 38.1]

每個欄位都變成一個獨立的陣列,透過相同的 index 來對應同一個 exemplar 的資料。 convertExemplars() 函式負責將 OTLP 的巢狀結構轉換成 ClickHouse 需要的格式:

func convertExemplars(exemplars pmetric.ExemplarSlice) (clickhouse.ArraySet, clickhouse.ArraySet, clickhouse.ArraySet, clickhouse.ArraySet, clickhouse.ArraySet) {
	var (
		attrs    clickhouse.ArraySet
		times    clickhouse.ArraySet
		values   clickhouse.ArraySet
		traceIDs clickhouse.ArraySet
		spanIDs  clickhouse.ArraySet
	)
	for i := 0; i < exemplars.Len(); i++ {
		exemplar := exemplars.At(i)
		attrs = append(attrs, AttributesToMap(exemplar.FilteredAttributes()))
		times = append(times, exemplar.Timestamp().AsTime())
		values = append(values, getValue(exemplar.IntValue(), exemplar.DoubleValue(), exemplar.ValueType()))

		traceID, spanID := exemplar.TraceID(), exemplar.SpanID()
		traceIDs = append(traceIDs, hex.EncodeToString(traceID[:]))
		spanIDs = append(spanIDs, hex.EncodeToString(spanID[:]))
	}
	return attrs, times, values, traceIDs, spanIDs
}

在這個函式中,它提取了每個 examplar中的欄位到不同的陣列當中,並透過迴圈的方式讓所有陣列的長度可以相同(也必須等於 examplars 的數量),最後的 return attrs, times, values, traceIDs, spanIDs 則確保其順序與 schema 的定義一致。

結語

今天我們以 Gauge 為例,深入了解了 ClickHouse Exporter 如何將 OTLP 的巢狀結構轉換成扁平的 table schema。包含了將三層結構攤平到同一張 table 的反正規化設計、資料轉換、資料冗餘與 JOIN 的權衡、exemplar 的巢狀結構處理等。

在安排文章內容時,曾經猶豫要不要把 clickhouse exporter 的原始碼介紹放進系列文當中。但是,筆者自己在探索並設計 observability 架構時,曾經非常苦惱要如何把複雜的 OTLP 架構寫入 S3 Table 當中,再加上目前也沒有現成的 S3 Table exporter 可以使用。

在那時候,就有參考 clickhouse exporter 的 schema 架構設計以及資料轉換方式,才學習了要如何把巢狀結構轉成欄式資料喜歡的扁平式架構。基於這樣的脈絡,還是把內容放進了系列文當中。

明天,將會介紹更多不同的可觀測性系統架構,這些也都是筆者在做系統設計的參考與養分。

參考資料

opentelemetry-collector-contrib/exporter/clickhouseexporter

ClickHouse Documentation - Nested Data Structures

parquet-writer - Storing Lists of Structs


上一篇
Day 23 - 從 ClickHouse Exporter 看資料處理流程:OTLP 到 ClickHouse 的轉換
系列文
被稱作Server Restart Engineer的我,也想了解如何實踐可觀測性工程24
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言