iT邦幫忙

0

告別 Batch Processor:OpenTelemetry Collector 批次架構大翻修

  • 分享至 

  • xImage
  •  

來源出自幹話王

1. 前言:伴隨 OTel 六年的老牌元件為何退場?

只要你曾在生產環境架設過 OpenTelemetry Collector,你的 config.yaml 裡百分之百寫過這幾行:

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [batch] # 幾乎所有官方教學與 Helm Charts 的標準配置
      exporters: [otlp]

自 2019 年 OpenTelemetry 專案問世以來,batch processor 一直被視為 Collector 效能優化的核心基石——它負責把來自各處零散的 Traces、Metrics 與 Logs 打包成 Batch,藉此提升網路壓縮比並分攤 HTTP/gRPC 的 RTT 延遲。

然而,在 2026 年 11 月,OpenTelemetry 官方維護團隊(Bogdan Drutu、Dmitry Anoshin 與 Joshua MacDonald)正式發布了 Deprecating the Collector Batch Processor 的重大架構變更公告:

「我們正式宣告棄用 batch processor。批次處理能力已直接整合至各個 Exporter 的 sending_queue(稱為 Exporter-level batching),並同步推出全新的 queue_batch processor 作為替代方案。」

關鍵里程碑時間表

  • v0.126.0+:sending_queue 與底層 queuebatch 模組進入穩定狀態。
  • v0.171.0 (2027 年 2 月預定):大部分主流 Exporter 將預設啟用 Exporter-level batching。
  • v0.177.0 (2027 年 5 月預定):batch processor 將正式從 Core Repository 與各發行版中徹底刪除。

表面上看,這只是把配置區塊從 processors.batch 搬到 exporters.<name>.sending_queue.batch;但實際上,這是 Collector 核心團隊歷經數年重構、為了解決分散式追蹤與遙測傳輸中最棘手的 OOM、Backpressure 雪崩 與 Head-of-line blocking 所交出的最終答案。


2. 歷史原罪:batch processor 的三大致命架構缺陷

要理解為什麼 OTel 社群不惜打破使用了六年的既有習慣,必須深入舊版 batchprocessor 的原始碼,看看它在現代高吞吐雲原生環境下撞上了哪些物理極限。


缺陷 1:只認 Item Count,對實際 Byte Size 毫無視覺

舊版 batch processor 的設定參數本質上完全基於 Item Count:

processors:
  batch:
    timeout: 200ms
    send_batch_size: 8192      # 湊滿 8192 個 items 就發送
    send_batch_max_size: 10000  # 最大切分上限

在 Collector 的內部表示中,一個「Item」代表一個 Span、一個 Metric Data Point 或一筆 Log Record。然而:

  • 在典型 Microservices 下,一個純 HTTP 呼叫的 Span 可能只有 200 Bytes。
  • 但在例外崩潰、或者帶有大封裝 Context、SQL 查詢字串與巨型 Stack Trace 時,一個 Span 可以超過 500 KB。
  • 同樣地,帶有巨型 JSON Payload 的 Log Record,一筆就可能高達 2 MB。

產生的災難:

當 send_batch_size 設為 8,192 時:

  • 最佳情況下:$8192 \times 200\text{ B} \approx 1.6\text{ MB}$(完美的網路吞吐大小)。
  • 最差情況下:$8192 \times 200\text{ KB} \approx 1.6\text{ GB}$!

這會直接引爆兩大問題:

  1. gRPC 4MB 上限斷線:下游後端(如 Jaeger、Tempo、OTLP Ingestor)通常設有 MaxRecvMsgSize(預設 4MB),一旦批次在序列化後超過 4MB,下游直接拒收並回報 ResourceExhausted。
  2. Collector 記憶體激增 OOM:記憶體限制器(memory_limiter)無法阻止 batch processor 正在聚合的記憶體膨脹,導致容器被 Kubernetes OOMKilled。

舊架構無法按照 Bytes 來聚合或切分批次,是促成重構的首要原因。

監控服務自己若因為自己沒做好記憶體管理,而導致 OOM 漏了資料。那就沒人會信這套監控服務是可靠的了。


缺陷 2:偽非同步與單一 Goroutine 併發瓶頸

我們翻開 processor/batchprocessor/batch_processor.go 的核心原始碼,檢視它的 Consume 處理機制:

// 舊版 batch_processor.go 中的核心接收邏輯
func (sb *singleShardBatcher[T]) consume(_ context.Context, data T) error {
	sb.single.newItem <- data  // 將請求推入一個內建的 Go Channel
	return nil                 // 立刻向 upstream 回報成功!
}

這個看似輕巧的實作背後隱藏著嚴重的並行盲點:

func (bp *batchProcessor[T]) newShard(...) *shard[T] {
	return &shard[T]{
		processor: bp,
		newItem:   make(chan T, runtime.NumCPU()), // 緩衝區極小(僅 CPU 核心數)
		batch:     bp.batchFunc(),
	}
}
// 舊版 shard.go 中的單一 Event Loop
func (b *shard[T]) startLoop() {
	for {
		select {
		case item := <-b.newItem:
			b.processItem(item)
		case <-timerCh:
			if b.batch.itemCount() > 0 {
				b.sendItems(triggerTimeout) // 觸發下游發送
			}
			b.resetTimer()
		}
	}
}

瓶頸連鎖反應圖解:

瓶頸分析:

  1. 單一 Worker 阻塞:每一個 Shard 只有一個 startLoop goroutine。當它呼叫 sendItems() 將資料發往下游 exporter 時,如果下游網路有延遲或發生 retry,這個迴圈就會卡在 b.sendItems() 呼叫上。
  2. 極小的 Channel 緩衝:newItem channel 容量僅有 runtime.NumCPU()(例如 8 或 16)。一旦 startLoop 卡住,只要十幾個 upstream 請求進來,channel 瞬間被塞滿。
  3. 無差別反向阻塞 Receiver:此時 upstream receiver 呼叫 consume() 就會被阻塞在 sb.single.newItem <- data,進而卡死整個 HTTP/gRPC receiver 執行緒,將延遲直接回頂給前端業務服務。
  4. 無效的 Concurrency 控制:除非你配置了 metadata_keys 讓它根據 header 開闢多個 shard,否則在預設情況下,批次聚合沒有任何並行發送能力!

缺陷 3:Backpressure、錯誤傳播與 Disk Persistence 的徹底缺位

請再次觀察上述 consume 函式的回傳值:

sb.single.newItem <- data
return nil // 無條件回傳成功!

而在非同步發送的 sendItems 裡:

func (b *shard[T]) sendItems(trigger trigger) {
	sent, req := b.batch.split(b.processor.sendBatchMaxSize)
	err := b.batch.export(b.exportCtx, req)
	if err != nil {
		b.processor.logger.Warn("Sender failed", zap.Error(err))
		return // 僅僅打了一行 Warning Log,錯誤直接被吞噬!
	}
}

這造成了系統可靠度的「三大黑洞」:

執行期真實互動時序圖(靜默丟包過程):

  1. No Error Propagation:客戶端收到 200 OK,誤以為遙測數據已安全送達。但後端當機或 Exporter 耗盡重試時,資料默默蒸發,發送端毫無感知,無法觸發 Client 重試。
  2. 缺乏 Backpressure 機制:系統無法配置當下游過載時是要直接 Fail-fast(回傳 HTTP 429/503)還是 Block on deadline。
  3. 缺乏 Disk Persistence:batch processor 內部的佇列純粹是記憶體 channel。當 Collector 滾動更新、Pod 被 Evict 或進程 Crash 時,滯留資料全數蒸發。

因為 OTel collector 其實不像 RDBMS 有 WAL 機制,重啟後也是能從硬碟中的資料持續進行。但 OTel Collector 並沒這設計,也不會寫硬碟,因為 OTel collector 基本都是會先以 Sidecar container 的形式存在,如果要每個 sidecar 都配置 PVC 其實是很大的成本,所以不預設這機制 這是正常的。


3. 拓撲反轉:從「先 Batch 再 Queue」到「先 Queue 再 Batch」

為了解決上述三大硬傷,OpenTelemetry 架構團隊做了一次大膽的結構重組:將佇列與批次合二為一,並將批次處理的時機點往後推移到佇列之後!

新舊架構資料流對比

為什麼這是一個突破性的架構演進?

比較維度 舊版 batch processor 新版 Exporter-Level sending_queue.batch
批次發生的位置 Pipeline 處理中段(所有 Exporter 共用) Exporter 發送端(各 Exporter 獨立運作)
度量維度 (Sizer) 僅限 Items Count 支援 Requests、Items 與 Bytes
持久化支援 ❌ 無(記憶體 Channel,當機即遺失) ✅ 完整支援(可對接 file_storage 等儲存擴充)
背壓與確認控制 ❌ 立即回傳 nil,吞噬下游失敗 ✅ 支援 block_on_overflow 與 wait_for_result
併發與執行緒模型 單一 Shard 單一 Loop,容易 Head-of-line blocking 獨立 workerPool,佇列消費與網路發送解耦
多後端適應性 ❌ 所有 Exporter 強迫接收同一種 Batch 大小 ✅ Exporter A (OTLP gRPC) 可設 4MB;Exporter B (Datadog) 可設 2MB
分區支援 (Partition) 依賴未限制或粗糙的 Multi-shard 內建長度前綴編碼 + LRU Cache + idle_timeout

3.1 架構思辨:既然 Sidecar 預設不該掛PVC,為何新版要支援 file_storage?

在深入原始碼之前,許多具備大規模生產經驗的 SRE 與架構師會浮現一個非常犀利的工程疑問:

「OpenTelemetry Collector 本質上不是 RDBMS,沒有原生預設的 WAL 機制。在 Kubernetes 生態中,Collector 大多以 Pod 的 Sidecar 形式部署;若為數以千計的 Sidecar 容器各自分配獨立的 PVC,不僅維運成本巨大,還會迅速觸碰雲端供應商的單節點磁碟掛載上限(例如 AWS EC2 單機通常僅支援 28~39 塊 EBS 磁碟卷)。既然 Sidecar 預設走純記憶體(In-Memory)是合理的工程決策,為什麼新版 Exporter-Level 架構要大費周章地將持久化(file_storage)與批次處理進行深度整合?」

這看似矛盾,但背後恰恰展現了 OpenTelemetry 架構委員會在 「分層部署拓撲」 與 「修復歷史假持久化漏洞」 上的嚴密工程思維:

1. 雙層架構的分工現實:Sidecar 不掛硬碟,不代表 Gateway 不需要!

在成熟的企業級可觀測性架構中,Collector 幾乎都是以 「雙層拓撲(Two-Tier Architecture)」 落地:

  • 邊緣層(Sidecar / DaemonSet):數以萬計,講求極低記憶體開銷與超低延遲,永遠維持純記憶體模式(In-Memory Queue),將資料快速拋往內部的集中式 Gateway。
  • 匯聚層(Gateway Cluster):通常以 StatefulSet 部署(數量僅數台至數十台)。這時為每台 Gateway 配置一塊 50GB~100GB 的 High-IOPS 本地 SSD 或 PVC,成本完全可控。
  • 為什麼 Gateway 迫切需要 file_storage?
    因為 99% 的發送中斷與限流都發生在 Gateway 與外部 SaaS 之間(例如跨公網 WAN 抖動、後端維護、或 SaaS 返回 HTTP 429 Too Many Requests)。此時 Gateway 必須具備 WAL 級別的落盤緩衝,才不至於在對外網絡癱瘓時迅速爆倉丟失珍貴的 Telemetry 數據。

2. 修復舊架構「假持久化」的歷史原罪

更關鍵的原因在於:舊架構中的持久化是一個「虛假的安全保證」!

  • 舊架構的欺騙性:舊版 file_storage 位於 Exporter,但在進入 Exporter 前,請求必須在 batch processor 的記憶體中等待累積(數秒到數十秒)。但 batch processor 一收到資料就向上游回傳 nil(200 OK)。如果 Collector 在這幾秒內遭遇 OOMKill 或節點被 Evict,資料在還沒看過磁碟一眼之前就徹底蒸發了,後方的 file_storage 完全是個擺設!
  • 新架構的真實一致性:新架構中佇列置頂,資料一進 Exporter 第一步先寫入 file_storage 磁碟;隨後由 partitionBatcher 從磁碟讀出並合併成批次發送;只有當下游網路確認成功後,Done 回調才會標記磁碟 Offset 完成。進程中斷重啟,能夠真正做到 100% 斷點續傳!

3. 即使在 Sidecar 純記憶體模式下,新架構依然解鎖了「真 Backpressure」

即便在不掛載任何磁碟的 Sidecar 上,新架構的純記憶體佇列(in_memory)也帶來了徹底的可靠性躍升:

  • 舊版:batch processor 即使自己的記憶體快被擠爆,依然向應用 SDK 回傳成功(nil),隨後在背景默默丟包或直接引發 OOMKill。
  • 新版:搭配 block_on_overflow: true 與 wait_for_result: true,當 Sidecar 記憶體隊列滿載時,Receiver 會立即向業務應用的 OTel SDK 回拋 gRPC RESOURCE_EXHAUSTED (Code 8) 或 HTTP 429。
  • 業務效益:業務應用的 SDK 能及時察覺 Sidecar 消化不良,進而觸發客戶端退避重試(Backoff Retry)或本機降級,徹底告別了「內部滿載卻盲目吞單」的黑洞現象!

4. 節點本地儲存(DaemonSet + hostPath / emptyDir)的折衷選擇

此外,對於以 DaemonSet 模式運行的 Collector Agent,團隊甚至不需要昂貴的雲端 PVC,直接透過掛載宿主機的本地 NVMe SSD(如 hostPath 或 emptyDir):

extensions:
  file_storage:
    directory: /var/lib/otelcol/buffer # 掛載自宿主機本地 NVMe SSD

這樣既不佔用雲端磁碟配額,又能讓每個節點上的 Agent 擁有數十 GB 的抗網絡抖動磁碟緩衝區,在成本、效能與可靠性之間取得了絕佳平衡。


4. 原始碼深度追蹤:exporterhelper/internal/queuebatch 實作精華

現在,我們打開 OpenTelemetry Collector 核心儲存庫中位於 exporter/exporterhelper/internal/queuebatch 的源碼,一窺這套工業級批次佇列系統是如何設計的。


4.1 統一介面抽象:Request 與 MergeSplit

新架構的核心抽象位於 exporter/exporterhelper/internal/request/request.go:

type Request interface {
    // 取得基本項目數量(Spans, Datapoints, Log records)
    ItemsCount() int

    // 取得序列化後的位元組大小
    BytesSize() int

    // 核心方法:將當前 Request 與另一個 Request 進行合併與/或切分
    MergeSplit(ctx context.Context, maxSize int, sizerType SizerType, req Request) ([]Request, error)
}

以追蹤資料為例,在 traces_batch.go 裡的 MergeSplit 實作:

func (req *tracesRequest) MergeSplit(_ context.Context, maxSize int, szt request.SizerType, r2 request.Request) ([]request.Request, error) {
	var sz sizer.TracesSizer
	switch szt {
	case request.SizerTypeItems:
		sz = &sizer.TracesCountSizer{}
	case request.SizerTypeBytes:
		sz = &sizer.TracesBytesSizer{}
	default:
		return nil, errors.New("unknown sizer type")
	}

	// 1. 如果傳入了另一個請求 r2,先將 r2 合併進當前的 req
	if r2 != nil {
		req2, ok := r2.(*tracesRequest)
		if !ok {
			return nil, errors.New("invalid input type")
		}
		req2.mergeTo(req, sz, szt)
	}

	// 2. 如果沒有設定 maxSize 上限,直接回傳合併後的大包
	if maxSize == 0 {
		return []request.Request{req}, nil
	}

	// 3. 若超過 maxSize,依照 SizerType(數量或位元組)切分出獨立的 Requests
	return req.split(maxSize, sz, szt)
}

關鍵亮點:MergeSplit 實現了 zero-copy 或指針轉移(MoveAndAppendTo),並且同時支援 Items 與 Bytes 計算。如果一筆資料太大,它會將 ResourceSpans 拆解;若即使單一 Span 都大於 maxSize 且無法再切,它會明確拋出錯誤或降級處理,不再陷入無窮迴圈。


4.2 佇列與批次協同調度:QueueBatch 的消費機制

在 queue_batch.go 中,Collector 將佇列 queue.Queue 與批次器 batcher.Batcher 組裝成一個統一的結構體:

type QueueBatch struct {
	queue   queue.Queue[request.Request]
	batcher Batcher[request.Request]
}

func NewQueueBatch(set AllSettings[request.Request], cfg Config, next sender.SendFunc[request.Request]) (*QueueBatch, error) {
	b, err := NewBatcher(cfg.Batch, batcherSettings[request.Request]{
		partitioner: set.Partitioner,
		mergeCtx:    set.MergeCtx,
		next:        next,
		maxWorkers:  cfg.NumConsumers,
		obsMetrics:  set.ObsMetrics,
		logger:      set.Telemetry.Logger,
	})
	if err != nil {
		return nil, err
	}

	// 關鍵設計!若啟用了 Batching 且無分區,則將底層隊列的 Consumers 強制設為 1!
	// 詳見官方 Issue #12473
	if cfg.Batch.HasValue() && set.Partitioner == nil {
		cfg.NumConsumers = 1
	}

	// 建立隊列,並將 b.Consume 作為隊列出列時的回呼函式!
	q, err := queue.NewQueue(queue.Settings[request.Request]{
		SizerType:        cfg.Sizer,
		Capacity:         cfg.QueueSize,
		NumConsumers:     cfg.NumConsumers,
		WaitForResult:    cfg.WaitForResult,
		BlockOnOverflow:  cfg.BlockOnOverflow,
		StorageID:        cfg.StorageID,
        // ...
	}, b.Consume)

	return &QueueBatch{queue: q, batcher: b}, nil
}

[!NOTE] 為什麼無分區時 Queue 的 NumConsumers 要設為 1?
這是非常精妙的併發控制!若多個佇列消費者同時從隊列撈取零散項目組裝批次,會造成資料碎片化與 Lock Contention。新架構中,由單一佇列通道出列餵給 batcher,組裝成符合 min_size 的大批次後,再交由 workerPool(內部維護 maxWorkers = cfg.NumConsumers)進行高度並行的網路發送。這徹底避免了競態條件,同時維持了最大的網路吞吐!


4.3 核心狀態機:partitionBatcher 與 workerPool

許多工程師在初讀原始碼時會有一個大疑問:「為什麼最基層的批次器叫 partitionBatcher?如果我沒有開啟 Partition 分區,它又在扮演什麼角色?」

名詞解惑:partitionBatcher 與 Partition 的架構關係

在 queuebatch 的設計哲學中,partitionBatcher 是整個系統中最小的 Atomic Batching 狀態機。不論系統有沒有啟用分區,所有的批次聚合邏輯最終都落在它身上:

  1. 無分區模式(Unpartitioned,預設場景): 系統使用 shardedBatcher,底層包裝了 1 個(或少數 Shards)partitionBatcher 實例。此時這個單一的 partitionBatcher 就代表「全局唯一的預設分區」,所有從佇列出列的資料都進入此處聚合。
  2. 多租戶分區模式(Partitioned,啟用 metadata_keys): 系統使用 multiBatcher,內部維護一個 LRU 快取。每個不同的租戶標頭組合(如 Tenant-A、Tenant-B)在記憶體中都會各自動態實例化一個專屬的 partitionBatcher,各自獨立計數、獨立計時。
  3. 最關鍵的併發約束架構:共享 workerPool: 無論系統中存在 1 個還是 10,000 個 partitionBatcher,它們全都共用同一個全域的 workerPool! 這意味著即使有上千個租戶分區同時滿足發送條件,網路發送的並發 Goroutine 數量永遠被嚴格限制在 num_consumers 上限內,徹底杜絕了連線數爆炸與 CPU 耗盡。


partitionBatcher 內部狀態機流轉機制

負責單一分區狀態與定時 Flush 的核心是 partition_batcher.go 中的結構體:

type partitionBatcher struct {
	cfg            BatchConfig
	wp             *workerPool                      // 指向全域共用的 Worker Pool
	sizer          request.Sizer
	consumeFunc    sender.SendFunc[request.Request] // Exporter 的實際發送函式
	currentBatchMu sync.Mutex                       // 保護當前分區的互斥鎖
	currentBatch   *batch                           // 目前正在累積中的 Batch
	timer          *time.Timer                      // 控制 flush_timeout 的定時器
	active         bool
}

當佇列出列並呼叫 b.Consume(ctx, req, done) 時,會進入核心方法 consumeInternal。其狀態流轉決策如下圖所示:

核心源碼邏輯對照:

func (qb *partitionBatcher) consumeInternal(ctx context.Context, req request.Request, done queue.Done) bool {
	qb.currentBatchMu.Lock()
	defer qb.currentBatchMu.Unlock()

	// 情況 A:目前沒有累積中的 Batch
	if qb.currentBatch == nil {
		reqList, _ := req.MergeSplit(ctx, int(qb.cfg.MaxSize), qb.cfg.Sizer, nil)
		lastReq := reqList[len(reqList)-1]

		// 檢查最後一個碎片是否小於 MinSize
		if qb.sizer.Sizeof(lastReq) < qb.cfg.MinSize {
			// 不立刻發送,留存為 currentBatch,並重設 FlushTimeout 定時器!
			reqList = reqList[:len(reqList)-1]
			qb.currentBatch = &batch{
				ctx:  ctx,
				req:  lastReq,
				done: multiDone{done},
			}
			qb.resetTimer()
		}

		// 超過 MinSize 的其他完整批次,解鎖後直接調用 qb.flush()!
		for _, r := range reqList {
			qb.flush(ctx, r, done)
		}
		return qb.active
	}

	// 情況 B:已有累積中的 Batch,將新進來的 req 合併進去
	reqList, _ := qb.currentBatch.req.MergeSplit(ctx, int(qb.cfg.MaxSize), qb.cfg.Sizer, req)
	qb.currentBatch.done = append(qb.currentBatch.done, done)

	// 若合併後達到 MinSize 門檻,取出 currentBatch 並呼叫 qb.flush() 送入 workerPool!
    // 剩餘不足 MinSize 的尾端碎片則重設為新的 currentBatch。
}

workerPool 的 Token-Bucket 併發發送模型

當批次湊滿或超時觸發 qb.flush() 時,它並不會無限制地 go consumeFunc() 開闢 Goroutine,而是必須向 workerPool 取得執行配額:

type workerPool struct {
	workers chan struct{} // 帶緩衝的 struct{} Channel,容量為 num_consumers
}

func newWorkerPool(maxWorkers int) *workerPool {
	workers := make(chan struct{}, maxWorkers)
	for range maxWorkers {
		workers <- struct{}{} // 初始化時將 Token 填滿
	}
	return &workerPool{workers: workers}
}

func (wp *workerPool) execute(f func()) {
	<-wp.workers // 搶佔一個 Token 配額;若已達 10 個 Worker 滿載,則此處自然阻塞!
	go func() {
		defer func() {
			wp.workers <- struct{}{} // 網路發送完畢(成功或失敗),立即歸還 Token
		}()
		f()
	}()
}

架構精妙之處:

  1. Zero Allocation:利用 chan struct{} 實作 Token Bucket,無需額外的 Lock 競爭或計數器。
  2. 解耦佇列消費與網路發送:佇列只要負責快速將資料餵給 Batcher 聚合;網路 I/O 阻塞全部由 workerPool 自適應調節,永遠維持最佳並發吞吐。

4.4 Done 生命週期與確認保證:multiDone 與 refCountDone

在舊架構中,資料一進 batch processor 就斷開確認了;但在新架構中,若啟用了持久化隊列(如 file_storage)或設定了 wait_for_result: true,系統必須確保「多個原始佇列項目被併成一個 Batch 發送」或「單一超大請求被拆解成多個 Batch 發送」時,每個佇列項目的 Ack/Nack 都是百分之百精確的!

源碼中使用了兩個精巧的裝飾器:

1. 多對一聚合:multiDone

當 5 個小 Request 合併成 1 個 Batch 發送:

type multiDone []queue.Done

func (mdc multiDone) OnDone(err error) {
	for _, d := range mdc {
		d.OnDone(err) // 當 Batch 發送成功或失敗時,一次性通知所有參與者!
	}
}

2. 一對多分拆:refCountDone

當 1 個巨型 Request(如 50MB Traces)被 MergeSplit 切成 10 個小批次發送:

type refCountDone struct {
	done     queue.Done
	mu       sync.Mutex
	refCount int64
	err      error
}

func (rcd *refCountDone) OnDone(err error) {
	rcd.mu.Lock()
	defer rcd.mu.Unlock()
	rcd.err = multierr.Append(rcd.err, err)
	rcd.refCount--
	if rcd.refCount == 0 {
		// 只有當全部 10 個切片都完成發送,才真正向佇列提交最終結果!
		rcd.done.OnDone(rcd.err)
	}
}

架構價值:這保證了極致的 Reliability(可靠度)。只要有任何一個切片在網路中失敗,底層持久化佇列就能準確記錄並重新排程重試,徹底告別「靜默丟包」。


4.5 多租戶與基數控制:multiBatcher 與 LRU 淘汰機制

在 Multi-tenancy 環境中,不同租戶(如帶有不同 X-Tenant-ID 或 x-auth-token)不能混成同一批次送往後端。

新模組在 metadata_partitioner.go 中,利用 Varint 長度前綴二進位編碼 生成分區 Key,杜絕了字串拼接可能帶來的碰撞注入:

func (p *metadataKeysPartitioner) GetKey(ctx context.Context, _ request.Request) string {
	kb := make([]byte, 0, 64)
	meta := client.FromContext(ctx).Metadata
	for _, k := range p.keys {
		values := meta.Get(k)
		kb = binary.AppendUvarint(kb, uint64(len(values)))
		for _, val := range values {
			kb = binary.AppendUvarint(kb, uint64(len(val)))
			kb = append(kb, val...)
		}
	}
	return string(kb)
}

在 multi_batcher.go 中,透過 HashiCorp 的 simplelru 控制記憶體上限:

cache, err := lru.NewLRU[string, *partitionBatcher](cacheSize, func(_ string, pb *partitionBatcher) {
    // 當分區數量超過 cacheSize 被驅逐時,自動觸發非同步 Flush,絕不丟失資料!
    mb.wp.execute(pb.shutdownInternal)
})

若某個分區在 idle_timeout(預設 90s)內沒有新資料進來,該分區會被平滑移除並釋放記憶體,完美解決了舊版 multi-shard 記憶體無限洩漏的隱患。


5. 完整設定欄位字典:queuebatch 參數全貌與底層行為詳解

在 OpenTelemetry Collector 中,queuebatch 是 exporterhelper 內建的核心模組,也是全新 queue_batch processor 的底層引擎。換言之,無論是在 Exporter 的 sending_queue 下,還是在 Processor 的 queue_batch 下,其設定層級、欄位名稱與驗證邏輯都是完全通用且一致的。

配置層級全景架構(YAML Schema Tree)

# 適用於 Exporter: exporters.<name>.sending_queue
# 或適用於 Processor: processors.queue_batch
enabled: true                       # [bool] 是否啟用隊列 (預設: true)
sizer: requests                     # [enum: requests|items|bytes] 隊列計量單位 (預設: requests)
queue_size: 1000                    # [int64] 隊列最大容量 (單位由 sizer 決定,預設: 1000)
num_consumers: 10                   # [int] 隊列消費端/發送端 Worker 並發數 (預設: 10)
block_on_overflow: false            # [bool] 隊列滿載時是否阻塞呼叫端 (預設: false)
wait_for_result: false              # [bool] 是否等待發送完成才向客戶端回傳 (預設: false)
storage: file_storage/buffer        # [string] 持久化擴充 ID (可選,預設: 無)

batch:                              # [可選區塊] 批次聚合設定 (預設不啟用,填入 batch: {} 啟用)
  enabled: true                     # [bool] 是否啟用批次聚合 (預設: false)
  sizer: items                      # [enum: items|bytes] 批次計量單位 (繼承隊列 sizer,或預設 items)
  min_size: 8192                    # [int64] 觸發 Flush 的最小門檻 (預設: 8192)
  max_size: 0                       # [int64] 批次切分上限,0 為不限制 (預設: 0)
  flush_timeout: 200ms              # [duration] 最長延遲發送時間 (預設: 200ms)

  partition:                        # [可選區塊] 多租戶/標頭獨立分區設定
    metadata_keys:                  # [list of strings] 用於分區的 Client Metadata 標頭鍵名
      - X-Tenant-ID
      - x-auth-token
    cache_size: 10000               # [int] LRU 快取保留的最大活躍分區數 (預設: 10000)
    idle_timeout: 90s               # [duration] 空分區閒置超時自動清理時間 (預設: 90s)

5.1 佇列層級設定(Queue-level Settings)

欄位名稱 型別 預設值 合法選項 / 範圍 底層對應 Go Struct 行為解析與生產環境建議
enabled bool true true, false Config 是否啟用發送隊列。若設為 false,資料將繞過佇列與批次器,直接同步呼叫 Exporter 發送(此時 num_consumers、batch 等均失效)。
sizer string requests requests, items, bytes Config.Sizer 決定 queue_size 的計量單位:• requests:以進來的 Request 批次數計數(吞吐最高、CPU 消耗近乎 0,但無法防範單一超大 Request 撐爆記憶體)。• items:以最小遙測單元(Spans/Points/Logs)計數。• bytes:以序列化後的 Protobuf 位元組大小計數(記憶體控制最精確,但需負擔序列化 CPU 計算)。
queue_size int64 1000 大於 0 的整數 Config.QueueSize 隊列的最大容納上限,數值單位由上述 sizer 決定。• 若搭配持久化儲存(storage),代表磁碟緩衝區允許容納的請求上限。• ⚠️ 遷移時若將 batch 移入 exporter,請務必重新評估此數值(詳見第六章)。
num_consumers int 10 大於 0 的整數 Config.NumConsumers 佇列的最大並發消費數。• 重要原始碼行為:當啟用批次(batch)且未設定分區時,底層會將隊列消費者強制設為 1,而將 num_consumers 的數值指派給 workerPool 作為最終發送網路請求的並行 Worker 數,避免多消費者組裝批次時產生資源爭搶。
block_on_overflow bool false true, false Config.BlockOnOverflow 隊列滿載(達到 queue_size)時的溢位背壓策略:• false(預設,Fail-fast):立即拒絕請求並拋出可重試錯誤,使 Receiver 快速向 Client 回傳 HTTP 429/503。• true(Block on deadline):阻塞呼叫端 goroutine,直到隊列釋出空位或 Context Deadline 逾時。
wait_for_result bool false true, false Config.WaitForResult 端到端確認機制:• false(預設):資料成功進入隊列就立即向呼叫端回傳成功(非同步入隊)。• true:呼叫端會持續阻塞等待,直到資料真正被消費者取出、完成批次聚合並經由網路成功送到後端(或遭遇永久性失敗)。適合要求嚴格 At-least-once 交付的金融交易級管線。⚠️ 限制:無法與持久化儲存 storage 同時開啟。
storage string none (空) 任何合法的 Storage 擴充 ID Config.StorageID 指定持久化儲存擴充元件(例如 file_storage/persistent)。• 設定後將完全停用 Go 記憶體隊列,改由磁碟檔案儲存。• 實現在進程重啟、Pod 重新調度時的未發送遙測資料恢復。

5.2 批次層級設定(Batch-level Settings)

批次設定位於 batch 子區塊下。在預設情況下,batch 是關閉的;若要以預設值啟用批次,只需在 YAML 中宣告 batch: {} 或 batch: enabled: true。

欄位名稱 型別 預設值 合法選項 / 範圍 底層對應 Go Struct 行為解析與生產環境建議
enabled bool false true, false Config.Batch 是否啟用 Exporter 層級批次聚合。在 v0.171.0 之後大部分 Exporter 將預設改為 true。
sizer string 繼承 / items items, bytes BatchConfig.Sizer 批次聚合與切分的度量單位。• 不可設為 requests(批次本身就是要聚合 requests,故只能計量內容物)。• 繼承邏輯:若未特別指定,會自動繼承自外層隊列的 sizer;若外層隊列為 requests 或未指定,則預設回退為 items。
min_size int64 8192 非負整數 ($\ge 0$) BatchConfig.MinSize 觸發主動 Flush 的最小門檻。• 當累積中的批次大小(Items 數或 Bytes 數)達到或超過此數值時,立即觸發 flush() 並交由 workerPool 送出。• ⚠️ 若 batch.sizer 與外層隊列 sizer 相同,min_size 必須 $\le$ queue_size,否則會觸發 Config 驗證失敗。
max_size int64 0 (不限制) 非負整數 ($\ge 0$) BatchConfig.MaxSize Batch Splitting(批次切分上限):• 0:不限制最大大小,不進行切分。• > 0:啟用 MergeSplit 自動切分演算法。傳入的單一請求若超過此數值,會被強制拆分成多個小於等於 max_size 的獨立請求。• ⚠️ 若大於 0,必須滿足 max_size >= min_size。通常搭配 sizer: bytes 設為 4194304(4MB)以防禦 gRPC 接收上限。
flush_timeout duration 200ms 大於 0 的時間長度 BatchConfig.FlushTimeout 最長延遲等待時間。• 即使批次累積的資料量尚未達到 min_size,一旦自上一批發送後經過此時間長度,定時器(time.Timer)將強制觸發 Flush,保證遙測數據的即時性,避免低流量時資料卡在記憶體。

5.3 多租戶分區設定(Partition-level Settings)

多租戶分區位於 batch.partition 子區塊下。在官方 README(Commit 302c933)與架構設計中,partition 專門解決不同 Tenant、不同 Auth Token 或不同路由上下文的數據隔離打包問題。若未配置分區,所有經過該 Exporter 的資料都會混雜在同一個 Batch 中發送;而當啟用分區時,系統會為每個獨立的標頭特徵組合維護專屬的批次器。

官方參數詳解表(對齊 ExporterHelper README 規範)

欄位名稱 型別 預設值 合法選項 / 範圍 底層對應 Go Struct 官方規範行為與實戰注意事項
metadata_keys []string [] (空列表) 字串陣列 PartitionConfig.MetadataKeys 用於切分獨立批次的 Client Metadata 鍵名列表(如 ["X-Tenant-ID", "x-auth-token"])。• 預設值:為空。為空時全局僅使用單一 Batcher 實例,所有請求共用聚合。• Case-insensitive(大小寫不敏感):設定 X-Tenant 或 x-tenant 均等價。• 重複檢查:若陣列中存在重複鍵名(不分大小寫),啟動驗證時直接拒絕並拋錯:duplicate entry in metadata_keys。• 未設 vs 空值的獨立性(關鍵邊界條件):官方規範明訂 Empty value(值為空字串 "")與 Unset metadata(完全未傳入該標頭)被視為兩種完全獨立的 Distinct Cases,絕不會混在同一批次中發送!
cache_size int 10000 大於 0 的整數 PartitionConfig.CacheSize LRU 快取保留的最大活躍分區 Batcher 實例數。• 必須為正整數。• 淘汰與 Flush 機制:當活躍租戶分區數超過此上限時,LRU 自動淘汰 LRU (Least Recently Used) 分區。淘汰時會立刻觸發該分區的非同步 Flush,確保資料安全送出,絕不發生記憶體遺失。• 專用觀測指標:當前活躍分區數與配置容量會透過以下兩個 Prometheus 指標導出:- otelcol_exporter_queue_batch_partition_cache_size- otelcol_exporter_queue_batch_partition_cache_capacity
idle_timeout duration 90s 大於 0 的時間長度 PartitionConfig.IdleTimeout 分區閒置超時自動清理週期。• 必須為正時間長度。• 防止 Partition Churning:官方特別警告,此數值必須設定高於上游 Data arrival interval(資料到達間隔)!例如在 Prometheus 拉取架構中,常見的 Scrape 間隔為 15s~60s;若將 idle_timeout 設為 10s,會導致分區在兩次 Scrape 之間被銷毀,下一個週期又重新創建,引發高頻的物件配置與 Partition Churning。預設 90s 是安全跨越 60s 抓取週期的黃金數值。

核心機制深究:上下文合併與傳遞(NewMetadataKeysMergeCtx)

在同一個分區內,多筆請求會被聚合成一個大的 request.Request,那最終送往後端的 Context(帶有的 HTTP 標頭)該以誰為準?

在 metadata_partitioner.go 原始碼中,設計了專屬的 Context 合併函式:

func NewMetadataKeysMergeCtx(keys []string) func(context.Context, context.Context) context.Context {
	if len(keys) == 0 {
		return nil
	}
	return func(ctx1, _ context.Context) context.Context {
		m1 := client.FromContext(ctx1).Metadata
		m := make(map[string][]string, len(keys))
		for _, key := range keys {
			v1 := m1.Get(key)
			if len(v1) > 0 {
				m[key] = v1
			}
		}
		// 僅提取 metadata_keys 指定的標頭生成全新的乾淨 Context,防止污染!
		return client.NewContext(
			context.Background(),
			client.Info{Metadata: client.NewMetadata(m)},
		)
	}
}
  • 安全性保證:只向下游 Exporter 傳遞定義在 metadata_keys 內的標頭,既保證了下游 Exporter 能拿到正確的 Tenant ID或授權 Token,又杜絕了不同請求間非關鍵私人 Header 的交互洩漏。

多租戶分區生產配置範例

exporters:
  otlp/multi_tenant:
    endpoint: tempo.internal:4317
    sending_queue:
      enabled: true
      sizer: items
      queue_size: 200000
      batch:
        enabled: true
        sizer: items
        min_size: 5000
        flush_timeout: 200ms
        partition:
          metadata_keys:
            - X-Tenant-ID         # 依據客戶端租戶 ID 切分批次
            - authorization       # 依據授權 Token 切分批次
          cache_size: 2000        # 保留最多 2000 個活躍租戶分區
          idle_timeout: 120s      # 2 分鐘無資料自動清理釋放記憶體

實戰 Sample:metadata_keys 從網路封包到記憶體分區的端到端範例

許多人看到 metadata_keys 常會疑惑:「這些 keys 到底是從哪裡來的?在 HTTP 或 gRPC 封包裡到底長什麼樣子?Collector 又是怎麼把它們拆成不同 Batch 的?」

以下透過一個完整的電商多租戶與多環境情境,展示資料從客戶端送出到 Collector 內部切分的全過程:

步驟 1:客戶端實際送出的 HTTP / gRPC 封包長相

假設有三間不同租戶的微服務,同時向 Collector(0.0.0.0:4318)發送 Traces:

  • 客戶端 1(租戶 Alpha / 生產環境)發出的 HTTP Request:

    POST /v1/traces HTTP/1.1
    Host: otel-collector:4318
    Content-Type: application/x-protobuf
    X-Tenant-ID: tenant-alpha
    X-Environment: production
    Authorization: Bearer alpha-secret-token
    
    [Protobuf 二進位資料: 包含 50 個 Spans]
    
  • 客戶端 2(租戶 Beta / 測試環境)發出的 HTTP Request:

    POST /v1/traces HTTP/1.1
    Host: otel-collector:4318
    Content-Type: application/x-protobuf
    X-Tenant-ID: tenant-beta
    X-Environment: staging
    Authorization: Bearer beta-secret-token
    
    [Protobuf 二進位資料: 包含 120 個 Spans]
    
  • 客戶端 3(遺留舊服務 / 未帶租戶標頭)發出的 HTTP Request:

    POST /v1/traces HTTP/1.1
    Host: otel-collector:4318
    Content-Type: application/x-protobuf
    
    [Protobuf 二進位資料: 包含 20 個 Spans]
    

步驟 2:Receiver 端的必備配置(新手最常踩的大坑)

[!WARNING] 重要避坑:在 OpenTelemetry Collector 中,Receiver 預設不會把 HTTP Header 或 gRPC Metadata 傳遞到 Pipeline 的 Context 內!
若要讓 metadata_keys 生效,必須在 Receiver 明確宣告 include_metadata: true,否則 Context 永遠抓不到標頭!

receivers:
  otlp:
    protocols:
      http:
        endpoint: 0.0.0.0:4318
        include_metadata: true  # 🚨 關鍵!必須開啟才會將 HTTP Header 寫入 Context
      grpc:
        endpoint: 0.0.0.0:4317
        include_metadata: true  # 🚨 關鍵!必須開啟才會將 gRPC Metadata 寫入 Context

當設定了 include_metadata: true 後,Receiver 會呼叫 client.NewContext(ctx, client.Info{Metadata: ...}),將所有傳入的標頭封裝進 Go 的 Context 之中。


步驟 3:Exporter 中的 partition 設定

我們希望針對 X-Tenant-ID 與 X-Environment 兩個維度做隔離打包:

exporters:
  otlp/tempo:
    endpoint: tempo-distributor:4317
    sending_queue:
      enabled: true
      batch:
        enabled: true
        sizer: items
        min_size: 5000
        flush_timeout: 500ms
        partition:
          metadata_keys:
            - X-Tenant-ID
            - X-Environment
          cache_size: 1000
          idle_timeout: 90s

步驟 4:multiBatcher 記憶體內的二進位編碼與實例對照表

當上述三筆請求通過 Exporter 時,metadataKeysPartitioner.GetKey() 會透過長度前綴編碼生成分區 Hash Key,並在 LRU Cache 中維護完全隔離的 partitionBatcher 實例:

請求來源 提取到的 Client Metadata 底層 Varint 二進位編碼 Key(示意) 記憶體中對應的獨立實例 獨立累積狀態 (Items / Timer)
客戶端 1 X-Tenant-ID: tenant-alphaX-Environment: production \x01\x0c"tenant-alpha"\x01\x0a"production" partitionBatcher #1(專屬 Alpha 產線區) 目前累積 3,450 / 5,000 itemsTimer: 倒數 320ms
客戶端 2 X-Tenant-ID: tenant-betaX-Environment: staging \x01\x0b"tenant-beta"\x01\x07"staging" partitionBatcher #2(專屬 Beta 測試區) 目前累積 5,000 / 5,000 items💥 達到 min_size,觸發 Flush!
客戶端 3 未帶有任何租戶與環境標頭 (Unset) \x00\x00(鍵數量為 0) partitionBatcher #3(專屬 Unset 隔離區) 目前累積 20 / 5,000 itemsTimer: 倒數 480ms

隔離成效:

  1. 資料互不污染:租戶 Beta 的資料即便量大湊滿 5,000 筆觸發 Flush,也絕對不會把租戶 Alpha 累積中的 3,450 筆資料提前帶走。
  2. 下游精準授權與路由:當 partitionBatcher #2 觸發 Flush 時,藉由 NewMetadataKeysMergeCtx,送往下游 Tempo / Mimir 的 gRPC 呼叫中將精確帶上 X-Tenant-ID: tenant-beta 標頭,下游的分散式儲存即可直接依據此 Header 寫入對應租戶的隔離 Bucket!

5.4 欄位連動、繼承與驗證約束規則矩陣

Collector 在啟動階段會呼叫 Config.Validate()、BatchConfig.Validate() 與 PartitionConfig.Validate() 進行嚴格的靜態檢查。以下整理出所有隱含的約束與報錯訊息對應表:

驗證錯誤檢查清單(Validation Errors)

條件與規則 違規情境範例 Collector 啟動報錯訊息
batch.sizer 限制 在 batch.sizer 填寫了 requests `batch` supports only `items` or `bytes` sizer, found "requests"
容量覆蓋衝突 batch.sizer 與隊列 sizer 均為 items,但 min_size: 10000、queue_size: 5000 `min_size` must be less than or equal to `queue_size`
切分區間顛倒 設定了 min_size: 8192,但 max_size: 4000 `max_size` (4000) must be greater or equal to `min_size` (8192)
持久化與同步等待衝突 同時啟用了 storage: file_storage 與 wait_for_result: true `wait_for_result` is not supported with a persistent queue configured with `storage`
標頭鍵名重複 metadata_keys: ["X-Tenant", "x-tenant"] duplicate entry in metadata_keys: "x-tenant" (case-insensitive)
正數驗證 queue_size: 0 或 num_consumers: -1 `queue_size` must be positive / `num_consumers` must be positive
超時長度驗證 flush_timeout: 0s 或 idle_timeout: 0s `flush_timeout` must be positive / `idle_timeout` must be positive

6. 重大遷移陷阱與隊列容量計算:Queue capacity after migration

這是所有 SRE 與平台工程師在 2026~2027 年升級 OTel Collector 時最容易踩中的致命雷區。官方在公告中特別以專門章節《Queue capacity after migration》提出嚴正警告:將批次處理移入 sending_queue,同時也將批次移到了隊列的「另一側(the other side of the queue)」!


6.1 批次位置轉移引發的容量突變 (The Other Side of the Queue)

在舊版典型的 batch processor 管線中,Exporter 的 sending_queue 出現在 batch processor 之後:

  • 舊隊列中存放的單位:已經由 batch processor 打包好的大型 Requests(例如每包 8,192 items)。預設 queue_size: 1000 可以容納高達 800 萬筆遙測資料: 舊隊列容量舊隊列容量=1000×8192=8,192,000 Spans
  • 新隊列中存放的單位:來自應用端微服務或上游 SDK 直接發來的未批次原始請求(通常只有 50~100 items)。
  • 災難場景:如果你在遷移時只是「移除 batch processor」並「在 Exporter 啟用 batch: enabled: true」,卻直接繼承了預設的 queue_size: 1000(且預設 sizer: requests): 新隊列容量新隊列容量=1000×100=100,000 Spans 整體隊列可容納的資料總量暴跌了 98.7%! 相同的 queue_size: 1000 在以 requests 計算時,實際緩衝的資料量少得可憐。一旦下游後端出現幾秒鐘的網路微抖動,隊列瞬間被打滿;若 block_on_overflow: false(預設),Collector 就會直接開始瘋狂丟包,產生大量的 otelcol_exporter_enqueue_failed_requests 報錯!

6.2 記憶體隊列的容量計算公式:確保 Workers 飽和與容錯緩衝

官方明確指出,在遷移至 sending_queue 批次時,必須考慮提高 queue_size 來容納合適數量的未批次請求。對於 In-memory Queue 場景,容量規劃應遵循兩大核心法則:

  1. 維持 Worker 飽和(Keep num_consumers Busy): 隊列中緩衝的未批次資料量,理想上至少要能讓所有 num_consumers 都能持續滿載並發送批次: 最低工作水位最低工作水位≥num_consumers×min_size
  2. 吸收緩衝(Survive Intermittent Export Failures): 取決於上游呼叫者是否設定阻塞等待(block_on_overflow),必須在最低水位之上額外分配隊列空間,用以在下游網路抖動、短暫超時或重試期間暫存新進資料,避免請求直接被拒絕或卡死上游。

6.3 官方實戰範例一:Items 維度與並發容量演算

當 queue_size 改用 items 計量時,請求本身的批次大小就不再造成容量波動。以 Items 或 Bytes 計算的隊列,能讓 sending_queue 的記憶體成本完全透明化(Explicit Memory Costs),不再像 Requests 計量那樣忽大忽小、不可預測:

exporters:
  <exporter>:
    sending_queue:
      sizer: items            # 佇列以 items 計量,不再以 requests 次數計算
      queue_size: 100_000      # 總容量 10 萬個 items (約可容納 50~100 個 SDK 批次)
      num_consumers: 10       # 10 個併發 Worker 負責導出批次
      batch:
        enabled: true
        sizer: items          # 導出批次以 items 計量
        min_size: 5_000       # 湊滿 5,000 items 立即導出

官方數值模型拆解:

  • Active Exporting 水位:10 個 consumers $\times$ 5,000 items/batch = 50,000 items。這保證了當系統滿載時,10 個工作線程能同時並行發送至少 50,000 筆資料。
  • Pending Buffer 水位:隊列總容量為 100,000 items,扣除 10 個 Worker 正在發送的 50,000 items,額外預留了 50,000 items 的安全空間,允許新進資料進入隊列等待發送,而不會觸發 Block 或 Enqueue Drop。

6.4 官方實戰範例二:Bytes 維度與後端訊息上限嚴格對齊

使用 bytes 的好處是,當下游後端(如 Jaeger、Tempo、Kafka、雲端可觀測性後端)的接收限制也是以位元組為單位時,能夠完美貼合限制。透過設定 max_size,可以保證每一筆送出的網路請求都絕不超過後端配額:

exporters:
  <exporter>:
    sending_queue:
      sizer: bytes            # 佇列以 bytes 為單位計量
      queue_size: 100_000_000 # 嚴格緩衝 100 MB (100 MB buffered)
      batch:
        enabled: true
        min_size: 1_000_000   # 累積到 1 MB 才送出批次 (1 MB batches)
        max_size: 10_000_000  # 嚴格限制在 10 MB 以內,保持在後端訊息上限之下

架構價值:

  1. 記憶體可預期性:queue_size: 100_000_000 讓 SRE 能在 Kubernetes Pod 的 Request/Limit 中精準預留出 100MB 專用隊列記憶體,杜絕因為流量突增導致記憶體不可控膨脹。
  2. 雙邊邊界防禦:min_size: 1MB 保證網路封包具備足夠的高壓縮比;max_size: 10MB 透過 MergeSplit 自動將超大遙測資料在出列時平滑切分,徹底杜絕 gRPC 4MB 或後端 HTTP Message Limit 造成的拒絕錯誤。

6.5 Sizer 選擇、CPU 開銷代價與核心監控指標對齊

使用 items 或 bytes 雖然帶來了記憶體與訊息大小的確定性,但天下沒有白吃的午餐——items 與 bytes 的計算都需要消耗比 requests 更多的 CPU:

  • requests 是最輕量的(僅做 atomic 整數計數)。
  • bytes 是最昂貴的,因為它必須計算甚至模擬 Protobuf Encoded Size。
Sizer 類型 計算標的 記憶體預測度 CPU 額外開銷 效能與原理分析
requests HTTP/gRPC 請求批次數 低(請求大則佔記憶體) 幾乎為 0(極快) 僅作整數計數,最高效能,但無法防止單一超大 Request 撐爆隊列。
items Spans / Points / Logs 數 中(物件數量固定) 低(輕微遍歷) 遍歷 Resource/Scope 結構計數,CPU 開銷極低,兼顧保護與效能。
bytes Protobuf 序列化後位元組 極高(精準防禦) 最高(最昂貴) 必須動態計算資料的 Protobuf 編碼體積,在高吞吐流量下會顯著消耗 CPU。

上線後的關鍵監控(Production Observability)

切換至 Exporter-Level 批次之後,維運團隊必須立即在 Prometheus / Grafana 中持續監控 otelcol_exporter_queue_size 並與 otelcol_exporter_queue_capacity 進行對比:

$$\text{隊列使用率 (%)} = \frac{\text{otelcol_exporter_queue_size}}{\text{otelcol_exporter_queue_capacity}} \times 100%$$

  • otelcol_exporter_queue_size:目前暫存在隊列中的資料量(單位自動對齊所選的 sizer)。
  • otelcol_exporter_queue_capacity:設定的隊列容量上限。
  • 告警黃金律:若使用率長期超過 80%,或者 rate(otelcol_exporter_enqueue_failed_requests[5m]) > 0,代表隊列深度不足或消費者數量過少,必須依上述公式調高 queue_size 或 num_consumers!

7. 配置遷移指南與實戰範例

路徑 A:推薦標準做法 —— Exporter-Level Batching

這是官方最強烈推薦的主流做法。適用於單一 Pipeline 或各 Exporter 有獨立後端配置的場景。

receivers:
  otlp:
    protocols:
      grpc:
        endpoint: 0.0.0.0:4317
      http:
        endpoint: 0.0.0.0:4318

processors:
  # ⚠️ batch processor 已在此完全移除!
  memory_limiter:
    check_interval: 1s
    limit_percentage: 75
    spike_limit_percentage: 20

exporters:
  otlp:
    endpoint: tempo.monitoring.svc.cluster.local:4317
    tls:
      insecure: true
    sending_queue:
      enabled: true
      sizer: items
      queue_size: 200000        # 依 items 計算佇列長度
      num_consumers: 16         # 併發發送的 Worker 數量
      block_on_overflow: false  # 若佇列已滿是否阻塞(false 代表立即拋回錯誤觸發背壓)
      batch:
        enabled: true
        sizer: items
        min_size: 8192          # 湊滿 8192 items 發送
        max_size: 16384         # 超過 16384 自動切分
        flush_timeout: 200ms    # 最長等待時間

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [memory_limiter] # 乾淨俐落,無 batch
      exporters: [otlp]

進階配置:Persistent Queue(磁碟持久化隊列)

若對資料可靠度有嚴苛要求(不容許 Collector 重啟遺失資料):

extensions:
  file_storage/traces:
    directory: /var/lib/otelcol/buffer
    timeout: 10s

exporters:
  otlp:
    endpoint: tempo.monitoring.svc.cluster.local:4317
    sending_queue:
      enabled: true
      storage: file_storage/traces # 掛載磁碟持久化擴充
      queue_size: 50000            # 持久化於磁碟的請求數
      batch:
        enabled: true
        min_size: 8192
        flush_timeout: 500ms

service:
  extensions: [file_storage/traces]
  pipelines:
    traces:
      receivers: [otlp]
      exporters: [otlp]

路徑 B:特定場景救星 —— queue_batch Processor

官方也清楚意識到:並非所有情況都適合在 Exporter 才批次!有兩種特殊情境仍然需要在 Processor 階段進行批次處理:

  1. 聚合型 Processor 的前置依賴:例如你在 Pipeline 中使用了 spanmetrics processor、metricsgeneration 或日誌滾動聚合元件,這些 Processor 在面對「單一大批次」時的效能與計算效率,遠遠高於處理數百個零散小請求。
  2. 多重 Exporter Fan-out:如果一條 Pipeline 同時分流發送給 5 個不同的 Exporters,在 Pipeline 階段先 Batch 一次,可以省下 5 個 Exporter 各自重複消耗記憶體進行批次聚合的開銷。

針對這些場景,OTel 推出全新的 queue_batch processor(底層完全複用 queuebatch 引擎):

processors:
  # 使用全新的 queue_batch 替代已棄用的 batch
  queue_batch:
    sizer: requests
    queue_size: 50
    block_on_overflow: true    # 隊列滿時阻塞上游
    wait_for_result: false
    num_consumers: 1           # 保持單一輸出至下游 processor
    batch:
      enabled: true
      sizer: items
      min_size: 8192
      flush_timeout: 200ms

  spanmetrics:                 # 依賴大批次以提升聚合效能
    aggregation_temporality: "AGGREGATION_TEMPORALITY_CUMULATIVE"

exporters:
  otlp/tempo:
    endpoint: tempo:4317
    # 這裡的 sending_queue 就不需要再開 batch: true 了
  otlp/s3:
    endpoint: s3-exporter:4317

service:
  pipelines:
    traces:
      receivers: [otlp]
      processors: [queue_batch, spanmetrics]
      exporters: [otlp/tempo, otlp/s3]

8. 升級時程表、Feature Flag 與 SRE 檢核表

為了避免在升級至 2027 年的新版本時措手不及,建議團隊自即日起啟動升級準備:

啟用 Feature Flag 提前驗證

在當前版本的 Collector 中,你可以透過啟動參數開啟 Feature Flag 測試新版預設行為:

otelcol --config=config.yaml --feature-gates=pkg.exporterhelper.queueBatchEnabled

關鍵監控指標異動表(Alerting & Dashboard 更新)

在升級儀表板與告警規則時,請務必切換指標監控路徑:

監控面向 舊版指標(即將廢除) 新版指標(即刻生效) 說明
佇列目前堆積量 otelcol_processor_batch_batch_send_size_count otelcol_exporter_queue_size 目前佇列內存放的單位數
佇列最大容量 N/A otelcol_exporter_queue_capacity 設定的佇列上限(可用來算使用率 %)
入隊丟包/失敗數 N/A otelcol_exporter_enqueue_failed_requests 核心告警指標!大於 0 表示隊列爆滿丟包
發送超時觸發計數 otelcol_processor_batch_timeout_trigger_send otelcol_exporter_queue_batch_send (label trigger=timeout) 批次因時間到期而發送的次數
容量滿足觸發計數 otelcol_processor_batch_batch_size_trigger_send otelcol_exporter_queue_batch_send (label trigger=size) 批次湊滿門檻發送的次數
分區快取使用量 otelcol_processor_batch_metadata_cardinality otelcol_exporter_queue_batch_partition_cache_size 多租戶 LRU Cache 使用分區數量

SRE Migration Checklist

  • [ ] 盤點現有 Pipeline:搜尋所有 Collector 配置檔案中的 processors.batch。
  • [ ] 評估批次位置:
    • 85% 以上情況:直接移至各 Exporter 的 sending_queue.batch。
    • 特殊情境(有 spanmetrics 或複雜 Fan-out):更換為 processors.queue_batch。
  • [ ] 校準 Queue 容量:
    • 是否盲目維持 queue_size: 1000?如果是,務必改成 sizer: items 並評估合適容量(如 200000),避免隊列瞬間萎縮。
  • [ ] 檢驗 Payload 限制:
    • 後端是否有 4MB gRPC 限制?評估將 batch.sizer 設為 bytes 並配置 max_size: 4194304。
  • [ ] 更新 Prometheus 告警:
    • 廢除舊的 processor_batch_* 規則。
    • 新增 rate(otelcol_exporter_enqueue_failed_requests[5m]) > 0 告警。
  • [ ] Canary 灰度發布:先在一組邊緣 Agent 或非關鍵日誌叢集開啟 Feature Flag 觀察 48 小時記憶體與佇列水位。

9. 結語:觀測管線邁向工程化成熟期

batch processor 的淡出,象徵著 OpenTelemetry Collector 從「能用就好」的原型時代,正式跨入「大規模工業級分散式架構」的成熟階段。

過去,觀測管線往往在流量激增時成為系統中最脆弱的一環——記憶體不可控、背壓傳不回去、下游抖動引發本機 OOM。透過將 Queue、Backpressure、Byte Sizing 與 Storage Extension 在 Exporter 層級形成原生閉環,Collector 終於擁有了能夠承載關鍵金融交易與超大規模雲原生負載的可靠底座。

不要等到 2027 年 v0.177.0 被強迫升級破壞配置,今天就動手檢視並重構你的 Collector 管線吧!


10. 參考資料與延伸閱讀 (References)

官方遷移公告與部落格文章

  1. OpenTelemetry Official Blog
    Deprecating the Collector Batch Processor
    作者:Bogdan Drutu, Dmitry Anoshin, Joshua MacDonald(發佈於 2026 年 11 月 11 日)。詳細闡述了自 2019 年以來 batch processor 的歷史沿革、社群對三大瓶頸(Byte size 限制、Concurrency 控制、Back-pressure 與持久化)的反饋,以及 Exporter-level batching 的官方遷移路徑。
  2. 本地備份網頁 (Local Web Archive)
    [Deprecating the Collector Batch Processor _ OpenTelemetry.html](file:///home/nathan/Project/o11y_lab_for_dummies/docs/Deprecating%20the%20Collector%20Batch%20Processor%20_%20OpenTelemetry.html)
    提供完整的官方部落格文章離線存檔與圖文對照。

核心規格與 ExporterHelper 文件

  1. ExporterHelper Sending Queue & Batch Settings (Git Commit 302c933)
    open-telemetry/opentelemetry-collector - exporterhelper/README.md#sending-queue-batch-settings
    詳細定義了 sending_queue 與 batch 的官方設定規範,包含 sizer(requests/items/bytes)、min_size、max_size、block_on_overflow、wait_for_result 以及分區(Partitioning)參數。
  2. ExporterHelper 最新官方技術文件
    open-telemetry/opentelemetry-collector - exporter/exporterhelper/README.md
    涵蓋 Persistent Queue(file_storage)、Context 傳遞限制、重試機制與消費端模型架構圖。

原始碼實作與架構分析 (Source Code Repositories)

  1. 全新 Exporter 批次與佇列整合核心模組
    open-telemetry/opentelemetry-collector - exporter/exporterhelper/internal/queuebatch
    關鍵實作檔案導覽:
    • queue_batch.go:佇列與批次器整合入口、生命週期啟動與關閉、約束非分區情況下隊列消費者為 1。
    • batcher.go:Batcher 介面定義與分發工廠(建立 shardedBatcher 或 multiBatcher)。
    • partition_batcher.go:單一分區狀態機、consumeInternal、flush、workerPool 以及 multiDone / refCountDone 呼叫追蹤。
    • multi_batcher.go:基於 HashiCorp SimpleLRU 的多租戶快取控制,結合 idle_timeout 與驅逐自動 Flush。
    • metadata_partitioner.go:Varint 長度前綴二進位編碼分區器。
    • traces_batch.go、logs_batch.go、metrics_batch.go:MergeSplit 演算法與三種遙測訊號的位元組 / 項目數計算。
  2. 即將被棄用的舊版 Batch Processor 原始碼
    open-telemetry/opentelemetry-collector - processor/batchprocessor
    可供對照分析 singleShardBatcher、單一 startLoop goroutine 以及 channel 阻塞與錯誤吞噬行為。

關鍵 Issue、PR 與設計提案 (Issues & RFCs)

  1. Issue #12473: queue_batch keeps consumers to 1 for unpartitioned batching
    探討無分區情況下為何將佇列消費者強制約束為 1,並交由 Batcher 內部 Worker Pool 併發發送的併發架構決策。
  2. Issue #8122: Exporter helper Request interface and MergeSplit design
    統一 Request 模型與跨訊號 MergeSplit(Traces/Metrics/Logs 合併與切分)的基礎架構設計。
  3. [**Pul

圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言