微服务不是把单体拆开就完了。这篇文章记录了在 Go 微服务项目中踩过的 5 个坑,以及如何避免这些问题。
一、go get 废弃导致 CI 构建失败
1.1 问题现象
某天 CI 突然报错:
1
2
3
| go get -u github.com/golang/protobuf/protoc-gen-go
# go: go get -u github.com/golang/protobuf/protoc-gen-go:
# installing executables with 'go get' in module mode is deprecated.
|
1.2 原因分析
Go 1.17 开始,go get 的行为发生了变化:
| 版本 | go get 行为 |
|---|
| 1.16 及之前 | 下载依赖 + 安装可执行文件 |
| 1.17+ | 只管理 go.mod 依赖 |
| 未来版本 | 默认启用 -d 参数 |
1.3 解决方案
1
2
3
4
5
6
| # 旧写法(已废弃)
go get -u github.com/golang/protobuf/protoc-gen-go
# 新写法:使用 go install + 版本号
go install google.golang.org/protobuf/cmd/protoc-gen-go@latest
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@latest
|
1.4 CI 脚本修复
1
2
3
4
5
6
7
8
| # .github/workflows/build.yml
jobs:
build:
steps:
- name: Install protoc plugins
run: |
go install google.golang.org/protobuf/cmd/protoc-gen-go@v1.31.0
go install google.golang.org/grpc/cmd/protoc-gen-go-grpc@v1.3.0
|
教训:锁定工具版本,不要用 @latest。
二、gRPC 版本冲突导致运行时 panic
2.1 问题现象
服务启动时崩溃:
1
| panic: proto: extension number 1001 is already registered
|
2.2 原因分析
项目中同时引用了两个版本的 protobuf 库:
1
2
3
| go mod graph | grep protobuf
# github.com/golang/protobuf@v1.4.3
# google.golang.org/protobuf@v1.31.0
|
github.com/golang/protobuf 是老库,google.golang.org/protobuf 是新库。两者内部会注册相同的扩展号,导致冲突。
2.3 解决方案
方法一:强制统一版本
1
2
3
4
5
6
| // go.mod
require (
google.golang.org/protobuf v1.31.0
)
replace github.com/golang/protobuf => google.golang.org/protobuf v1.31.0
|
方法二:升级所有依赖
1
2
| go get -u ./...
go mod tidy
|
方法三:使用 go mod why 定位
1
2
| # 找出谁引入了老版本
go mod why github.com/golang/protobuf
|
教训:定期运行 go mod tidy,保持依赖图干净。
三、服务发现失败:DNS 解析超时
3.1 问题现象
服务间调用偶发超时,日志显示:
1
| context deadline exceeded (Client.Timeout exceeded while awaiting headers)
|
3.2 排查过程
1
2
3
4
5
| // 问题代码
conn, err := grpc.Dial(
"user-service:8080", // 使用 K8s Service 名称
grpc.WithInsecure(),
)
|
用 tcpdump 抓包发现,DNS 解析偶尔需要 5+ 秒。
3.3 根因
K8s 集群的 CoreDNS 配置了 upstream 外部 DNS,当内部解析失败时会尝试外部解析,导致延迟。
3.4 解决方案
方法一:使用 FQDN
1
2
3
4
5
| // 明确指定完整域名,避免搜索域探测
conn, err := grpc.Dial(
"user-service.default.svc.cluster.local:8080",
grpc.WithInsecure(),
)
|
方法二:K8s DNS 配置优化
1
2
3
4
5
| # Pod spec 中
dnsConfig:
options:
- name: ndots
value: "2" # 减少 DNS 搜索尝试
|
方法三:客户端 DNS 缓存
1
2
3
4
5
6
| import "google.golang.org/grpc/resolver"
func init() {
// 使用 passthrough resolver,绕过 gRPC 的 DNS 解析
resolver.SetDefaultScheme("passthrough")
}
|
教训:微服务的网络问题往往不在代码层面。
四、Context 泄露导致 Goroutine 暴涨
4.1 问题现象
服务运行一段时间后,内存持续增长,pprof 显示大量 goroutine 处于等待状态:
1
2
| go tool pprof http://localhost:6060/debug/pprof/goroutine
# 50000 goroutines, 90% in select {}
|
4.2 问题代码
1
2
3
4
5
6
7
8
9
10
11
12
13
| func HandleRequest(ctx context.Context, req *Request) {
// 创建了新的 context,但没有 cancel
newCtx, _ := context.WithTimeout(ctx, 10*time.Second)
go func() {
// 这个 goroutine 会等待 newCtx.Done()
// 但如果请求提前返回,newCtx 永远不会被 cancel
<-newCtx.Done()
cleanup()
}()
// ... 处理请求
}
|
4.3 正确写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
| func HandleRequest(ctx context.Context, req *Request) {
newCtx, cancel := context.WithTimeout(ctx, 10*time.Second)
defer cancel() // 关键:确保 context 被取消
done := make(chan struct{})
go func() {
defer close(done)
// 异步工作
}()
select {
case <-done:
// 正常完成
case <-newCtx.Done():
// 超时
}
}
|
4.4 检测工具
1
2
3
4
5
6
| // 使用 goleak 检测 goroutine 泄露
import "go.uber.org/goleak"
func TestMain(m *testing.M) {
goleak.VerifyTestMain(m)
}
|
教训:context.WithCancel/Timeout 返回的 cancel 函数必须调用。
五、优雅退出:请求还在处理就被杀了
5.1 问题现象
服务重启时,用户反馈请求失败。日志显示:
5.2 问题代码
1
2
3
4
5
6
7
8
9
10
| func main() {
srv := &http.Server{Addr: ":8080"}
go srv.ListenAndServe()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM)
<-quit
srv.Close() // 立即关闭,不等待请求完成!
}
|
5.3 正确写法
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
| func main() {
srv := &http.Server{Addr: ":8080"}
go func() {
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
log.Fatalf("listen: %s\n", err)
}
}()
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
log.Println("Shutting down server...")
// 优雅退出:等待最多 30 秒让现有请求完成
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
if err := srv.Shutdown(ctx); err != nil {
log.Fatal("Server forced to shutdown:", err)
}
log.Println("Server exited")
}
|
5.4 K8s PreStop Hook
1
2
3
4
5
6
| # Pod spec
lifecycle:
preStop:
exec:
command: ["/bin/sh", "-c", "sleep 5"]
# 给 K8s 时间更新 Endpoints,避免流量继续进来
|
5.5 gRPC 优雅退出
1
2
3
4
5
6
7
8
9
10
11
12
| func main() {
srv := grpc.NewServer()
// ... 注册服务
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGTERM)
<-quit
// GracefulStop 会等待所有 RPC 完成
srv.GracefulStop()
}
|
教训:优雅退出不只是代码问题,还需要配合 K8s 的 preStop 和 terminationGracePeriodSeconds。
六、总结
| 问题 | 根因 | 解决方案 |
|---|
| go get 废弃 | Go 1.17 行为变更 | 使用 go install @version |
| protobuf panic | 新旧库冲突 | 统一版本 + go mod tidy |
| DNS 超时 | K8s DNS 配置 | 使用 FQDN + 调整 ndots |
| Goroutine 泄露 | Context 未 cancel | defer cancel() |
| 请求被中断 | 没有优雅退出 | srv.Shutdown() + preStop |
核心教训:微服务的复杂度不在于拆分,而在于分布式环境带来的各种边缘情况。每个问题都需要在代码、配置、基础设施三个层面综合考虑。