微服务难点剖析:服务拆的挺爽,问题是日志该怎么串联起来呢?

时间:2022-04-06 06:09:58

微服务难点剖析:服务拆的挺爽,问题是日志该怎么串联起来呢?

本文转载自微信公众号「网管叨bi叨」,作者KevinYan11。转载本文请联系网管叨bi叨公众号。

现在服务架构盛行,很多以前的单体应用服务都被拆成了多个分布式的微服务,以解决应用系统发展壮大后的开发周期长、难以扩展、故障隔离等挑战。

不过技术领域有个谚语叫--没有银弹,这句话的意思其实跟现实生活中任何事都有利和弊两面一样,意思是告诉我们不要寄希望于用一个解决方案解决所有问题,引入新方案解决旧问题的同时,势必会引入新的问题。典型的比如,原本在单体应用里可以靠本地数据库的ACID 事务来保证数据一致性。但是微服务拆分后,就没那么简单了。

同理拆分成为服务后,一个业务逻辑的完成一般需要多个服务的协作才能完成,每个服务都会有自己的业务日志,那怎么把各个服务的业务日志串联起来,也会变难,今天我们就聊一下微服务的日志串联的方案。

在早前的文章分布式链路跟踪中的traceid和spanid代表什么? 这里我给大家介绍过 TraceId 和 SpanId 的概念。

微服务难点剖析:服务拆的挺爽,问题是日志该怎么串联起来呢?

  • trace 是请求在分布式系统中的整个链路视图
  • span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图

在微服务的日志串联里,我们同样能使用这两个概念,通过 trace 串联出一个业务逻辑的所有业务日志,span 串联出在单个服务里的业务日志。

而单个微服务的日志串联的时候还有个挑战是怎么把数据库执行过程的一些日志也注入这些 traceid 和 spanid 打到业务日志里。下面我们就分别通过

  • HTTP 服务间的日志追踪参数传递
  • HTTP 和 RPC 服务间的追踪参数传递
  • ORM 的日志中注入追踪参数

来简述一下微服务业务日志串联的思路。提前声明本文中给出的解决方案更多是 Go 技术栈的,其他语言的技术栈有些方案实现跟这里列举的稍有不同,尤其是 Java 一些开源库上比较容易实现的东西在 Go 这里并不简单。

其实如果使用 APM 的话,是有比较统一的解决方案的,比如接入 Skywalking 就可以,不过还是有额外的学习成本以及需要引入外部系统组件的。

HTTP 服务间的日志追踪参数传递

HTTP 服务间的追踪参数传递,主要是靠在全局的路由中间件来搞,我们可以在请求头里指定 TraceId 和 SpanId。当然如果是请求到达的第一个服务,则生成 TraceId 和 SpanId,加到Header 里往下传。

  1. type Middleware func(http.HandlerFunc) http.HandlerFunc
  2. func withTrace() Middleware {
  3. // 创建中间件
  4. return func(f http.HandlerFunc) http.HandlerFunc {
  5. return func(w http.ResponseWriter, r *http.Request) {
  6. traceID := r.Header.Get("xx-tranceid")
  7. parentSpanID := r.Header.Get("xx-spanid")
  8. spanID := genSpanID(r.RemoteAddr)
  9. if traceID == "" {// traceID为空,证明是初始调用,让root span id == trace id
  10. traceId = spanID
  11. }
  12. // 把 追踪参数通过 Context 在服务内部处理中传递
  13. ctx := context.WithValue(r.Context(), "trace-id", traceID)
  14. ctx := context.WithValue(ctx, "pspan-id", parentSpanID)
  15. ctx := context.WithValue(ctx, "span-id", parentSpanID)
  16. r.WithContext(ctx)
  17. // 调用下一个中间件或者最终的handler处理程序
  18. f(w, r)
  19. }
  20. }
  21. }

上面主要通过在中间件程序,获取 Header 头里存储的追踪参数,把参数保存到请求的 Context 中在服务内部传递。上面的程序有几点需要说明:

  • genSpanID 是根据远程客户端IP 生成唯一 spanId 的方法,生成方法只要保证哈希串唯一就行。
  • 如果服务是请求的开始,在生成spanId 的时候,我们把它也设置成 traceId,这样就能通过 spanId == traceId 判断出当前的 Span 是请求的开端、即 root span。

接下来往下游服务发起请求的时候,我们需要在把 Context 里存放的追踪参数,放到 Header 里接着往下个 HTTP 服务传。

  1. func HttpGet(ctx context.Context url string, data string, timeout int64) (code int, content string, err error) {
  2. req, _ := http.NewRequest("GET", url, strings.NewReader(data))
  3. defer req.Body.Close()
  4. req.Header.Add("xx-trace-tid", ctx.Value("trace-id").(string))
  5. req.Header.Add("xx-trace-tid", ctx.Value("span-id").(string))
  6. client := &http.Client{Timeout: time.Duration(timeout) * time.Second}
  7. resp, error := client.Do(req)
  8. }

HTTP 和 RPC 服务间的追踪参数传递

上面咱们说的上下游服务都是 HTTP 服务的追踪参数传递,那如果是 HTTP 服务的下游是 RPC 服务呢?

其实跟发HTTP请求可以配置HTTP客户端携带 Header 和 Context 一样,RPC客户端也支持类似功能。以 gRPC 服务为例,客户端调用RPC 方法时,在可以携带的元数据里设置这些追踪参数。

  1. traceID := ctx.Value("trace-id").(string)
  2. traceID := ctx.Value("trace-id").(string)
  3. md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
  4. // 新建一个有 metadata 的 context
  5. ctx := metadata.NewOutgoingContext(context.Background(), md)
  6. // 单向的 Unary RPC
  7. response, err := client.SomeRPCMethod(ctx, someRequest)

RPC 的服务端的处理方法里,可以再通过 metadata 把元数据里存储的追踪参数取出来。

  1. func (s server) SomeRPCMethod(ctx context.Context, req *xx.someRequest) (reply *xx.SomeReply, err error) {
  2. remote, _ := peer.FromContext(ctx)
  3. remoteAddr := remote.Addr.String()
  4. // 生成本次请求在当前服务的 spanId
  5. spanID := utils.GenerateSpanID(remoteAddr)
  6. traceID, pSpanID := "", ""
  7. md, _ := metadata.FromIncomingContext(ctx)
  8. if arr := md["xx-tranceid"]; len(arr) > 0 {
  9. traceID = arr[0]
  10. }
  11. if arr := md["xx-spanid"]; len(arr) > 0 {
  12. pSpanID = arr[0]
  13. }
  14. return
  15. }

有一个概念我们需要注意一下,代码里是把上游传过来的 spanId 作为本服务的 parentSpanId 的,本服务处理请求时候的 spanId 是需要重新生成的,生成规则在之前我们介绍过。

除了 HTTP 网关调用 RPC 服务外,处理请求时也经常出现 RPC 服务间的调用,那这种情况该怎么弄呢?

RPC 服务间的追踪参数传递

其实跟 HTTP 服务调用 RPC 服务情况类似,如果上游也是 RPC 服务,那么则应该在接收到的上层元数据的基础上再附加的元数据。

  1. md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
  2. mdOld, _ := metadata.FromIncomingContext(ctx)
  3. md = metadata.Join(mdOld, md)
  4. ctx = metadata.NewOutgoingContext(ctx, md)

当然如果我们每个客户端调用和RPC 服务方法里都这么搞一遍得类似,gRPC 里也有类似全局路由中间件的概念,叫拦截器,我们可以把追踪参数传递这部分逻辑封装在客户端和服务端的拦截器里。

gRPC 拦截器的详细介绍请看我之前的文章 -- gRPC生态里的中间件

客户端拦截器

  1. func UnaryClientInterceptor(ctx context.Context, ... , opts ...grpc.CallOption) error {
  2. md := metadata.Pairs("xx-traceid", traceID, "xx-spanid", spanID)
  3. mdOld, _ := metadata.FromIncomingContext(ctx)
  4. md = metadata.Join(mdOld, md)
  5. ctx = metadata.NewOutgoingContext(ctx, md)
  6. err := invoker(ctx, method, req, reply, cc, opts...)
  7. return err
  8. }
  9. // 连接服务器
  10. conn, err := grpc.Dial(*address, grpc.WithInsecure(),grpc.WithUnaryInterceptor(UnaryClientInterceptor))

服务端拦截器

  1. func UnaryServerInterceptor(ctx context.Context, req interface{}, info *grpc.UnaryServerInfo, handler grpc.UnaryHandler) (resp interface{}, err error) {
  2. remote, _ := peer.FromContext(ctx)
  3. remoteAddr := remote.Addr.String()
  4. spanID := utils.GenerateSpanID(remoteAddr)
  5. // set tracing span id
  6. traceID, pSpanID := "", ""
  7. md, _ := metadata.FromIncomingContext(ctx)
  8. if arr := md["xx-traceid"]; len(arr) > 0 {
  9. traceID = arr[0]
  10. }
  11. if arr := md["xx-spanid"]; len(arr) > 0 {
  12. pSpanID = arr[0]
  13. }
  14. // 把 这些ID再搞到 ctx 里,其他两个就省略了
  15. ctx := Context.WithValue(ctx, "traceId", traceId)
  16. resp, err = handler(ctx, req)
  17. return
  18. }

ORM 的日志中注入追踪参数

其实,如果你用的是 GORM 注入这个参数是最难的,如果你是 Java 程序员的话,可能会对比如阿里巴巴的 Druid 数据库连接池加入类似 traceId 这种参数习以为常,但是 Go 的 GORM 库确实做不到,也有可能新版本可以,我用的还是老版本,其他 Go 的 ORM 库没有接触过,知道的同学可以留言给我们普及一下。

GORM做不到在日志里加入追踪参数的原因就是这个GORM 的 logger 没有实现SetContext方法,所以除非修改源码中调用db.slog的地方,否则无能为力。

不过话也不能说死,之前介绍过一种使用函数调用栈实现 Goroutine Local Storage 的库 jtolds/gls ,我们可以通过它在外面封装一层来实现,并且还需要重新实现 GORM Logger 的打印日志的 Print 方法。

下面大家感受一下,GLS 库的使用,确实有点点怪,不过能过。

  1. func SetGls(traceID, pSpanID, spanID string, cb func()) {
  2. mgr.SetValues(gls.Values{traceIDKey: traceID, pSpanIDKey: pSpanID, spanIDKey: spanID}, cb)
  3. }
  4. gls.SetGls(traceID, pSpanID, spanID, func() {
  5. data, err = findXXX(primaryKey)
  6. })

重写 Logger 的我就简单贴贴,核心思路还是在记录SQL到日志的时候,从调用栈里把 traceId 和 spanId 取出来放一并加入到日志记录里。

  1. // 对Logger 注册 Print方法
  2. func (l logger) Print(values ...interface{}) {
  3. if len(values) > 1 {
  4. // ...
  5. l.sqlLog(sql, args, duration, path.Base(source))
  6. } else {
  7. err := values[2]
  8. log.Error("source", source, "err", err)
  9. }
  10. }
  11. }
  12. func (l logger) sqlLog(sql string, args []interface{}, dur time.Duration, source string) {
  13. argsArr := make([]string, len(args))
  14. for k, v := range args {
  15. argsArr[k] = fmt.Sprintf("%v", v)
  16. }
  17. argsStr := strings.Join(argsArr, ",")
  18. spanId := gls.GetSpanId()
  19. traceId := gls.GetTraceId()
  20. //对于超时的,统一打warn日志
  21. if dur > (time.Millisecond * 500) {
  22. log.Warn("xx-traceid", traceId, "xx-spanid", spanId, "sql", sql, "args_detal", argsStr, "source", source)
  23. } else {
  24. log.Debug("xx-traceid", traceId, "xx-spanid", spanId, "sql", sql, "args_detal", argsStr, "source", source)
  25. }
  26. }

通过调用栈获取 spanId 和 traceId 的是类似这样的方法,由 GLS 库提供的方法封装实现。

  1. // Get spanID 用于Goroutine的链路追踪
  2. func GetSpanID() (spanID string) {
  3. span, ok := mgr.GetValue(spanIDKey)
  4. if ok {
  5. spanID = span.(string)
  6. }
  7. return
  8. }

日志打印的话,也是对超过 500 毫秒的SQL执行进行 Warn 级别日志的打印,方便线上环境分析问题,而其他的SQL执行记录,因为使用了 Debug 日志级别只会在测试环境上显示。

总结

用分布式链路追踪参数串联起整个服务请求的业务日志,在线上的分布式环境中是非常有必要的,其实上面只是简单阐述了一些思路,只有把日志搞的足够好,上下文信息足够多才会能高效地定位出线上问题。感觉这部分细节太多,想用一篇文章阐述明白非常困难。

而且还有一点就是日志错误级别的选择也非常有讲究,如果本该用Debug的地方,用了 Info 级别,那线上日志就会出现非常多的干扰项。

细节的地方怎么实现,就属于实践的时候才能把控的了。希望这篇文章能给你个实现分布式日志追踪的主旨思路。

原文链接:https://mp.weixin.qq.com/s/M2jNnLkYaearwyRERnt0tA