1. 6.3 链路追踪实践
在 日志与指标之外,本节说明 zmsg.Message 内 Trace 字段、zgate.Server.SetTraceHook 与 zactor.SetTraceHooks 的配合方式,以及跨进程时字段如何随 Marshal/Unmarshal 保留。完整 OTel 导出需自行接 SDK(本仓库为 hook 位而非内置 Agent)。
1.1. 6.3.1 为什么需要链路追踪?
一个请求在 zhenyi 中可能经过多条路径:
客户端 → Gate(进程1) → NATS → IM Actor(进程2) → Chat Actor(进程3) → 客户端
当请求变慢或出错,怎么知道瓶颈在哪一段?
- 日志:能看到单个节点的事件,但不知道上下游关系
- 指标:能看到聚合统计,但不知道单个请求的路径
链路追踪解决的就是请求级别的问题:一个请求经过了哪些节点,每个节点花了多少时间。
1.2. 6.3.2 zhenyi 的追踪设计
zhenyi 实现了轻量的链路追踪,核心是 Trace ID 透传 + Span 钩子。
1.2.1. Trace ID 透传
每条消息携带 128 位 Trace ID(TraceIdHi + TraceIdLo):
type Message struct {
TraceIdHi uint64 // 高 64 位
TraceIdLo uint64 // 低 64 位
SpanId uint64 // 当前 Span ID
// ...
}
Trace ID 在请求入口生成,跨 Actor、跨进程传递时保持不变:
Gate 收到客户端消息
↓ 生成 TraceIdHi = 雪花算法 ID
↓ TraceIdLo = 0
消息投递到 IM Actor
↓ TraceIdHi/TraceIdLo 保持不变
↓ SpanId 更新为 IM Actor 的 ID
跨进程转发到进程 2
↓ TraceIdHi/TraceIdLo 透传
↓ 通过 NATS 的消息体携带
1.2.2. Span 钩子
zhenyi 不强制绑定 OpenTelemetry,而是提供钩子接口:
// 伪代码:tracer 为你的 OTel/自研实现
zactor.SetTraceHooks(
func() bool { return /* 采样或全局开关 */ true },
func(ctx context.Context, name string) (context.Context, func()) {
ctx, span := tracer.Start(ctx, name)
return ctx, func() { span.End() }
},
func(ctx context.Context, msg *zmsg.Message) context.Context {
// 在此用 TraceIdHi/Lo 构造 trace.SpanContext 并 trace.ContextWithSpanContext(ctx, ...)
return ctx
},
func(msg *zmsg.Message) {
msg.TraceIdHi = zid.NextFast()
},
)
(SetTraceHooks 仅执行一次 sync.Once,须在任意 Actor Run 前注册。)
如果没调用 SetTraceHooks,isTraceEnabled() 返回 false,所有追踪代码零开销跳过:
// Dispatcher 中的追踪代码
if isTraceEnabled() {
ctx, endSpan = traceStartSpan(ctx, obs.spanName)
}
// isTraceEnabled() == false 时,这段代码完全不执行
1.3. 6.3.3 与 OpenTelemetry 的集成
如果需要完整的分布式追踪(导出到 Jaeger、Zipkin),可以集成 OpenTelemetry:
1.3.1. 与 OTel 对接(原则)
- 使用 OTel SDK 创建
TracerProvider/Tracer(导出到 Jaeger/OTLP 等由运维选型)。 - 在
SetTraceHooks的ctxFromMsg中,将msg.TraceIdHi/TraceIdLo(均为uint64) 规范地填入trace.TraceID(16 字节),并构造trace.SpanContext;禁止把格式化字符串误当作TraceIDFromBytes的输入。 - 在
SetTraceHook(Gate) 中,可在入站时把 当前 Span 上下文写回zmsg.Message,保证与客户端或边缘网关约定一致。
1.3.2. Span 结构(示意)
集成后,每个 Handler 执行会创建一个 Span:
Gate.handler.1001 (收到登录请求)
├── IM.handler.2001 (验证用户)
│ └── DB.query (查数据库)
└── Gate.reply (回复客户端)
在 Jaeger UI 中可以看到完整的调用链和每个节点的耗时。
1.4. 6.3.4 日志关联
Trace ID 会自动注入到日志中:
// Handler 中记录日志
func (a *IMActor) HandleLogin(ctx context.Context, msg *zmsg.Message) {
zlog.Info("user login",
zap.Uint64("traceIdHi", msg.TraceIdHi),
zap.String("userId", userId))
}
输出:
{"level":"info","msg":"user login","traceIdHi":1234567890123,"userId":"abc123"}
在 ELK 或 Loki 中通过 traceIdHi 可以搜到同一条请求的所有日志:
traceIdHi:1234567890123
1.4.1. Context 方式注入
业务代码也可以从 context 获取 Trace ID:
fields := zlog.TraceZapFields(ctx)
zlog.Info("processing", fields...)
或者手动提取:
if tf, ok := zlog.TraceFieldsFromContext(ctx); ok {
// tf.TraceIdHi, tf.TraceIdLo, tf.SpanId, tf.MsgId, tf.ActorId
}
1.5. 6.3.5 跨进程追踪
跨进程转发时,Trace ID 通过消息体携带:
// 发送端(Gate)
msg.TarActor = target.Id
buf, _ := msg.MarshalPooled()
zbus.DefaultBus.Broadcast(topic, buf.B) // buf 包含 TraceIdHi/TraceIdLo
// 接收端(IM Actor)
func handler(topic string, data []byte) {
msg := zmsg.GetMessage()
msg.Unmarshal(data) // 还原 TraceIdHi/TraceIdLo
actor.Push(zmodel.ActorCmd{Type: zmodel.CmdTypeMsg, Msg: msg})
}
若已接入 OTel,可在订阅回调内 ctxFromMsg + startSpan 创建子 Span(具体顺序依你的包装代码而定)。
1.6. 6.3.6 采样策略
全量追踪开销大(每个 Handler 都创建 Span),生产环境通常采样:
在 SetTraceHooks 第一个参数 enabled 中接 OTel Sampler 或简单随机开关即可。
建议:
- 开发环境:100% 采样,方便调试
- 生产环境:0.1%~1% 采样,或根据错误率动态调整
1.7. 6.3.7 性能开销
| 场景 | 开销 |
|---|---|
未注册 SetTraceHooks |
isTraceEnabled() 为 false,追踪分支不执行 |
注册 hook 且 enabled()==false |
一次布尔与函数间接;量级远小于典型业务 handler |
| 采样命中 | 依赖 OTel/SDK 与 Span 属性数量,需实测 |
未安装 hook 时 isTraceEnabled() 为 false,分发器内追踪分支不执行。
1.8. 6.3.8 实际案例
场景:用户登录慢。
- 在 Jaeger 中搜索
traceIdHi = 1234567890123 - 看到调用链:
Gate.handler.1001 (120ms)
├── IM.handler.2001 (100ms) ← 瓶颈
│ └── DB.query (95ms) ← 真正的问题
└── Gate.reply (5ms)
- 定位到 DB 查询慢,检查 SQL 或索引
场景:消息丢失。
- 通过日志
traceIdHi找到相关日志 - 发现消息在 Gate 路由后没有到达 IM Actor
- 检查 NATS 指标,发现
zhenyi_nats_publish_errors_total增加 - 定位到 NATS 连接断开
1.9. 6.3.9 本节要点
- 载体:
TraceIdHi/TraceIdLo/SpanId在zmsg线格式中持久化,NATS/总线传 字节流 即带上。 - 入口:Gate
SetTraceHook;Actor 管线SetTraceHooks(一次性)。 - 日志:
zlog.TraceZapFields/TraceFieldsFromContext;Loki/ELK 用 traceIdHi 等关联。 - OTel:在 hook 内自洽转换 16 字节 TraceID;避免错误 API 拼字符串。
- 采样:由
enabled或 OTelSampler决定。
6.4 节:依赖拓扑、K8s 探针与 SetDraining 等运维约定。