当三套日志框架遇上分布式追踪:Go 微服务网关日志架构重构实录

当三套日志框架遇上分布式追踪:Go 微服务网关日志架构重构实录

在一个基于 Hertz + Kitex 的企业级微服务系统中,API 网关层混用了 zerolog、hertz-zerolog、slog 三套日志框架,约 100 处日志调用实际是 fmt.Sprint 拼接而非结构化输出,且无任何追踪上下文注入。本文记录了从问题发现到统一重构的完整过程,包括方案设计中的关键取舍、实施中踩过的坑,以及重构完成后对 OpenTelemetry 集成的进一步思考。

一、问题是怎么长出来的

没有人会在第一天就写出三套日志框架混用的代码。问题是随着项目演进逐渐积累的。

我们的系统是一个标准的 Go 微服务架构:Hertz 做 HTTP 网关,Kitex 做 RPC 服务,中间通过 metainfo + TTHeader 传递追踪信息。网关层的分层结构如下:

bash 复制代码
api/gateway/
├── biz/handler/            # Hertz 生成的 HTTP Handler
├── internal/
│   ├── application/        # 中间件层 (JWT, CORS, Error, Trace)
│   ├── domain/service/     # 域服务层 (20+ 个服务)
│   ├── infrastructure/     # 基础设施层 (RPC Client, Redis, Config)
│   └── wire/               # 依赖注入
└── pkg/log/                # tracelog 日志工具包

日志代码大致沿着三条路径各自演化:

路径一:域服务层 --- 通过 BaseService 基类暴露 Logger() 方法,返回的是 *hertzZerolog.Logger。24 个域服务在此之上调用 s.Logger().Error("msg", "key", val) 这种 printf 风格 API。看起来像结构化日志,实际上 hertzZerolog 的 Error(v ...interface{}) 内部走的是 fmt.Sprint 拼接 ,所有的 key-value 对被拼成一个字符串塞进 message 字段。

路径二:中间件层 --- JWT、CORS、Error Handler 中间件各自持有 *hertzZerolog.Logger,使用 Warnf/Errorf 等 printf 风格方法。其中错误处理中间件的作者了解 tracelog 包,正确使用了 tracelog.Event(ctx, logger.Error()) 注入追踪字段;但其他中间件没有。

路径三:SSE 服务 --- 独立使用 Go 标准库的 log/slog,完全脱离了项目的日志体系。

路径四:tracelog 包 --- 项目已经有一个设计良好的 pkg/log/trace_logger.go,提供了 WithTrace()Event()BindToContext() 等方法来注入 OpenTelemetry 追踪字段。但这个包只被错误处理中间件正确使用了,其余代码一概不知。

最终结果:生产环境的日志里,只有错误处理中间件的日志带有 trace_idrequest_id,域服务、JWT 中间件、Redis 缓存层的日志都是裸奔的,无法做分布式追踪关联。

二、看清问题之前,先看清框架

重构之前必须搞清楚一个基本问题:项目中出现的 zerologhertzZerologhlog 到底是什么关系?

三者的包装链

bash 复制代码
github.com/rs/zerolog              → 日志引擎(核心)
github.com/hertz-contrib/logger/zerolog  → Hertz hlog 适配层(壳)
github.com/cloudwego/hertz/pkg/common/hlog  → Hertz 框架日志接口(契约)

rs/zerolog 是实际干活的日志引擎,提供高性能的结构化链式 API:

go 复制代码
logger.Info().Str("user_id", "123").Int("age", 25).Msg("user created")
// 输出: {"level":"info","user_id":"123","age":25,"message":"user created"}

hlog 是 Hertz 框架定义的日志接口(hlog.FullLogger),框架内部所有日志都通过这个接口输出。它的 API 设计是 printf 风格的:

go 复制代码
type FullLogger interface {
    Debugf(format string, v ...interface{})
    Infof(format string, v ...interface{})
    Errorf(format string, v ...interface{})
    // ...
}

hertzZerologhertz-contrib/logger/zerolog)的存在意义只有一个:实现 hlog.FullLogger 接口,让 Hertz 框架内部能通过 zerolog 输出日志 。你调用 hlog.SetLogger(hertzZerolog.New()),Hertz 的路由、中间件等内部组件就会通过 zerolog 输出。

它的关键方法是 Unwrap(),用于获取底层的 zerolog.Logger

go 复制代码
hertzLogger := hertzZerolog.New(hertzZerolog.WithLevel(hlog.LevelInfo))
rawLogger := hertzLogger.Unwrap()  // → zerolog.Logger

问题出在哪

我们的项目犯了一个常见错误:把适配层当作日志 API 来用

hertzZerolog.Logger 本应只出现在一个地方 --- hlog.SetLogger() 调用处。但在 Wire 依赖注入的传递链中,它成了全局的日志类型:

scss 复制代码
config.CreateLogger() → *hertzZerolog.Logger
  → Wire 注入 → AppContainer.Logger
    → ProvideTraceMiddleware(logger)   → 拿到后 Unwrap()
    → ProvideJWTMiddleware(logger)     → 拿到后 Unwrap()
    → NewBaseService(logger)           → 拿到后 Unwrap()
    → ProvideCORSMiddleware(logger)    → 拿到后 Unwrap()
    → ...

每个消费者拿到 *hertzZerolog.Logger 后,第一件事都是 Unwrap() 拆壳。整个 Wire 链路传递的是一个没人直接使用的适配壳。

更糟糕的是,域服务通过 BaseService.Logger() 拿到的也是 *hertzZerolog.Logger,开发者调用 s.Logger().Error("检查失败", "error", err) 时,以为在写结构化日志,实际输出的是:

json 复制代码
{ "level": "error", "message": "检查失败error<nil>" }

所有的 key-value 对被 fmt.Sprint 拼成了一个字符串。代码看起来是对的,日志输出却是错的。

三、重构方案设计

设计原则

  1. 统一到 zerolog 原生 API --- 所有业务日志使用 logger.Info().Str(...).Msg(...) 链式调用
  2. 追踪字段自动注入 --- 通过已有的 tracelog 包确保每条日志携带 trace_idrequest_idspan_id
  3. 最小接口变更 --- 24 个域服务通过 BaseService 间接调用日志,优先改 BaseService 核心方法
  4. hertzZerolog 只保留在入口 --- Wire 链路继续传递 *hertzZerolog.Logger(避免改动 Wire 签名),但消费者内部统一使用 *zerolog.Logger

分阶段执行

阶段 内容 文件数 修改处数
0 编写日志规范文档 + Claude 技能文件 2 (新建) ---
1 重构 BaseService 核心(新增 Log(ctx) 方法) 1 ~60 行
2 替换域服务直接日志调用 10 ~100 处
3 重构中间件日志(JWT/CORS/Redis) 4-5 ~30 处
4 SSE 服务从 slog 迁移到 zerolog 3 ~15 处
5 TraceMiddleware 增强(BindToContext 1-2 ~10 行
6 清理收尾 + lint 检查 --- ---

阶段的排列有讲究:先改核心(BaseService),再改使用方(域服务),再改独立模块(中间件、SSE),最后增强基础设施(TraceMiddleware)。每个阶段完成后都可以独立编译验证,不会出现"改了一半编译不过"的尴尬。

四、BaseService 核心改造

BaseService 是 24 个域服务的公共基类,改它等于改所有服务的日志行为。

改造前

go 复制代码
type BaseService struct {
    logger      *hertzZerolog.Logger
    serviceName string
}

// Logger 暴露 hertzZerolog 的 printf 风格 API
func (bs *BaseService) Logger() *hertzZerolog.Logger {
    return bs.logger
}

域服务调用方式:

go 复制代码
s.Logger().Error("获取用户失败", "user_id", userID, "error", err)
// 实际输出: {"message":"获取用户失败user_id12345error<nil>"}  ← 全拼接了

改造后

go 复制代码
// Log 返回带追踪信息的 zerolog.Logger(推荐使用)
func (bs *BaseService) Log(ctx context.Context) *zerolog.Logger {
    return bs.getTracedLogger(ctx)
}

func (bs *BaseService) getTracedLogger(ctx context.Context) *zerolog.Logger {
    // 优先从 context 获取(TraceMiddleware 已绑定带追踪字段的 logger)
    logger := zerolog.Ctx(ctx)
    if logger != nil && logger.GetLevel() != zerolog.Disabled {
        return logger
    }
    // 回退:手动注入追踪字段
    base := bs.logger.Unwrap()
    traced := tracelog.WithTrace(ctx, base)
    return &traced
}

// Deprecated: 使用 Log(ctx) 替代
func (bs *BaseService) Logger() *hertzZerolog.Logger {
    return bs.logger
}

关键设计点:

  1. Log(ctx) 要求传入 context --- 这是刻意的。没有 context 就没有追踪信息,API 签名本身就在约束正确用法。
  2. 优先从 context 获取 logger --- TraceMiddleware 在请求入口处已经将带追踪字段的 logger 绑定到 context,域服务直接取用即可,避免每次重复构建。
  3. 保留 Logger() 但标记废弃 --- 避免一次性改所有调用点,允许渐进式迁移。

域服务的新调用方式:

go 复制代码
s.Log(ctx).Error().Str("user_id", userID).Err(err).Msg("获取用户失败")
// 输出: {"level":"error","trace_id":"abc...","request_id":"req-123",
//        "user_id":"12345","error":"...","message":"获取用户失败"}

五、批量替换:正则不是银弹

10 个域服务文件、约 100 处日志调用需要从 printf 风格迁移到链式 API。手动改太慢,我用 Python 脚本做正则批量替换。

替换规则

场景 改造前 改造后
单参数 s.Logger().Error("msg") s.Log(ctx).Error().Msg("msg")
带 error s.Logger().Error("msg", "error", err) s.Log(ctx).Error().Err(err).Msg("msg")
带字段 s.Logger().Info("msg", "k1", v1) s.Log(ctx).Info().Str("k1", v1).Msg("msg")
多字段 s.Logger().Info("msg", "k1", v1, "k2", v2) s.Log(ctx).Info().Str("k1", v1).Str("k2", v2).Msg("msg")

看起来很规整?实际执行中遇到了两个痛点。

痛点一:正则无法理解 Go 表达式边界

多参数场景的正则模式需要匹配任意 Go 表达式作为值,但 Go 表达式可以包含括号、点号、方法调用:

go 复制代码
// 原始代码
s.Logger().Info("获取成功", "count", len(resp.GetData()))

// 正则错误替换结果 --- Msg() 被嵌套进了 Str() 的值参数里
s.Log(ctx).Info().Str("count", len(resp.GetData()).Msg("获取成功"))
//                                               ↑ 括号配对出错

正则引擎无法正确处理嵌套括号。最终的解决方案是分两遍处理:第一遍用正则做粗略替换,第二遍用针对性的修复脚本处理每个错误模式。

痛点二:类型推断是人的工作

正则不知道值的类型,统一生成 .Str("key", val)。但实际代码中:

go 复制代码
s.Logger().Info("上传成功", "success", rpcResp.Success)  // bool 类型
s.Logger().Info("获取完成", "count", len(tokenHashes))    // int 类型

这些需要人工审查,改为 .Bool("success", rpcResp.Success).Int("count", len(tokenHashes))

顺便修正的问题

批量替换过程中还发现了原始代码的日志级别错误:

go 复制代码
// 用 Error 级别记录成功信息
s.Logger().Error(ctx, "检查数据源使用状态成功", slog.String("data_source_id", id))
// → 修正为 Info 级别
s.Log(ctx).Info().Str("data_source_id", id).Msg("检查数据源使用状态成功")

// 用 Error 级别记录参数校验
s.Logger().Error("Upload ID is required")
// → 修正为 Warn 级别(参数校验不是系统错误)
s.Log(ctx).Warn().Msg("Upload ID is required")

批量重构的额外价值就在这里 --- 你被迫逐行审视每一条日志,那些藏了几个月的小问题自然浮出水面。

六、中间件层:从 printf 到结构化

中间件的改造模式比较统一。以 JWT 中间件为例:

改造前

go 复制代码
type JWTMiddlewareImpl struct {
    logger *hertzZerolog.Logger  // 适配壳类型
}

// printf 风格,无追踪上下文
m.logger.Warnf("Access denied: token has been revoked")
m.logger.Errorf("Failed to revoke token: %v", err)

改造后

go 复制代码
type JWTMiddlewareImpl struct {
    logger *zerolog.Logger  // 直接用 zerolog
}

// 结构化 + 追踪上下文
tracelog.Event(ctx, m.logger.Warn()).
    Str("component", "jwt_middleware").
    Msg("Access denied: token has been revoked")

tracelog.Event(ctx, m.logger.Error()).
    Str("component", "jwt_middleware").
    Err(err).
    Msg("Failed to revoke token")

中间件和域服务的日志调用方式不同:域服务通过 s.Log(ctx) 从 context 获取 logger,中间件通过 tracelog.Event(ctx, m.logger.Level()) 对已有 logger 注入追踪字段。两种方式各有适用场景:

  • 域服务用 s.Log(ctx) --- logger 已在 TraceMiddleware 中绑定到 context,直接取用最简洁
  • 中间件用 tracelog.Event() --- 某些中间件在 TraceMiddleware 之前执行(如 CORS),context 中还没有 logger

七、TraceMiddleware 增强:让一切自动化

整个重构的最后一块拼图是让 TraceMiddleware 在请求入口处完成 logger 的 context 绑定:

go 复制代码
func (m *TraceMiddlewareImpl) MiddlewareFunc() app.HandlerFunc {
    return func(ctx context.Context, c *app.RequestContext) {
        requestID := requestid.Get(c)
        ctx = errors.InjectRequestIDToContext(ctx, requestID)
        ctx = errors.InjectTraceToContext(ctx)

        // 关键:将带追踪字段的 logger 绑定到 context
        if m.logger != nil {
            path := string(c.Request.Path())
            ctx = tracelog.BindToContext(ctx, *m.logger, "api-gateway", path)
        }

        c.Next(ctx)
    }
}

BindToContext 做了三件事:

  1. 从 context 提取 trace_idspan_idrequest_id
  2. 将这些字段附加到 logger 上
  3. 将 logger 存入 context(通过 zerolog.WithContext

之后所有的 zerolog.Ctx(ctx)s.Log(ctx) 调用都能直接拿到这个带追踪字段的 logger,零成本、无感知

中间件的执行顺序决定了这个方案能否工作:

scss 复制代码
hertztracing.ServerMiddleware   → 1. 创建 OTel Span
requestid.New()                 → 2. 生成 RequestID
traceMiddleware.MiddlewareFunc  → 3. 绑定 logger 到 context ★
corsMiddleware                  → 4. 已经可以用 tracelog.Event()
errorMiddleware                 → 5. 已经可以用 tracelog.Event()
jwtMiddleware                   → 6. 已经可以用 tracelog.Event()
→ 域服务                         → 7. s.Log(ctx) 直接获取

八、重构之后,新的问题浮出水面

重构完成,编译通过,lint 检查干净。但当我打开 Jaeger 查看链路追踪时,发现了一个新问题:

Jaeger 的 Span 详情中只有框架自动生成的 "start" 和 "finish" 事件,看不到任何业务日志。

这不是 bug,而是架构层面的认知偏差。

日志输出 ≠ 链路追踪

我们的 tracelog.Event() 做的事情是:把 trace_id 写进日志的 JSON 字段

json 复制代码
{
  "level": "error",
  "trace_id": "abc123",
  "span_id": "def456",
  "message": "RPC调用失败"
}

这让你可以在 Grafana Loki 或 ELK 中通过 trace_id 检索相关日志。但 Jaeger 不会去读你的日志文件 --- Jaeger 只展示 OpenTelemetry Span 中记录的事件

要让业务日志出现在 Jaeger 中,需要调用 span.AddEvent()span.RecordError(),将日志作为 Span Event 写入 OpenTelemetry 链路。我们的代码从未这样做过。

hertz-contrib/obs-opentelemetry/logging/zerolog

CloudWeGo 官方提供了一个解决方案:hertz-contrib/obs-opentelemetry/logging/zerolog。我去读了它的源码,核心实现是一个 zerolog Hook:

go 复制代码
func (cfg config) defaultZerologHookFn() zerolog.HookFunc {
    return func(e *zerolog.Event, level zerolog.Level, message string) {
        ctx := e.GetCtx()                          // 从 event 获取 context
        span := trace.SpanFromContext(ctx)          // 从 context 获取 span

        // 自动注入 trace_id/span_id 到日志输出
        e.Any("trace_id", spanCtx.TraceID())
        e.Any("span_id", spanCtx.SpanID())

        // Error 级别日志 → 自动记录到 Span(Jaeger 可见)
        if level >= cfg.traceConfig.errorSpanLevel {
            span.SetStatus(codes.Error, "")
            span.RecordError(errors.New(message))
        }
    }
}

这个 Hook 通过 zerolog 的 e.GetCtx() 获取 context(而非我们的 tracelog.Event(ctx, event) 显式传递),然后:

  1. 将 trace_id/span_id 注入日志输出(替代我们的手动注入)
  2. 对 Error 级别日志调用 span.RecordError()让它出现在 Jaeger 中

但它也有局限:

  • 只处理 Error 级别,Warn/Info 不会出现在 Jaeger
  • 不处理 request_id(这是我们的业务字段,通过 metainfo 传播)
  • 依赖 e.GetCtx() --- 需要 logger 在创建时通过 .Ctx(ctx) 绑定了请求 context

这意味着下一步的集成方案需要:引入官方 Hook 自动处理 trace_id 注入和 Error 记录,同时补充一个自定义 Hook 处理 Warn 级别的 Span Event 和 request_id 注入。

日志与追踪的正确关系

flowchart TB subgraph TraceChannel["追踪通道"] Jaeger["Jaeger / Tempo
展示 Span + Span Events"] Hook["zerolog Hook
拦截日志事件 → 写入 Span
OTel SDK: span.AddEvent / RecordError"] Jaeger <-->|双向通信| Hook end subgraph LogChannel["日志通道"] Logger["zerolog Logger
结构化 JSON 日志输出"] Storage["Loki / ELK / 日志文件
通过 trace_id 关联检索"] Logger -->|io.Writer| Storage end Hook -.->|拦截并处理| Logger style Jaeger fill:#F39C12,stroke:#E67E22,color:#34495E style Hook fill:#7F8C8D,stroke:#95A5A6,color:#ECF0F1 style Logger fill:#2C3E50,stroke:#34495E,color:#ECF0F1 style Storage fill:#7F8C8D,stroke:#95A5A6,color:#ECF0F1

关键理解:日志和追踪是两条独立的数据通道

  • 关联检索 :在 Loki/ELK 中通过 trace_id 检索相关日志
  • 链路可视化 :在 Jaeger 中通过 span.AddEvent() 将业务事件写入链路
  • 两者不冲突,也不能互相替代

日志和追踪是两条独立的数据通道。trace_id 写进日志 JSON 是为了关联检索 (在 Loki 中搜 trace_id=xxx 找到相关日志),而 span.AddEvent() 是为了在链路中直接看到事件(Jaeger Span 详情页)。两者不冲突,也不能互相替代。

九、回顾与反思

收益

维度 改造前 改造后
日志格式 fmt.Sprint 拼接,字段丢失 zerolog 结构化,字段独立可查询
追踪关联 仅错误中间件有 trace_id 所有业务日志自动携带 trace_id/request_id/span_id
日志级别 多处误用(Error 记成功信息) 审查修正
框架统一 zerolog + hertzZerolog + slog 混用 统一 zerolog,hertzZerolog 仅留在 hlog 入口

经验

1. 适配层不是 API 层。 hertzZerolog 是给 Hertz 框架用的适配壳,不是给业务代码用的日志 API。这个认知偏差是问题的根源。

2. 正则批量替换需要两遍。 第一遍粗替换,第二遍修复正则无法处理的模式(嵌套括号、类型推断)。别指望一遍搞定。

3. 强制传 context 是好的 API 设计。 Log(ctx) 强制调用者提供 context,从 API 层面杜绝了"忘记注入追踪信息"的可能。

4. 写进日志 ≠ 写进链路。 trace_id 出现在日志 JSON 中只能做关联检索,要在 Jaeger 中看到业务事件,必须通过 span.AddEvent() / span.RecordError() 写入 OTel Span。这是两条独立的数据通道。

5. 分层验证比最终验证重要。 每改完一个阶段就 go build -o /dev/null .,能在几秒内发现问题。如果等全部改完再编译,面对 25 个编译错误的堆栈会让人崩溃。

后续计划

本次重构统一了日志框架和追踪字段注入,但还缺最后一环:让业务日志作为 Span Event 出现在 Jaeger 中 。下一步将引入 hertz-contrib/obs-opentelemetry/logging/zerolog 的 Hook 机制,并扩展自定义 Hook 以覆盖 Warn 级别和 request_id 注入。


本文基于一个真实项目的重构过程撰写。项目使用 Go 1.24+,Hertz v0.10.2,Kitex,zerolog v1.34.0,OpenTelemetry SDK v1.39.0。

相关推荐
青云计划7 小时前
知光项目知文发布模块
java·后端·spring·mybatis
Victor3567 小时前
MongoDB(9)什么是MongoDB的副本集(Replica Set)?
后端
Victor3568 小时前
MongoDB(8)什么是聚合(Aggregation)?
后端
yeyeye1119 小时前
Spring Cloud Data Flow 简介
后端·spring·spring cloud
Tony Bai9 小时前
告别 Flaky Tests:Go 官方拟引入 testing/nettest,重塑内存网络测试标准
开发语言·网络·后端·golang·php
牛奶10 小时前
《前端架构设计》:除了写代码,我们还得管点啥
前端·架构·设计
+VX:Fegn089510 小时前
计算机毕业设计|基于springboot + vue鲜花商城系统(源码+数据库+文档)
数据库·vue.js·spring boot·后端·课程设计
程序猿阿伟10 小时前
《GraphQL批处理与全局缓存共享的底层逻辑》
后端·缓存·graphql
小小张说故事10 小时前
SQLAlchemy 技术入门指南
后端·python
识君啊10 小时前
SpringBoot 事务管理解析 - @Transactional 的正确用法与常见坑
java·数据库·spring boot·后端