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

## 1\. 前言：伴隨 OTel 六年的老牌元件為何退場？

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

```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](https://opentelemetry.io/blog/2026/collector-batching-migration/) 的重大架構變更公告：

> **「我們正式宣告棄用** `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` 的原始碼，看看它在現代高吞吐雲原生環境下撞上了哪些物理極限。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/e7a71791-a861-483a-8b64-85dd54fadf35.png align="center")

---

### 缺陷 1：只認 Item Count，對實際 Byte Size 毫無視覺

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

```yaml
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}$！

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/1e180d30-2ce6-46e8-98af-034200eb5ec4.png align="center")

這會直接引爆兩大問題：

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 處理機制：

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

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

```go
func (bp *batchProcessor[T]) newShard(...) *shard[T] {
	return &shard[T]{
		processor: bp,
		newItem:   make(chan T, runtime.NumCPU()), // 緩衝區極小（僅 CPU 核心數）
		batch:     bp.batchFunc(),
	}
}
```

```go
// 舊版 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()
		}
	}
}
```

#### 瓶頸連鎖反應圖解：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/238444d7-ada5-4997-9ee0-82b86af2c0f0.png align="center")

#### 瓶頸分析：

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` 函式的回傳值：

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

而在非同步發送的 `sendItems` 裡：

```go
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，錯誤直接被吞噬！
	}
}
```

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

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/0a45ad09-df89-4c57-b4b2-9fa009bf04b9.png align="center")

#### 執行期真實互動時序圖（靜默丟包過程）：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/b711fd71-813c-48ce-ad82-af4abb0e8766.png align="center")

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 架構團隊做了一次大膽的結構重組：**將佇列與批次合二為一，並將批次處理的時機點往後推移到佇列之後！**

### 新舊架構資料流對比

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/9807877e-98e7-4841-9303-cbae9c484bbf.png align="center")

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

| 比較維度 | 舊版 `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）」** 落地：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/72089436-c5c6-4d18-a540-23d53e8b0137.png align="center")

- **邊緣層（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\. 修復舊架構「假持久化」的歷史原罪

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

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/181e91f5-5846-499b-8115-e40001f3b87f.png align="center")

- **舊架構的欺騙性**：舊版 `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`）：

```yaml
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`：

```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` 實作：

```go
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` 組裝成一個統一的結構體：

```go
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 耗盡。

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/d164098e-bd11-4263-99b7-2346ddc63580.png align="center")

---

#### `partitionBatcher` 內部狀態機流轉機制

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

```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`。其狀態流轉決策如下圖所示：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/8487b76b-c86b-46f1-a306-d674468f19ec.png align="center")

#### 核心源碼邏輯對照：

```go
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` 取得執行配額：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/fd29a594-addf-47cc-aed1-fff6675cac1e.png align="center")

```go
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 發送：

```go
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 個小批次發送：

```go
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，杜絕了字串拼接可能帶來的碰撞注入：

```go
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` 控制記憶體上限：

```go
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）

```yaml
# 適用於 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` **的計量單位**：<br>• `requests`：以進來的 Request 批次數計數（吞吐最高、CPU 消耗近乎 0，但無法防範單一超大 Request 撐爆記憶體）。<br>• `items`：以最小遙測單元（Spans/Points/Logs）計數。<br>• `bytes`：以序列化後的 Protobuf 位元組大小計數（記憶體控制最精確，但需負擔序列化 CPU 計算）。 |
| `queue_size` | `int64` | `1000` | 大於 0 的整數 | `Config.QueueSize` | **隊列的最大容納上限**，數值單位由上述 `sizer` 決定。<br>• 若搭配持久化儲存（`storage`），代表磁碟緩衝區允許容納的請求上限。<br>• ⚠️ 遷移時若將 batch 移入 exporter，請務必重新評估此數值（詳見第六章）。 |
| `num_consumers` | `int` | `10` | 大於 0 的整數 | `Config.NumConsumers` | **佇列的最大並發消費數**。<br>• **重要原始碼行為**：當啟用批次（`batch`）且未設定分區時，底層會將隊列消費者強制設為 1，而將 `num_consumers` 的數值指派給 `workerPool` 作為最終發送網路請求的並行 Worker 數，避免多消費者組裝批次時產生資源爭搶。 |
| `block_on_overflow` | `bool` | `false` | `true`, `false` | `Config.BlockOnOverflow` | **隊列滿載（達到** `queue_size`**）時的溢位背壓策略**：<br>• `false`（預設，Fail-fast）：立即拒絕請求並拋出可重試錯誤，使 Receiver 快速向 Client 回傳 HTTP 429/503。<br>• `true`（Block on deadline）：阻塞呼叫端 goroutine，直到隊列釋出空位或 Context Deadline 逾時。 |
| `wait_for_result` | `bool` | `false` | `true`, `false` | `Config.WaitForResult` | **端到端確認機制**：<br>• `false`（預設）：資料成功進入隊列就立即向呼叫端回傳成功（非同步入隊）。<br>• `true`：呼叫端會**持續阻塞等待**，直到資料真正被消費者取出、完成批次聚合並經由網路成功送到後端（或遭遇永久性失敗）。適合要求嚴格 At-least-once 交付的金融交易級管線。<br>⚠️ **限制**：無法與持久化儲存 `storage` 同時開啟。 |
| `storage` | `string` | `none` (空) | 任何合法的 Storage 擴充 ID | `Config.StorageID` | 指定持久化儲存擴充元件（例如 `file_storage/persistent`）。<br>• 設定後將**完全停用 Go 記憶體隊列**，改由磁碟檔案儲存。<br>• 實現在進程重啟、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` | **批次聚合與切分的度量單位**。<br>• **不可設為** `requests`（批次本身就是要聚合 requests，故只能計量內容物）。<br>• **繼承邏輯**：若未特別指定，會自動繼承自外層隊列的 `sizer`；若外層隊列為 `requests` 或未指定，則預設回退為 `items`。 |
| `min_size` | `int64` | `8192` | 非負整數 ($\ge 0$) | `BatchConfig.MinSize` | **觸發主動 Flush 的最小門檻**。<br>• 當累積中的批次大小（Items 數或 Bytes 數）達到或超過此數值時，立即觸發 `flush()` 並交由 `workerPool` 送出。<br>• ⚠️ 若 `batch.sizer` 與外層隊列 `sizer` 相同，`min_size` 必須 $\le$ `queue_size`，否則會觸發 Config 驗證失敗。 |
| `max_size` | `int64` | `0` (不限制) | 非負整數 ($\ge 0$) | `BatchConfig.MaxSize` | **Batch Splitting（批次切分上限）**：<br>• `0`：不限制最大大小，不進行切分。<br>• `> 0`：啟用 `MergeSplit` 自動切分演算法。傳入的單一請求若超過此數值，會被強制拆分成多個小於等於 `max_size` 的獨立請求。<br>• ⚠️ 若大於 0，必須滿足 `max_size >= min_size`。通常搭配 `sizer: bytes` 設為 `4194304`（4MB）以防禦 gRPC 接收上限。 |
| `flush_timeout` | `duration` | `200ms` | 大於 0 的時間長度 | `BatchConfig.FlushTimeout` | **最長延遲等待時間**。<br>• 即使批次累積的資料量尚未達到 `min_size`，一旦自上一批發送後經過此時間長度，定時器（`time.Timer`）將強制觸發 Flush，保證遙測數據的即時性，避免低流量時資料卡在記憶體。 |

---

### 5.3 多租戶分區設定（Partition-level Settings）

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

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/bea6275b-6d58-4e79-8308-0ff1bf9f1d51.png align="center")

#### 官方參數詳解表（對齊 ExporterHelper README 規範）

| 欄位名稱 | 型別 | 預設值 | 合法選項 / 範圍 | 底層對應 Go Struct | 官方規範行為與實戰注意事項 |
| :-- | :-- | :-- | :-- | :-- | :-- |
| `metadata_keys` | `[]string` | `[]` (空列表) | 字串陣列 | `PartitionConfig.MetadataKeys` | **用於切分獨立批次的 Client Metadata 鍵名列表**（如 `["X-Tenant-ID", "x-auth-token"]`）。<br>• **預設值**：為空。為空時全局僅使用單一 Batcher 實例，所有請求共用聚合。<br>• **Case-insensitive（大小寫不敏感）**：設定 `X-Tenant` 或 `x-tenant` 均等價。<br>• **重複檢查**：若陣列中存在重複鍵名（不分大小寫），啟動驗證時直接拒絕並拋錯：`duplicate entry in metadata_keys`。<br>• **未設 vs 空值的獨立性（關鍵邊界條件）**：官方規範明訂 **Empty value（值為空字串** `""`**）與 Unset metadata（完全未傳入該標頭）被視為兩種完全獨立的 Distinct Cases**，絕不會混在同一批次中發送！ |
| `cache_size` | `int` | `10000` | 大於 0 的整數 | `PartitionConfig.CacheSize` | **LRU 快取保留的最大活躍分區 Batcher 實例數**。<br>• 必須為正整數。<br>• **淘汰與 Flush 機制**：當活躍租戶分區數超過此上限時，LRU 自動淘汰 LRU (Least Recently Used) 分區。淘汰時會**立刻觸發該分區的非同步 Flush，確保資料安全送出，絕不發生記憶體遺失**。<br>• **專用觀測指標**：當前活躍分區數與配置容量會透過以下兩個 Prometheus 指標導出：<br>\- `otelcol_exporter_queue_batch_partition_cache_size`<br>\- `otelcol_exporter_queue_batch_partition_cache_capacity` |
| `idle_timeout` | `duration` | `90s` | 大於 0 的時間長度 | `PartitionConfig.IdleTimeout` | **分區閒置超時自動清理週期**。<br>• 必須為正時間長度。<br>• **防止 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 合併函式：

```go
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 的交互洩漏。

---

#### 多租戶分區生產配置範例

```yaml
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**：

  ```http
  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**：

  ```http
  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**：

  ```http
  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 永遠抓不到標頭！

```yaml
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` 兩個維度做隔離打包：

```yaml
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-alpha`<br>`X-Environment: production` | `\x01\x0c"tenant-alpha"\x01\x0a"production"` | `partitionBatcher #1`<br>(專屬 Alpha 產線區) | 目前累積 3,450 / 5,000 items<br>Timer: 倒數 320ms |
| **客戶端 2** | `X-Tenant-ID: tenant-beta`<br>`X-Environment: staging` | `\x01\x0b"tenant-beta"\x01\x07"staging"` | `partitionBatcher #2`<br>(專屬 Beta 測試區) | 目前累積 5,000 / 5,000 items<br>💥 **達到 min_size，觸發 Flush！** |
| **客戶端 3** | 未帶有任何租戶與環境標頭 (Unset) | `\x00\x00`（鍵數量為 0） | `partitionBatcher #3`<br>(專屬 Unset 隔離區) | 目前累積 20 / 5,000 items<br>Timer: 倒數 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()` 進行嚴格的靜態檢查。以下整理出所有隱含的約束與報錯訊息對應表：

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/775fd673-8e48-4265-85ef-0f02e800c547.png align="center")

#### 驗證錯誤檢查清單（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）」！**

![](https://cdn.hashnode.com/uploads/covers/6420f5cbbdbe7d697133d12a/af591394-a2ef-4c65-a539-a6d7b2718173.png align="center")

---

### 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 計量那樣忽大忽小、不可預測：

```yaml
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`，可以保證**每一筆送出的網路請求都絕不超過後端配額**：

```yaml
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 有獨立後端配置的場景。

```yaml
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 重啟遺失資料）：

```yaml
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` 引擎）：

```yaml
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 測試新版預設行為：

```bash
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](https://opentelemetry.io/blog/2026/collector-batching-migration/)\
   *作者*：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 文件

3. **ExporterHelper Sending Queue & Batch Settings (Git Commit** `302c933`**)**\
   [open-telemetry/opentelemetry-collector - exporterhelper/README.md#sending-queue-batch-settings](https://github.com/open-telemetry/opentelemetry-collector/blob/302c933caa8c66f7ebf35ea42238b261bc3e5fc6/exporter/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）參數。
4. **ExporterHelper 最新官方技術文件**\
   [open-telemetry/opentelemetry-collector - exporter/exporterhelper/README.md](https://github.com/open-telemetry/opentelemetry-collector/blob/main/exporter/exporterhelper/README.md)\
   涵蓋 Persistent Queue（`file_storage`）、Context 傳遞限制、重試機制與消費端模型架構圖。

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

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

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

7. [**Issue #12473: queue_batch keeps consumers to 1 for unpartitioned batching**](https://github.com/open-telemetry/opentelemetry-collector/issues/12473)\
   探討無分區情況下為何將佇列消費者強制約束為 1，並交由 Batcher 內部 Worker Pool 併發發送的併發架構決策。
8. [**Issue #8122: Exporter helper Request interface and MergeSplit design**](https://github.com/open-telemetry/opentelemetry-collector/issues/8122)\
   統一 Request 模型與跨訊號 MergeSplit（Traces/Metrics/Logs 合併與切分）的基礎架構設計。
9. [**Pull Request #12641: Remove draft status from collector batching migration**](https://github.com/open-telemetry/opentelemetry-collector/pull/12641)\
   官方正式移除 Draft 狀態，確立 2026～2027 年的遷移藍圖與核心庫移除時程。
