第一章:Go语言精进之路紧急补漏:etcd v3.6+强制依赖Go 1.21+的底层动因与影响全景
etcd v3.6.0 起正式将 Go 1.21+ 设为构建与运行的硬性要求,这一变更并非偶然升级,而是源于 Go 运行时、标准库及安全模型的关键演进。最核心动因在于 Go 1.21 引入的 runtime/coverage 精确覆盖率支持 和 unsafe.Slice 的标准化替代 reflect.SliceHeader —— etcd v3.6+ 的单元测试覆盖率收集机制深度依赖前者,而其内存敏感的 WAL(Write-Ahead Log)序列化逻辑已全面迁移到后者以规避 go vet 在 Go 1.20 及更早版本中无法捕获的未定义行为。
此外,Go 1.21 对 net/http 的 TLS 1.3 默认启用与证书验证强化,直接支撑 etcd gRPC server 的 mTLS 安全通信基线;其 sync.Map 的性能优化也显著降低了 leader lease 检查路径的锁竞争开销。
验证本地环境是否兼容:
# 检查当前 Go 版本(必须 ≥1.21.0)
go version
# 若版本过低,推荐使用 goenv 或直接下载安装:
# https://go.dev/dl/go1.21.10.linux-amd64.tar.gz (根据平台选择)
# 构建 etcd v3.6+ 时,若仍用 Go 1.20 将触发明确错误:
# "build constraints exclude all Go files in ..."
常见影响场景包括:
- CI/CD 流水线中
golang:1.20镜像需同步升级至golang:1.21或更高; - Kubernetes 1.28+ 集成 etcd v3.6+ 时,节点上的
kubelet启动参数需确保--etcd-servers指向兼容端点; - 使用
go.etcd.io/etcd/client/v3的应用若未升级 Go 版本,go mod tidy将失败并提示etcd requires Go >= 1.21.
| 影响维度 | 具体表现 | 应对建议 |
|---|---|---|
| 构建失败 | go build 报错 “unsupported Go version” |
升级 Go 并清理 $GOCACHE |
| 运行时 panic | unsafe.Slice 替代失败导致 WAL 解析崩溃 |
确保无手动 unsafe 旧写法 |
| TLS 握手异常 | 客户端证书链验证失败(尤其自签名 CA) | 检查 ca.crt 是否含完整链 |
第二章:Go 1.21+核心演进与兼容性重构
2.1 Go 1.21新增runtime与调度器优化:抢占式调度增强与GC停顿收敛
Go 1.21 对 runtime 层进行了关键性调优,核心聚焦于抢占精度提升与GC STW 收敛性增强。
抢占式调度粒度细化
调度器 now uses finer-grained preemption points — especially inside tight loops and runtime syscalls. The runtime.preemptM mechanism triggers more reliably when goroutines exceed ~10ms CPU time (down from ~20ms in 1.20).
// 示例:显式让出控制权(非必需,但体现协作式辅助)
func busyLoop() {
for i := 0; i < 1e9; i++ {
if i%10000 == 0 {
runtime.Gosched() // 主动让渡,缓解抢占延迟
}
}
}
此代码虽非强制,但在长循环中插入
Gosched()可显式配合新抢占逻辑,减少因编译器未插入足够安全点导致的调度延迟。参数i%10000平衡开销与响应性。
GC 停顿行为收敛
GC 的 mark termination 阶段 STW 时间标准差显著降低:
| 版本 | 平均 STW (ms) | STW 波动 (σ) |
|---|---|---|
| Go 1.20 | 1.8 | ±0.9 |
| Go 1.21 | 1.7 | ±0.3 |
调度关键路径优化
graph TD
A[goroutine 执行] --> B{CPU 时间 ≥10ms?}
B -->|是| C[触发异步抢占信号]
B -->|否| D[继续执行]
C --> E[转入 scheduler 队列]
E --> F[快速重调度]
- 新增
preemptible标志位在g结构体中,由m定期轮询 - GC mark assist 现采用更平滑的增量权重分配,避免突发性辅助负担
2.2 modules依赖解析机制升级:go.mod语义版本校验与proxy缓存策略变更
语义版本严格校验增强
Go 1.18+ 强制要求 go.mod 中的模块版本必须符合 SemVer 2.0 规范(如 v1.2.3, v2.0.0+incompatible),非法格式(如 v1.2、1.2.3)将导致 go build 直接报错:
$ go build
go: github.com/example/lib@v1.2: invalid version: version must be of the form v1.2.3
Proxy 缓存行为变更
GOPROXY 默认启用 sum.golang.org 校验,且本地缓存($GOCACHE)不再存储未经 checksum 验证的模块包。
| 行为项 | Go 1.16 及之前 | Go 1.18+ |
|---|---|---|
| 版本格式校验 | 宽松(仅 warn) | 强制拒绝非法 SemVer |
| proxy 缓存写入 | 下载即缓存 | 仅当 go.sum 校验通过后缓存 |
校验流程图
graph TD
A[解析 go.mod] --> B{版本格式合法?}
B -->|否| C[终止构建]
B -->|是| D[向 GOPROXY 请求 module.zip + .info]
D --> E[比对 sum.golang.org 签名]
E -->|失败| F[拒绝缓存并报错]
E -->|成功| G[写入 $GOCACHE 并构建]
2.3 net/http与crypto/tls在1.21中的安全加固:ALPN协商、证书链验证与零日漏洞修复
Go 1.21 对 net/http 和 crypto/tls 进行了深度安全增强,聚焦协议健壮性与信任链完整性。
ALPN 协商默认强化
TLS 1.3 握手 now mandates ALPN extension presence for HTTP/2 and HTTP/3—no fallback to insecure plaintext upgrades.
证书链验证严格化
crypto/tls 现拒绝包含空 Subject 或无效 Basic Constraints 的中间证书:
cfg := &tls.Config{
VerifyPeerCertificate: func(rawCerts [][]byte, verifiedChains [][]*x509.Certificate) error {
for _, chain := range verifiedChains {
if len(chain) < 2 {
return errors.New("incomplete chain: missing intermediate")
}
if chain[0].Subject.Empty() {
return errors.New("leaf cert has empty Subject")
}
}
return nil
},
}
该钩子在标准验证后执行,强制链长 ≥2 且叶证书 Subject 非空,堵住伪造链绕过漏洞(CVE-2023-39325 修复路径之一)。
零日修复关键点
| 漏洞类型 | 补丁位置 | 影响范围 |
|---|---|---|
| ALPN空值解析缺陷 | crypto/tls/handshake.go |
TLS 1.2/1.3 客户端 |
| 双重释放证书缓存 | net/http/transport.go |
http.Transport 复用场景 |
graph TD
A[Client Hello] --> B{ALPN present?}
B -->|No| C[Abort handshake]
B -->|Yes| D[Validate ALPN list against server policy]
D --> E[Proceed with selected protocol]
2.4 etcd v3.6+对Go 1.21+的硬性依赖溯源:atomic.Value内存模型变更与unsafe.Pointer对齐约束收紧
atomic.Value 的语义强化
Go 1.21 将 atomic.Value 的底层实现从 unsafe.Pointer + sync/atomic 手动对齐,改为基于 runtime/internal/atomic 的严格顺序一致性模型。这导致 etcd v3.6+ 中依赖 atomic.Value.Store() 写入非对齐结构体指针的旧路径(如 raftpb.Entry 切片缓存)在 Go
unsafe.Pointer 对齐约束收紧
Go 1.21 强制要求 unsafe.Pointer 转换前的地址必须满足目标类型的自然对齐(如 int64 要求 8 字节对齐)。etcd 的 mvcc/backend 中 pageAlloc 初始化逻辑曾通过 unsafe.Offsetof 计算偏移并构造非对齐指针,在 Go 1.21+ 运行时 panic。
// etcd v3.5(不兼容 Go 1.21+)
var p unsafe.Pointer = unsafe.Pointer(&buf[0])
v := (*int64)(p) // 若 buf[0] 地址 % 8 != 0,则 Go 1.21+ panic
逻辑分析:该代码假设
buf起始地址天然对齐int64,但make([]byte, n)分配的底层数组无对齐保证。Go 1.21+ 的unsafe检查器在(*int64)(p)转换时执行运行时对齐校验,失败则fatal error: unsafe pointer conversion。
兼容性修复关键点
- etcd v3.6+ 使用
memalign或unsafe.AlignedLayout显式对齐缓冲区 atomic.Value改用sync/atomic.Load/StoreUintptr+ 手动类型转换替代原始Store(interface{})- 构建脚本强制检查
go version >= 1.21
| Go 版本 | atomic.Value 行为 | unsafe.Pointer 转换 | etcd 最低兼容版本 |
|---|---|---|---|
| ≤1.20 | 允许弱内存序写入 | 宽松对齐检查 | v3.5.x |
| ≥1.21 | 顺序一致 Store | 硬性对齐校验 | v3.6.0+ |
2.5 实战:跨版本迁移checklist——从Go 1.19/1.20平滑升级至1.21+的CI/CD流水线改造
关键变更前置校验
- 检查
GO111MODULE=on是否全局启用(Go 1.21 强制模块模式) - 确认
go.mod中go指令 ≥1.21,否则go build将报错 - 验证
GOROOT不指向旧版 SDK(CI 中建议显式export GOROOT=$(go env GOROOT))
CI 配置升级示例(GitHub Actions)
# .github/workflows/ci.yml
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/setup-go@v4
with:
go-version: '1.21' # 必须显式指定 ≥1.21
- run: go version # 输出验证
逻辑说明:
actions/setup-go@v4默认不降级,但若未指定go-version,可能复用缓存旧版;go version步骤用于阻断性验证,避免静默使用错误版本。
兼容性检查矩阵
| 检查项 | Go 1.19/1.20 | Go 1.21+ | 动作建议 |
|---|---|---|---|
unsafe.Slice |
❌ | ✅ | 替换 reflect.SliceHeader |
net/http.ServeMux |
✅(无警告) | ✅(新增 ServeHTTP 方法) |
无需修改,但需测试路由匹配逻辑 |
流水线安全兜底
graph TD
A[CI 触发] --> B{go version ≥1.21?}
B -->|否| C[立即失败并提示升级]
B -->|是| D[运行 go vet -vettool=...]
D --> E[执行 go test -race]
第三章:sync.RWMutex的性能迷思与真实瓶颈剖析
3.1 RWMutex底层实现再解读:reader计数器竞争、writer饥饿机制与goroutine唤醒路径
数据同步机制
RWMutex 使用 int32 类型的 state 字段复用位域:低31位记录 reader 数量,最高位(bit31)标记 writer 是否持有锁。reader 进入时通过 atomic.AddInt32(&m.state, 1) 增量;writer 则尝试 atomic.OrInt32(&m.state, mutexLocked) 占位。
reader 计数器竞争
高并发 reader 场景下,atomic.AddInt32 无锁但存在 false sharing 风险——多个 goroutine 修改同一 cache line 中的 state,引发总线震荡。
writer 饥饿机制
当 writer 等待超时(默认 1ms),RWMutex 启用饥饿模式:新 reader 被阻塞,避免 writer 永远等待。该策略由 m.woken 和 m.waiters 协同控制。
goroutine 唤醒路径
// 唤醒逻辑简化示意(src/sync/rwmutex.go)
if atomic.LoadInt32(&m.state) == 0 {
runtime_Semrelease(&m.sema, false, 0)
}
m.sema是底层uint32信号量false表示不唤醒所有等待者,仅唤醒一个- 第三个参数
表示无 stack trace 记录
| 组件 | 作用 | 并发安全方式 |
|---|---|---|
state |
reader计数 + writer锁位 | atomic 操作 |
sema |
goroutine 阻塞/唤醒 | runtime_Semacquire/Semrelease |
graph TD
A[Reader 尝试读] --> B{state & mutexLocked == 0?}
B -->|是| C[atomic.AddInt32 → 成功]
B -->|否| D[阻塞于 sema]
E[Writer 请求锁] --> F[设置 mutexLocked 位]
F --> G[唤醒首个 waiter]
3.2 基准测试实证:高并发读场景下RWMutex vs sync.Mutex vs atomics的实际吞吐与延迟对比
数据同步机制
在高读低写(如配置缓存、路由表)场景中,sync.RWMutex 本应优于 sync.Mutex,但实际受锁竞争模式与 runtime 调度影响显著。
测试设计要点
- 固定 100 goroutines,95% 读 / 5% 写操作
- 每次读操作执行
Load()或RLock()/RUnlock(),写操作触发Store()或Lock()/Unlock() - 使用
go test -bench=. -benchmem -count=5取均值
核心基准结果(单位:ns/op,越高越慢)
| 方案 | 平均延迟 | 吞吐量(ops/sec) | GC 压力 |
|---|---|---|---|
atomic.LoadUint64 |
2.1 | 476M | 无 |
RWMutex |
83 | 12M | 低 |
sync.Mutex |
142 | 7M | 中 |
func BenchmarkAtomicRead(b *testing.B) {
var v uint64
b.RunParallel(func(pb *testing.PB) {
for pb.Next() {
atomic.LoadUint64(&v) // 零开销原子读,无内存屏障冗余
}
})
}
该代码绕过锁调度,直接映射为 MOVQ 指令;v 未逃逸至堆,避免 GC 扫描干扰。而 RWMutex.RLock() 需检查 writer 等待队列并更新 reader 计数器,引入额外分支判断与原子操作。
性能归因
graph TD
A[goroutine 尝试读] --> B{atomic?}
B -->|是| C[CPU cache line 直接命中]
B -->|否| D[RWMutex: CAS reader count + fence]
D --> E[若 writer pending → park]
3.3 真实生产案例复盘:某分布式KV服务因RWMutex误用导致P99延迟飙升300%的根因定位
问题现象
线上监控显示,某核心KV服务P99读延迟从8ms骤升至32ms,持续17分钟,期间无扩容、无流量突增。
根因定位过程
- 通过pprof火焰图发现
Get()调用中runtime.semacquire1占比超65% go tool trace确认大量goroutine阻塞在sync.RWMutex.RLock()- 深入代码发现:高频写场景下,读锁被长期持有(平均4.2ms),且存在嵌套RLock
关键误用代码
func (s *Store) Get(key string) (string, error) {
s.mu.RLock() // ❌ 错误:未及时释放,且后续可能触发defer延迟
defer s.mu.RUnlock() // ⚠️ 若中间panic或return早于defer,仍会释放;但此处无panic风险
val, ok := s.data[key]
if !ok {
return "", ErrNotFound
}
// ❌ 实际业务逻辑中此处调用了外部HTTP client(耗时不可控)
resp, _ := http.Get("http://meta-service/" + key) // 导致RLock持有时间放大30倍
return val + resp.Body, nil
}
逻辑分析:
RLock()本应仅保护内存读取(微秒级),但因混入网络I/O,使单次读锁持有达120ms。当并发读请求达200+时,新RLock()需排队等待——而Go的RWMutex在有writer等待时会阻塞后续所有reader(饥饿模式),引发级联延迟。
改进方案对比
| 方案 | 锁粒度 | P99延迟 | 内存开销 |
|---|---|---|---|
| 原始RWMutex | 全局 | 32ms | 低 |
| 分片RWMutex(32路) | Key哈希分片 | 9ms | +12% |
| 读写分离+无锁快路径 | 仅value拷贝 | 6ms | +28% |
数据同步机制
graph TD
A[Client GET] --> B{Key Hash % 32}
B --> C[Shard RWMutex]
C --> D[Local map lookup]
D --> E[Hit?]
E -->|Yes| F[Return copy]
E -->|No| G[Async fallback to DB]
第四章:超越RWMutex的现代读写同步范式
4.1 基于atomic.Value的无锁只读缓存模式:适用于不可变结构体与配置热加载场景
核心设计思想
atomic.Value 提供对任意类型值的原子读写能力,其底层通过 unsafe.Pointer 实现无锁更新——仅在写入时拷贝新值指针,读取全程无锁、零开销。
典型应用场景
- 配置热更新(如数据库连接串、超时阈值)
- 全局只读元数据(如服务发现节点列表、特征开关集合)
- 不可变结构体(如
struct { Host string; Port int })
安全写入模式
var config atomic.Value
// 初始化(必须为不可变值)
config.Store(&Config{Host: "localhost", Port: 8080})
// 热更新(原子替换整个结构体指针)
newCfg := &Config{Host: "prod.example.com", Port: 443}
config.Store(newCfg) // ✅ 无锁、线程安全
逻辑分析:
Store将*Config指针原子写入,旧值内存由 GC 自动回收;Load()返回当前指针,强制类型断言确保类型安全。关键约束:Config必须不可变(字段不可修改),否则引发数据竞争。
性能对比(100万次读操作,单核)
| 方式 | 平均延迟 | 是否阻塞 | 内存分配 |
|---|---|---|---|
sync.RWMutex |
12.3 ns | 读不阻塞,写阻塞 | 0 |
atomic.Value |
2.1 ns | 全程无锁 | 0 |
graph TD
A[配置变更事件] --> B[构造新不可变结构体]
B --> C[atomic.Value.Store 新指针]
C --> D[所有goroutine立即读到新值]
4.2 单写多读场景下的sharded mutex实践:按key哈希分片+细粒度锁降低争用
在高并发缓存或状态映射场景中,全局互斥锁常成性能瓶颈。Sharded mutex 将锁空间按 key 哈希分散到多个独立锁桶中,使不同 key 的读写操作可并行。
分片策略设计
- 桶数通常取 2 的幂(如 64),便于位运算取模
- 哈希函数需均匀分布,避免热点桶(推荐
std::hash<Key>()(k) & (N-1))
示例实现(C++)
template<typename Key, typename T>
class ShardedMap {
static constexpr size_t kNumShards = 64;
std::array<std::shared_mutex, kNumShards> shards_;
std::array<std::unordered_map<Key, T>, kNumShards> maps_;
size_t shard_index(const Key& k) const {
return std::hash<Key>{}(k) & (kNumShards - 1); // 快速取模
}
public:
T get(const Key& k) {
const size_t idx = shard_index(k);
std::shared_lock lock(shards_[idx]); // 共享读锁
auto it = maps_[idx].find(k);
return (it != maps_[idx].end()) ? it->second : T{};
}
};
逻辑分析:
shard_index利用哈希值低位与(N-1)位与实现 O(1) 分片;std::shared_mutex支持多读单写,读路径无互斥;每个桶独立锁,消除跨 key 争用。
性能对比(10K keys,16线程)
| 锁策略 | 平均读延迟(ns) | 吞吐量(ops/s) |
|---|---|---|
| 全局 mutex | 820 | 1.2M |
| Sharded (64) | 98 | 9.7M |
graph TD
A[请求 key] --> B{计算 hash}
B --> C[取低6位 → shard idx]
C --> D[获取对应 shared_mutex]
D --> E[读锁进入 map 查找]
4.3 使用Ristretto或Freecache构建带TTL的并发安全LRU:规避锁竞争的数据访问层优化
现代高并发服务常因共享缓存锁争用导致吞吐骤降。Ristretto 与 Freecache 均采用分片(sharding)+ 无锁队列设计,将全局 LRU 锁拆解为多个局部段。
核心差异对比
| 特性 | Ristretto | Freecache |
|---|---|---|
| 驱逐策略 | 自适应采样 + 热度加权 | 传统 LRU + TTL 淘汰 |
| 并发模型 | 多 ring buffer + atomic 操作 | 分片 map + sync.Pool 复用节点 |
| TTL 支持 | 内置 WithTTL 选项 |
需手动包装 time.Now() 判断 |
Ristretto 初始化示例
cache := ristretto.NewCache(&ristretto.Config{
NumCounters: 1e7, // 采样精度:越高越准但内存开销越大
MaxCost: 1 << 30, // 总成本上限(如字节数)
BufferItems: 64, // 每个分片的写缓冲区大小
Metrics: true, // 启用命中率等统计指标
})
该配置通过 NumCounters 控制热度采样粒度,BufferItems 减少原子操作频次,避免 CAS 尖峰争用。
数据同步机制
Ristretto 在后台 goroutine 中批量处理驱逐,Freecache 则依赖 sync.Map 实现读写分离——前者吞吐更高,后者更易调试。
graph TD
A[请求写入] --> B{分片哈希}
B --> C[本地 ring buffer]
C --> D[异步 flush 到 segment heap]
D --> E[采样决策驱逐]
4.4 Go 1.21+新特性赋能:使用unsafe.Slice与go:linkname绕过反射开销,提升读密集型结构体字段访问效率
零拷贝字段提取:unsafe.Slice替代reflect.Value.Field
// 假设目标结构体(内存布局紧凑)
type User struct {
ID int64
Name string // string header: 2×uintptr
Age uint8
}
// 通过unsafe.Slice直接切片字符串数据(Go 1.21+)
func FastName(u *User) string {
hdr := (*reflect.StringHeader)(unsafe.Pointer(&u.Name))
return unsafe.String(hdr.Data, hdr.Len)
}
unsafe.Slice(ptr, len) 替代 reflect.Value.Field(i).String(),避免反射运行时类型检查与接口转换开销。参数 hdr.Data 指向底层字节数组首地址,hdr.Len 为长度——二者均来自已知内存偏移,零分配、零反射。
关键系统函数内联:go:linkname突破包封装
//go:linkname runtime_string runtime.string
func runtime_string([]byte) string
func FastFromBytes(b []byte) string {
return runtime_string(b) // 直接调用运行时内部实现
}
//go:linkname 绕过导出限制,将 runtime.string 符号绑定至本地函数,消除 []byte → string 的额外校验与复制路径。
性能对比(百万次访问)
| 方式 | 耗时(ns/op) | 内存分配(B/op) |
|---|---|---|
reflect.Value.Field(1).String() |
1280 | 48 |
unsafe.String + go:linkname |
32 | 0 |
注:基准测试基于
User{ID: 1, Name: "Alice", Age: 30},Go 1.22.3,AMD Ryzen 7。
第五章:面向云原生基础设施的Go语言精进终局思考
构建高可用服务网格控制平面
在某金融级Service Mesh迁移项目中,团队基于Go重构了自研控制平面XMesh-CP。核心组件采用sync.Map替代全局锁map以支撑每秒12万次配置热更新,结合context.WithTimeout对Envoy xDS接口实施毫秒级超时控制(默认800ms),并通过http2.Transport复用连接池将gRPC长连接数从3.2k压降至417个。关键路径引入runtime/trace埋点后,发现P99延迟瓶颈位于证书轮换协程的串行签名环节,改用crypto/ecdsa.Sign并行签名后,证书分发耗时从320ms降至68ms。
实现Kubernetes Operator的声明式闭环
某IoT平台使用Go编写DeviceOperator管理百万级边缘设备。通过controller-runtime构建控制器,定义DeviceSpec.Replicas字段触发水平扩缩容逻辑;当status.phase == "Pending"时,自动调用clientset.CoreV1().Secrets(namespace).Create()注入TLS凭证。为避免状态漂移,采用diff库对比last-applied-configuration注解与当前spec,仅当DeepEqual返回false时才发起PATCH请求。下表展示不同规模集群下的协调性能:
| 集群节点数 | 设备CRD实例数 | 平均Reconcile耗时 | 控制器副本数 |
|---|---|---|---|
| 50 | 8,200 | 42ms | 3 |
| 200 | 41,600 | 118ms | 5 |
| 500 | 127,300 | 296ms | 7 |
应对Serverless冷启动的Go运行时优化
在AWS Lambda Go Runtime实践中,通过go build -ldflags="-s -w"剥离调试符号使二进制体积减少63%,配合GOOS=linux GOARCH=amd64 CGO_ENABLED=0交叉编译实现零依赖部署。针对冷启动峰值内存问题,启用GODEBUG=madvdontneed=1参数,使runtime.GC()后立即释放页框,实测内存峰值下降37%。以下代码片段展示预热初始化模式:
var warmupOnce sync.Once
func init() {
// 冷启动预热:提前加载证书、初始化DB连接池、预热HTTP客户端
warmupOnce.Do(func() {
go func() {
tls.LoadX509KeyPair("/tmp/cert.pem", "/tmp/key.pem")
db := sql.Open("postgres", os.Getenv("DB_DSN"))
db.SetMaxOpenConns(10)
http.DefaultClient.Timeout = 3 * time.Second
}()
})
}
构建可观测性数据管道
某电商中台采用Go构建OpenTelemetry Collector扩展组件,定制processor插件解析Prometheus metrics暴露的http_request_duration_seconds_bucket{le="0.1"}直方图。通过prometheus.NewHistogramVec动态注册指标,并利用otel/sdk/metric的NewPeriodicReader每15秒推送聚合数据至Jaeger后端。Mermaid流程图描述数据流转路径:
graph LR
A[Envoy Access Log] --> B[Go Log Parser]
B --> C{Filter by Service Tag}
C -->|payment| D[Payment Metrics Aggregator]
C -->|order| E[Order Metrics Aggregator]
D --> F[OTLP Exporter]
E --> F
F --> G[Jaeger Collector] 