昨天我們了解了 ClickHouse Exporter 如何透過三層迴圈展開 OTLP 的巢狀結構,並將不同類型的 metrics 分流到不同的 table。我們看到了 pushMetricsData 函式對 Gauge、Sum、Histogram 等不同類型呼叫對應的 Add()
方法。今天,我們將以 Gauge 為例,來探討更核心的問題:
我們選擇 Gauge 作為範例,是因為它的資料結構相對單純,容易理解轉換邏輯。這裡的重點是「轉換處理流程」,而非 Gauge 本身的特性(關於 metrics 的類型,可以參考 Day 09)。
在深入轉換邏輯前,我們先來看 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 清楚地反映了上一篇文章提到的 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的敘述。
現在讓我們深入了解 Gauge 的資料轉換流程。這個流程分為兩個階段:
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,
})
}
這個方法做了幾件事:
pmetric.Metric
中取得 Gauge 類型的資料gaugeModel
gaugeModels
陣列中,等待後續批次處理這個設計的好處是延遲轉換:在收集階段只做最少的處理,把耗時的資料轉換留到批次寫入時一次處理。
當所有資料收集完成後,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 中:
ResourceAttributes
, ResourceSchemaUrl
等欄位ScopeName
, ScopeAttributes
等欄位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()
一次性送出。並記錄總處理時間和網路傳輸時間,方便監控效能瓶頸。
ClickHouse Exporter 的設計給了我們幾個重要的啟發:
ClickHouse 選擇將 Resource、Scope、Metric 的資訊都存在同一個 table 中,雖然會有大量重複資料(例如同一個 service 的 ResourceAttributes 會在每一筆 metric 中重複),但換來的是:
這個設計思維也適用於 Data Lakehouse。在 Parquet/Iceberg 中,我們也可以選擇:
對於 observability 資料,通常查詢模式是「分析某個時間範圍內某個服務的 metrics」,而不是「查詢所有 metrics 的 resource 資訊」,反正規化設計可能會更合適這樣的應用場景。
ClickHouse Exporter 透過快速地收集並累積資料來減少處理開銷,而在寫入資料時,則是批次處理來提升寫入效率並降低資料庫的壓力。
在我們前面設計的 data pipeline 架構中,也採用類似的兩階段模式:
在講巢狀結構之前,需要先定義什麼是巢狀結構?它是指一筆資料中包含一對多的關係,例如說 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