zl程序教程

您现在的位置是:首页 >  其他

当前栏目

利用GLS实现的日志监控

2023-04-18 12:32:17 时间

背景

对于一个现有的基于Go语言开发的整个web服务组件来说,想要将其整个服务运作流程的相关日志获取到并且能够有效的监控这些过程。实际上是有很多方法,不过这些方法实施起来有很多的困难,面临如下问题:

  1. 如何覆盖到每个异常处理流程
  2. 对于现有系统如何低侵入性的支持日志上报、查询等功能

以上两点的第一点很好理解,做好覆盖就好、第二点主要说的是对于一个开发好的完整系统或者组件,代码中零散分布很多日志打印语句,如何在打印日志后将日志上报,一句句的追加显然不现实(代码侵入性高、不易维护),所以需要某种方式的收拢,在某一点上将整个处理流程的相关日志一起上报。而本文则是利用了GLS(Goroutine Local Storage)实现了该方法。

TLS

对于go以外的其他语言,如Java。其对于工作内存和主内存的抽象是比较好的,线程自身可以存放线程独享的数据:

Thread.CurrentThread()
ThreadLocal

首先获取当前线程,再通过 ThreadLocal 即可操作线程局部变量。

然而go语言是没有支持该设计的

GLS

Go语言是不支持GLS的、主要原因是Go语言设计者不愿意暴露GoId,官方想避免Goroutine Id作为GLS的Key。原因很简单:

  1. 在Goroutine结束之后,Go语言无法保证将GLS当中的内存回收,这时候垃圾回收是有问题的。
  2. Goroutine不同于线程(线程可以通过线程池管理起来),程序可以非常easy的产生新的Goroutine,而新的Goroutine会失去GLS的访问能力。需要上层保证不产生新的Goroutine,显然这很难有效控制(特别是涉及第三方库)。

(1)获取GoId

尽管官方不支持这样的做法,但是仍然可以使用(当然要避免以上的问题)

首先,回归日志追踪的诉求,对于一个Go实现的web服务组件,需要理解大致处理Request的方式:

添加描述

首先Go web在接收到Request的时候会经过以上大致过程,通过多路复用器(Selector)走到处理器(Handler)这里,handler通过业务逻辑(包含与DB的交互)得到Response;或者通过模板渲染页面。而每个Request过来后,Go会通过协程来处理。

所以要追踪Request相关日志等信息,就需要了解获取GoId的方法了,主要可以通过两种方式去获取GoId:

  1. runtime.Stack
  2. runtime.Callers

(2)Storage

在协程里面通过Goroutine(处理Request)获取到Request的相关信息,如下:

func (this interfaceHandler) ProcessRequest(write http.ResponseWriter, request *http.Request) (ok bool) {

        ////////////////////////////////////省略////////////////////////////////////////////////////
	gls.Set( "client", request.RemoteAddr )
	gls.Set( "ua", request.UserAgent() )
	gls.Set( "host", request.Host )

        go status.ReportLogRaw( gls.Dump() )
	////////////////////////////////////省略////////////////////////////////////////////////////
}

这样便可以获取到Request相关信息,并上报,而日志信息如何一起上报呢?只需要在日志函数里面加入相关代码即可(截获日志信息并GLS保存),如下代码:

func (l *logger) Error(args ...interface{}) {
	addTrack( l.loggerName, LEVEL_Error, args )
	log4g.GetLogger(l.loggerName).Error(args)
}


func addTrack( loggerName string, level log4g.Level, args ...interface{} ) {
	
	gls.Set( key, append( l, &LogRs{ Level : LeveLNameMap[level], TimeStamp : time.Now().Unix(), Content:fmt.Sprint(args) } ) )
        
}

这样从日志代码的一个收拢点去获取日志信息,并保存到对应协程的storage当中。而Storage的实现也没有特殊的地方,无外乎是通过map+RWMutex来实现的,当然map的Key是GoId,Value就是对应的Storage对象;而Storage也是K-V的map(这就看自己的日志内容了,自己定义K-V关系)。并发通过加锁来控制。当然,在上报日志信息之后要记得Clean掉(不要遗留GC问题)。

(3)Report

这里Report比较简单了,只要上报日志信息到需要的节点即可。这里主要是要说明的是,在上报的时候采用分地域上报,如何利用一个简单的方式实现,让程序自己知道要上报数据到哪一个地域呢?配置文件是一种方式,但当地域多了会变得繁琐。这里提供了另外一种方式,通过本地dns解析,不同地域会解析为不同ip,通过这些ip作map,便可以很轻松的找到自己所属的地域。代码实现如下:

var RegionIdsMap map[int]string = map[int]string{
	1 : "gz",
	4 : "sh",
	5 : "hk",
	6 : "ca",
	7 : "shjr",
	8 : "bj",
	9 : "sg",
	11 : "szjr",
	12 : "gzopen",
	13 : "shysx",
	15 : "usw",
	16 : "cd",
	17 : "de",
	18 : "kr",
	19 : "cq",
	21 : "in",
	22 : "use",
	23 : "th",
	24 : "ru",
	25 : "jp",
 }

func GetRegionId() (int, error) {
	ns, err := net.LookupHost("region-id.nosql.tencentyun.com")
	if err != nil {
		return 0, err
	}

	for _, n := range ns {
		l := strings.Split( n, "." )
		if len( l ) == 4 {
			return strconv.Atoi( l[3] )
		}
	}

	return 0, errors.New( "unknown region" )
}

func GetRegion() (string, error) {
	regionId, err := GetRegionId()
	if err != nil {
		return "", err
	}

	region, exists := RegionIdsMap[ regionId ]
	if !exists {
		return "", fmt.Errorf( "unknown regionId:%d", regionId )
	}

	return region, nil
}

上报结束后,便可以将日志数据组织并展示出来;如下所示:

添加描述

添加描述

优化方向

在此基础上如何做好优化呢?这里面的最明显缺点是什么呢?主要是以下两点:

Goroutine不同于线程,协程可以做到十万以上的并发,多协程同时竞争同一把锁,会造成性能恶化

GoId的获取是通过分析调用Stack信息来间接获取的,这样同样造成“慢”的结果

当然对于并发要求不是特别高的系统这些都不是问题,但是还是有优化的必要,针对上面两个问题,需要考虑的是如何使用更加高效的并发控制方式(改良加锁方式等),或者优化获取GoId的方法。这是接下来需要考虑的工作。