列舉幾個 Go 語言常見的坑

我喜歡 Go 語言有幾個原因:

  1. 語言本身極其簡潔(只有 25 個關鍵字);

  2. 能輕而易舉地實現交叉編譯;

  3. 天然支持創建可靠的 HTTP 服務器;

從根本上來講,Go 是一種 boring 的語言,可能這就是爲什麼可以用它來開發一些諸如 Docker 和 Kubernetes 等很棒的項目,像 Cloudflare 等具有高性能和彈性要求的公司也正在使用它。

儘管上手很容易,但是有很多細節還是值得關注。如果你在不清楚的情況下編寫代碼,很可能會導致各種稀奇古怪的問題,並且很難發現和糾正錯誤。

下面會給大家列舉一些常見錯誤,是在 review 生產代碼時發現的。希望你再遇到相同問題時能輕鬆地解決。

HTTP 超時時間

HTTP 超時時間,其實在點擊已經跟大家討論過這個問題。但仍然值得再提一提,因爲好的解決方案總是需要更多的時間思考的。

使用默認的 HTTP 客戶端可以發出 HTTP 請求,爲了說明問題,下面是一個使用 GET 請求訪問 google.com 的例子:

 1package main
 2
 3import (
 4    "io/ioutil"
 5    "log"
 6    "net/http"
 7)
 8var (
 9    c = &http.Client{}
10)
11func main() {
12    req, err := http.NewRequest("GET", "google.com", nil)
13    if err != nil {
14        log.Fatal(err)
15    }
16    res, err := c.Do(req)
17    if err != nil {
18        log.Fatal(err)
19    }
20    defer res.Body.Close()
21    b, _ := ioutil.ReadAll(res.Body)
22    ...
23}
24

正如文章指出的,默認的 HTTP 客戶端沒有設置超時時間,這意味着請求有可能會被長時間掛起(ps:具體原因可以查看原文)

所以,解決這個問題最好的辦法是什麼呢?

&http.Client{Timeout: time.Minute},給 HTTP 客戶端定義一個合理的超時時間。你也可以考慮給 HTTP 請求加上 context,這樣做有幾個好處:

  1. 有能力取消正在進行的 HTTP 請求;

  2. 爲一些特殊請求指定超時時間;

第 2 個好處顯得尤爲重要,比如你知道有幾個請求需要耗時很長時間,超過 1 個小時。但是你又不想每個請求都設置這麼長的超時時間,你就可以只針對特殊請求設置比較長的超時時間。

上面的例子中,如果加上 context 代碼會像下面這樣:

1ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
2defer cancel()
3req = req.WithContext(ctx)
4res, err := c.Do(req)
5...
6
7

請求時間如果超過了超時時間,c.Do() 調用就會返回 DeadlineExceeded 錯誤,可以很容易地處理錯誤或者重試。

數據庫連接

我參與的每一個 Go 項目幾乎都會出現數據庫連接問題。我認爲對剛入門 Go 語言的新手來說,有個難以繞過去的點,sql.DB 對象是併發安全的連接池,而不是單個數據庫連接。這意味着連接使用完之後如果沒有返還給進程池,會輕易導致連接數耗盡,甚至最後導致應用程序宕掉。

例如,數據庫連接池包含打開和空閒連接,分別是通過下面這些選項設置的:

需要注意的是,即使你的最大打開連接數設置成 200,如果連接使用完不返還連接池,應用程序也有可能會耗盡數據庫能接受的最大連接數,最後導致宕機、重啓服務。你需要檢查數據庫設置,以確保正確設置了這些參數。

如果數據庫沒有設置這些參數,應用程序將輕而易舉地耗盡數據庫能接受的連接數。

讓我們回到進程池的問題上,查詢數據庫之後,很多開發人員會忘記關閉 *sql.Rows 對象,這就會導致超出最大連接數限制,並導致死鎖或者高延遲。下面給大家展示下類似的代碼片段:

 1package main
 2import (
 3    "context"
 4    "database/sql"
 5    "fmt"
 6    "log"
 7)
 8var (
 9    ctx context.Context
10    db  *sql.DB
11)
12func main() {
13    age := 27
14    ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
15    defer cancel()
16    rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
17    if err != nil {
18        log.Fatal(err)
19    }
20    for rows.Next() {
21        var name string
22        if err := rows.Scan(&name); err != nil {
23            log.Fatal(err)
24        }
25        fmt.Println(name)
26    }
27    ...
28}
29
30

相信你也注意到,正如能在 HTTP 請求上添加 context 一樣,我們也可以在數據庫查詢時添加超時時間的 context。這沒什麼問題。

正如上面討論的,我們需要關閉 rows 對象將連接返還給進程池,防止連接數超出。

1rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
2if err != nil {
3    log.Fatal(err)
4}
5defer rows.Close()
6
7

如果在函數或者包之間傳遞數據庫連接,尤其難以發現這一點。

goroutine 或者內存泄漏

最後一個要討論的常見問題是 goroutine 泄漏,一般這個問題難以發現,但通常是由開發人員的錯誤引起的。

使用 channel 時通常會發生這種問題,比如:

 1package main
 2func main() {
 3    c := make(chan error)
 4    go func() {
 5        for err := range c {
 6            if err != nil {
 7                panic(err)
 8            }
 9        }
10    }()
11    c <- someFunc()
12    ...
13}
14
15

如果我們不關閉通道 c 或者 someFunc() 不返回錯誤,我們初始化的 goroutine 將會掛起直到程序終止。

我們不可能找出每一個導致 goroutine 泄漏的 地方,我通常採用兩種方法來檢測和消除它們。

第一種方法是在單元測試方法裏使用探測器,比如使用 Uber 開源的 goleak 庫,就像下面這個例子一樣:

1func TestA(t *testing.T) {
2    defer goleak.VerifyNone(t)
3    // test logic here.
4}
5
6

這段代碼就會驗證,在代碼優美關閉 30s 之後是否還有多餘的 goroutine 在運行。

另一種方法是在應用程序的運行實例上使用 Go profiler,並查看存活的 goroutine 數量。其中一種方法就是使用 net/http/pprof 庫,並查看生成的火焰圖。

就像下面這樣使用它:

1import _ "net/http/pprof"
2func someFunc() {
3    go func() {
4        log.Println(http.ListenAndServe("localhost:6060", nil))
5    }
6}
7
8

上面這段代碼,pprof 佔用 6060 端口,對於特別嚴重的泄漏,如果你刷新將會看到協程數量在增多;對於更多的一些微小泄漏問題,則需要查看 profile 發現具體的問題,profile 頁面就像下面這樣:

 1goroutine profile: total 39
 22 @ 0x43cf10 0x44ca6b 0x980600 0x46b301
 3#    0x9805ff    database/sql.(*DB).connectionCleaner+0x36f  /usr/local/go/src/database/sql/sql.go:950
 4
 52 @ 0x43cf10 0x44ca6b 0x980b18 0x46b301
 6#    0x980b17    database/sql.(*DB).connectionOpener+0xe7    /usr/local/go/src/database/sql/sql.go:1052
 7
 82 @ 0x43cf10 0x44ca6b 0x980c4b 0x46b301
 9#    0x980c4a    database/sql.(*DB).connectionResetter+0xfa  /usr/local/go/src/database/sql/sql.go:1065
10...
11

如果你的應用程序是空閒的,但是你又看見大數據量的 goroutine,這說明程序已經有問題了。確認泄漏位置之後,我仍然建議在單元測試中使用探測器,以確保解決問題。

希望上面討論的這些常見錯誤,如果以後你也遇到,可以幫助你更快地識別並完美地解決問題。

作者:Tyler Finethy
原文:https://medium.com/better-programming/common-go-pitfalls-a92197cd96d2

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