Go微服务中的trace日志串联

Posted Go招聘

tags:

篇首语:本文由小常识网(cha138.com)小编为大家整理,主要介绍了Go微服务中的trace日志串联相关的知识,希望对你有一定的参考价值。

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

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

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

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

图片
  • trace 是请求在分布式系统中的整个链路视图
  • span 则代表整个链路中不同服务内部的视图,span 组合在一起就是整个 trace 的视图
  • 在微服务的日志串联里,我们同样能使用这两个概念,通过 trace 串联出一个业务逻辑的所有业务日志,span 串联出在单个服务里的业务日志。

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

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

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

    return


    ORM 的日志中注入追踪参数

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

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

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

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

    func SetGls(traceID, pSpanID, spanID string, cb func()) 
      mgr.SetValues(gls.ValuestraceIDKey: traceID, pSpanIDKey: pSpanID, spanIDKey: spanID, cb)


    gls.SetGls(traceID, pSpanID, spanID, func() 
       data, err =  findXXX(primaryKey)
    )

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

    // 对Logger 注册 Print方法
    func (l logger) Print(values ...interface) 
     if len(values) > 1 
       // ...
       l.sqlLog(sql, args, duration, path.Base(source))

       else 
       err := values[2]
       log.Error("source", source, "err", err)
      
     


    func (l logger) sqlLog(sql string, args []interface, dur time.Duration, source string) 
     argsArr := make([]stringlen(args))
     for k, v := range args 
      argsArr[k] = fmt.Sprintf("%v", v)
     
     argsStr := strings.Join(argsArr, ",")
      
      spanId := gls.GetSpanId()
      traceId := gls.GetTraceId()
     //对于超时的,统一打warn日志
     if dur > (time.Millisecond * 500
      log.Warn("xx-traceid", traceId, "xx-spanid", spanId, "sql", sql, "args_detal", argsStr, "source", source)
      else 
      log.Debug("xx-traceid", traceId, "xx-spanid", spanId, "sql", sql, "args_detal", argsStr, "source", source)
     

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

    //  Get spanID 用于Goroutine的链路追踪
    func GetSpanID() (spanID string) 
     span, ok := mgr.GetValue(spanIDKey)
     if ok 
      spanID = span.(string)
     
     return

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

    总结

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

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

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


    觉得有用就点个在看 

    Dubbo 全链路追踪日志的实现

    微服务架构的项目,一次请求可能会调用多个微服务,这样就会产生多个微服务的请求日志,当我们想要查看整个请求链路的日志时,就会变得困难,所幸的是我们有一些集中日志收集工具,比如很热门的ELK,我们需要把这些日志串联起来,这是一个很关键的问题,如果没有串联起来,查询起来很是很困难,我们的做法是在开始请求系统时生成一个全局唯一的id,这个id伴随这整个请求的调用周期,即当一个服务调用另外一个服务的时候,会往下传递,形成一条链路,当我们查看日志时,只需要搜索这个id,整条链路的日志都可以查出来了。

    现在以dubbo微服务架构为背景,举个栗子:

    A -> B -> C
    

    我们需要将A/B/C/三个微服务间的日志按照链式打印,我们都知道Dubbo的RpcContext只能做到消费者和提供者共享同一个RpcContext,比如A->B,那么A和B都可以获取相同内容的RpcContext,但是B->C时,A和C就无法共享相同内容的RpcContext了,也就是无法做到链式打印日志了。

    那么我们是如何做到呢?

    我们可以用左手交换右手的思路来解决,假设左手是线程的ThreadLocal,右手是RpcContext,那么在交换之前,我们首先将必要的日志信息保存到ThreadLocal中。

    在我们的项目微服务中大致分为两种容器类型的微服务,一种是Dubbo容器,这种容器的特点是只使用spring容器启动,然后使用dubbo进行服务的暴露,然后将服务注册到zookeeper,提供服务给消费者;另一种是SpringMVC容器,也即是我们常见的WEB容器,它是我们项目唯一可以对外开放接口的容器,也是充当项目的网关功能。

    在了解了微服务容器之后,我们现在知道了调用链的第一层一定是在SpringMVC容器层中,那么我们直接在这层写个自定义拦截器就ojbk了,talk is cheap,show you the demo code:

    举例一个Demo代码,公共拦截器的前置拦截中代码如下:

    public class CommonInterceptor implements HandlerInterceptor {
        @Override
        public boolean preHandle(HttpServletRequest httpServletRequest, HttpServletResponse httpServletResponse, Object handler)
            throws Exception {
    
            // ...
    
            // 初始化全局的Context容器
            Request request = initRequest(httpServletRequest);
            // 新建一个全局唯一的请求traceId,并set进request中
            request.setTraceId(JrnGenerator.genTraceId());
            // 将初始化的请求信息放进ThreadLocal中
            Context.initialLocal(request);
    
            // ...
    
            return true;
        }
        
        // ...
        
    }
    
    

    系统内部上下文对象:

    public class Context {
        
        // ...
        
        private static final ThreadLocal<Request> REQUEST_LOCAL = new ThreadLocal<>();
        
        public final static void initialLocal(Request request) {
            if (null == request) {
                return;
            }
            REQUEST_LOCAL.set(request);
        }
        
        public static Request getCurrentRequest() {
            return REQUEST_LOCAL.get();
        }
        
        // ...
    }
    

    拦截器实现了org.springframework.web.servlet.HandlerInterceptor接口,它的主要作用是用于拦截处理请求,可以在MVC层做一些日志记录与权限检查等操作,这相当于MVC层的AOP,即符合横切关注点的所有功能都可以放入拦截器实现。

    这里的initRequest(httpServletRequest);就是将请求信息封装成系统内容的请求对象Request,并初始化一个全局唯一的traceId放进Request中,然后再把它放进系统内部上下文ThreadLocal字段中。

    接下来讲讲如何将ThreadLocal中的内容放到RpcContext中,在讲之前,我先来说说Dubbo基于spi扩展机制,官方文档对拦截器扩展解释如下:

    服务提供方和服务消费方调用过程拦截,Dubbo 本身的大多功能均基于此扩展点实现,每次远程方法执行,该拦截都会被执行,请注意对性能的影响。

    也就是说我们进行服务远程调用前,拦截器会对此调用进行拦截处理,那么就好办了,在消费者调用远程服务之前,我们可以偷偷把ThreadLocal的内容放进RpcContext容器中,我们可以基于dubbo的spi机制扩展两个拦截器,一个在消费者端生效,另一个在提供者端生效:

    在META-INF中加入com.alibaba.dubbo.rpc.Filter文件,内容如下:

    provider=com.objcoding.dubbo.filter.ProviderFilter
    consumer=com.objcoding.dubbo.filter.ConsumerFilter
    

    消费者端拦截处理:

    
    public class ConsumerFilter implements Filter {
        @Override
        public Result invoke(Invoker<?> invoker, Invocation invocation) 
            throws RpcException {
    
            //1.从ThreadLocal获取请求信息
            Request request = Context.getCurrentRequest();
            //2.将Context参数放到RpcContext
            RpcContext rpcCTX = RpcContext.getContext();
            // 将初始化的请求信息放进ThreadLocal中
            Context.initialLocal(request);
    
            // ...
    
        }   
    }
    

    Context.getCurrentRequest();就是从ThreadLocal中拿到Request请求内容,contextToDubboContext(request);将Request内容放进当前线程的RpcContext容器中。

    很容易联想到提供者也就是把RpcContext中的内容拿出来放到ThreadLocal中:

    public class ProviderFilter extends AbstractDubboFilter implements Filter{
         @Override
        public Result invoke(Invoker<?> invoker, Invocation invocation) 
            throws RpcException {
    		// 1.获取RPC远程调用上下文
            RpcContext rpcCTX = RpcContext.getContext();
            // 2.初始化请求信息
            Request request = dubboContextToContext(rpcCTX);
            // 3.将初始化的请求信息放进ThreadLocal中
            Context.initialLocal(request);
    
            // ...
    
        }   
    }
    

    接下来我们还要配置log4j2,使得我们同一条请求在关联的每一个容器打印的消息,都有一个共同的traceId,那么我们在ELK想要查询某个请求时,只需要搜索traceId,就可以看到整条请求链路的日志了。

    我们在Context上下文对象的initialLocal(Request request)方法中在log4j2的上下文中添加traceId信息:

    public class Context {
        
        // ...
    
        final public static String TRACEID = "_traceid";
    
        public final static void initialLocal(Request request) {
            if (null == request) {
                return;
            }
            // 在log4j2的上下文中添加traceId
            ThreadContext.put(TRACEID, request.getTraceId());
            REQUEST_LOCAL.set(request);
        }
        
        // ...
    }
    

    接下来实现org.apache.logging.log4j.core.appender.rewrite.RewritePolicy

    @Plugin(name = "Rewrite", category = "Core", elementType = "rewritePolicy", printObject = true)
    public final class MyRewritePolicy implements RewritePolicy {
    
        // ...
        
        @Override
        public LogEvent rewrite(final LogEvent source) {
            HashMap<String, String> contextMap = Maps.newHashMap(source.getContextMap());
            contextMap.put(Context.TRACEID, contextMap.containsKey(Context.TRACEID) ? contextMap.get(Context.TRACEID) : NULL);
            return new Log4jLogEvent.Builder(source).setContextMap(contextMap).build();
        }
        
        // ...
    }
    

    RewritePolicy的作用是我们每次输出日志,log4j都会调用这个类进行一些处理的操作。

    配置log4j2.xml:

    <Configuration status="warn">
        <Appenders>
            <Console name="Console" target="SYSTEM_OUT">
    			<PatternLayout
    				pattern="[%d{yyyy/MM/dd HH:mm:ss,SSS}][${ctx:_traceid}]%m%n" />
    		</Console>
    		
            <!--定义一个Rewrite-->
            <Rewrite name="Rewrite">
                <MyRewritePolicy/>
                <!--引用输出模板-->
                <AppenderRef ref="Console"/>
            </Rewrite>
        </Appenders>
        <Loggers>
           
            <!--使用日志模板-->
            <Logger name="com.objcoding.MyLogger" level="debug" additivity="false">
    			<!--引用Rewrite-->
                <AppenderRef ref="Rewrite"/>
            </Logger>
        </Loggers>
    </Configuration>
    

    自定义日志类:

    public class MyLogger {
        private static final Logger logger = LoggerFactory.getLogger(MyLogger.class);
        
         public static void info(String msg, Object... args) {
            if (canLog() == 1 && logger.isInfoEnabled()) {
                logger.info(msg, args);
            }
        }
        
        public static void debug(String message, Object... args) {
            if (canLog() == 1 && logger.isDebugEnabled()) {
                logger.debug(message, args);
            }
        }
        
        // ..
    }
    

    更多精彩文章请关注作者维护的公众号「后端进阶」,这是一个专注后端相关技术的公众号。
    关注公众号并回复「后端」免费领取后端相关电子书籍。
    欢迎分享,转载请保留出处。

    公众号「后端进阶」,专注后端技术分享!

    以上是关于Go微服务中的trace日志串联的主要内容,如果未能解决你的问题,请参考以下文章

    Dubbo 全链路追踪日志的实现

    微服务不是软件工程银弹的10个原因

    34 | 深入理解微服务架构:银弹 or 焦油坑?

    深入理解微服务架构:银弹or焦油坑?

    Istio可观测性(链路)

    Summary of《认识微服务——一颗银弹》