如何在 Go 服務中做鏈路追蹤

在 Java 中解決這個問題比較簡單,可以使用 MDC,在一個進程內共享一個請求的 RequestId。

下面的代碼基於 gin 框架來實現。

  1. 使用全局 map 來實現

使用 map 方案需要在全局維護一個 map,在一個請求進來的時候,會爲每一個請求生成 RequestId,然後在每次在打印日誌的時候,從這個 Map 中通過 goid 獲取到 RequestId,打印到日誌中。

代碼的實現很簡單:

var requestIdMap = make(map[int64]string) // 全局的 Map
func main() {
  r := gin.Default()
  r.Use(Logger()) // 使用中間件
  r.GET("/index", func(c *gin.Context) {
    Info("main goroutine") // 打印日誌
    c.JSON(200, gin.H{
      "message": "index",
    })
  })
  r.Run()
}
func Logger() gin.HandlerFunc {
  return func(c *gin.Context) {
    requestIdMap[goid.Get()] = uuid.New().String() // 在日誌中間件中爲每個請求設定
    c.Next()
  }
}
func Info(msg string)  {
  now := time.Now()
  nowStr := now.Format("2006-01-02 15:04:05")
  fmt.Printf("%s [%s] %s\n", nowStr, requestIdMap[goid.Get()], msg) // 打印日誌
}

這樣的實現很簡單,但是問題也很多。

第一個問題就是,在 Go 程序中,一次請求可能會涉及到多個 goroutine,用這種方式很難在多個 gotoutine 之間傳遞 RequestId。

在下面的代碼中,如果新啓動了一個 goroutine,就會導致日誌中獲取不到 RequestId:

func main() {
  r := gin.Default()
  r.Use(Logger())
  r.GET("/index", func(c *gin.Context) {
    Info("main goroutine")
    go func() {  // 這裏新啓動了一個一個 goroutine
      Info("goroutine1")
    }()
    c.JSON(200, gin.H{
      "message": "index",
    })
  })
  r.Run()
}

獲取 goroutine id 也不是一種常規的做法,一般要通過 hack 的方式來獲取,這種做法已經不推薦了。而且這個全局的 map 爲了併發安全,在實際的使用中,可以還需要用到鎖,在高併發的情況下必然會影響性能。

在每個請求結束的時候,還需要手動的把 requestId 從 map 中刪除,否則就會造成內存泄漏。

總的來說,使用 map 這種方式來實現並不是很好。

  1. 使用 Context 來實現

在傳遞 RequestId 的場景中,同樣也可以使用 Context 來實現,使用 Context 好處很明顯,Context 生命週期與請求相同,不需要手動銷燬。而且 Context 是每個請求獨享的,也不用擔心併發安全的問題,Context 還可以在 goroutine 之間傳遞。

使用 Context 實現的代碼如下:

func main() {
  r := gin.Default()
  r.Use(Logger())
  r.GET("/index", func(c *gin.Context) {
    ctx, _ := c.Get("ctx")
    Info(ctx.(context.Context) , "main goroutine")
    go func() {
      Info(ctx.(context.Context), "goroutine1")
    }()
    c.JSON(200, gin.H{
      "message": "index",
    })
  })
  r.Run()
}
func Logger() gin.HandlerFunc {
  return func(c *gin.Context) {
    valueCtx := context.WithValue(c.Request.Context(), "RequestId", uuid.New().String())
    c.Set("ctx", valueCtx)
    c.Next()
  }
}
func Info(ctx context.Context, msg string)  {
  now := time.Now()
  nowStr := now.Format("2006-01-02 15:04:05")
  fmt.Printf("%s [%s] %s\n", nowStr, ctx.Value("RequestId"), msg)
}

這樣在一個請求中,所有的 gotroutine 都可以獲取到同一個 RequestId,而且不用擔心內存泄漏和併發安全。

但是使用 Context 也有個問題就是需要每次傳遞 Context,很多人還不習慣使用這種方式。其實 Go 官方早就推薦使用 Context 了,通常會把 Context 作爲函數的第一個參數。如果函數使用結構體作爲參數,也可以直接把 Context 作爲結構體的一個字段。

Context 除了使用可以同來傳遞 RequestId 之外,還可以用來控制 goroutine 的生命週期,這些內容在之前的 Context 文章中詳細說明了,感興趣的可以去看看。

  1. 小結

獲取 goroutine id 這種方式應該被拋棄,而是應該使用 Context, Go 官方也早就推薦使用這種方式,在上文中,我們使用 Context 來傳遞 RequestId,除此之外還可以用來傳遞單個請求範圍的值,比如認證的 token 之類的,應該習慣在代碼中使用 Context。

[1] https://blog.golang.org/context

文 / Rayjun

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/q77CX4s08FfL0XQx2BPC8Q