列举几个 Go 语言常见的坑
点击上方“Go编程时光”,选择“加为星标”
第一时间关注Go技术干货!
我喜欢 Go 语言有几个原因:
语言本身极其简洁(只有 25 个关键字);
能轻而易举地实现交叉编译;
天然支持创建可靠的 HTTP 服务器;
从根本上来讲,Go 是一种 boring 的语言,可能这就是为什么可以用它来开发一些诸如 Docker 和 Kubernetes 等很棒的项目,像 Cloudflare 等具有高性能和弹性要求的公司也正在使用它。
尽管上手很容易,但是有很多细节还是值得关注。如果你在不清楚的情况下编写代码,很可能会导致各种稀奇古怪的问题,并且很难发现和纠正错误。
下面会给大家列举一些常见错误,是在 review 生产代码时发现的。希望你再遇到相同问题时能轻松地解决。
HTTP 超时时间
HTTP 超时时间,其实在点击已经跟大家讨论过这个问题。但仍然值得再提一提,因为好的解决方案总是需要更多的时间思考的。
使用默认的 HTTP 客户端可以发出 HTTP 请求,为了说明问题,下面是一个使用 GET 请求访问 google.com 的例子:
package main
import (
"io/ioutil"
"log"
"net/http"
)
var (
c = &http.Client{}
)
func main() {
req, err := http.NewRequest("GET", "google.com", nil)
if err != nil {
log.Fatal(err)
}
res, err := c.Do(req)
if err != nil {
log.Fatal(err)
}
defer res.Body.Close()
b, _ := ioutil.ReadAll(res.Body)
...
}
正如文章指出的,默认的 HTTP 客户端没有设置超时时间,这意味着请求有可能会被长时间挂起(ps:具体原因可以查看原文)
所以,解决这个问题最好的办法是什么呢?
&http.Client{Timeout: time.Minute},给 HTTP 客户端定义一个合理的超时时间。你也可以考虑给 HTTP 请求加上 context,这样做有几个好处:
有能力取消正在进行的 HTTP 请求;
为一些特殊请求指定超时时间;
第 2 个好处显得尤为重要,比如你知道有几个请求需要耗时很长时间,超过 1 个小时。但是你又不想每个请求都设置这么长的超时时间,你就可以只针对特殊请求设置比较长的超时时间。
上面的例子中,如果加上 context 代码会像下面这样:
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
req = req.WithContext(ctx)
res, err := c.Do(req)
...
请求时间如果超过了超时时间,c.Do() 调用就会返回 DeadlineExceeded 错误,可以很容易地处理错误或者重试。
数据库连接
我参与的每一个 Go 项目几乎都会出现数据库连接问题。我认为对刚入门 Go 语言的新手来说,有个难以绕过去的点,sql.DB 对象是并发安全的连接池,而不是单个数据库连接。这意味着连接使用完之后如果没有返还给进程池,会轻易导致连接数耗尽,甚至最后导致应用程序宕掉。
例如,数据库连接池包含打开和空闲连接,分别是通过下面这些选项设置的:
SetConnMaxLifetime,连接可以重用的最长时间;
SetMaxIdleConns,最大的空闲连接数量;
SetMaxOpenConns,最大的打开连接数量;
需要注意的是,即使你的最大打开连接数设置成 200,如果连接使用完不返还连接池,应用程序也有可能会耗尽数据库能接受的最大连接数,最后导致宕机、重启服务。你需要检查数据库设置,以确保正确设置了这些参数。
如果数据库没有设置这些参数,应用程序将轻而易举地耗尽数据库能接受的连接数。
让我们回到进程池的问题上,查询数据库之后,很多开发人员会忘记关闭 *sql.Rows 对象,这就会导致超出最大连接数限制,并导致死锁或者高延迟。下面给大家展示下类似的代码片段:
package main
import (
"context"
"database/sql"
"fmt"
"log"
)
var (
ctx context.Context
db *sql.DB
)
func main() {
age := 27
ctx, cancel := context.WithTimeout(context.Background(), time.Minute)
defer cancel()
rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
if err != nil {
log.Fatal(err)
}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
log.Fatal(err)
}
fmt.Println(name)
}
...
}
相信你也注意到,正如能在 HTTP 请求上添加 context 一样,我们也可以在数据库查询时添加超时时间的 context。这没什么问题。
正如上面讨论的,我们需要关闭 rows 对象将连接返还给进程池,防止连接数超出。
rows, err := db.QueryContext(ctx, "SELECT name FROM users WHERE age=?", age)
if err != nil {
log.Fatal(err)
}
defer rows.Close()
如果在函数或者包之间传递数据库连接,尤其难以发现这一点。
goroutine 或者内存泄漏
最后一个要讨论的常见问题是 goroutine 泄漏,一般这个问题难以发现,但通常是由开发人员的错误引起的。
使用 channel 时通常会发生这种问题,比如:
package main
func main() {
c := make(chan error)
go func() {
for err := range c {
if err != nil {
panic(err)
}
}
}()
c <- someFunc()
...
}
如果我们不关闭通道 c 或者 someFunc() 不返回错误,我们初始化的 goroutine 将会挂起直到程序终止。
我们不可能找出每一个导致 goroutine 泄漏的 地方,我通常采用两种方法来检测和消除它们。
第一种方法是在单元测试方法里使用探测器,比如使用 Uber 开源的 goleak 库,就像下面这个例子一样:
func TestA(t *testing.T) {
defer goleak.VerifyNone(t)
// test logic here.
}
这段代码就会验证,在代码优美关闭 30s 之后是否还有多余的 goroutine 在运行。
另一种方法是在应用程序的运行实例上使用 Go profiler,并查看存活的 goroutine 数量。其中一种方法就是使用 net/http/pprof 库,并查看生成的火焰图。
就像下面这样使用它:
import _ "net/http/pprof"
func someFunc() {
go func() {
log.Println(http.ListenAndServe("localhost:6060", nil))
}
}
上面这段代码,pprof 占用 6060 端口,对于特别严重的泄漏,如果你刷新将会看到协程数量在增多;对于更多的一些微小泄漏问题,则需要查看 profile 发现具体的问题,profile 页面就像下面这样:
goroutine profile: total 39
2 @ 0x43cf10 0x44ca6b 0x980600 0x46b301
# 0x9805ff database/sql.(*DB).connectionCleaner+0x36f /usr/local/go/src/database/sql/sql.go:950
2 @ 0x43cf10 0x44ca6b 0x980b18 0x46b301
# 0x980b17 database/sql.(*DB).connectionOpener+0xe7 /usr/local/go/src/database/sql/sql.go:1052
2 @ 0x43cf10 0x44ca6b 0x980c4b 0x46b301
# 0x980c4a database/sql.(*DB).connectionResetter+0xfa /usr/local/go/src/database/sql/sql.go:1065
...
如果你的应用程序是空闲的,但是你又看见大数据量的 goroutine,这说明程序已经有问题了。确认泄漏位置之后,我仍然建议在单元测试中使用探测器,以确保解决问题。
希望上面讨论的这些常见错误,如果以后你也遇到,可以帮助你更快地识别并完美地解决问题。
作者:Tyler Finethy
原文:https://medium.com/better-programming/common-go-pitfalls-a92197cd96d2
-- END --
⬇⬇⬇