目录

Go 微服务踩坑实录:从依赖管理到服务治理的 5 个教训

微服务不是把单体拆开就完了。这篇文章记录了在 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 问题现象

服务重启时,用户反馈请求失败。日志显示:

1
http: Server closed

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 的 preStopterminationGracePeriodSeconds


六、总结

问题根因解决方案
go get 废弃Go 1.17 行为变更使用 go install @version
protobuf panic新旧库冲突统一版本 + go mod tidy
DNS 超时K8s DNS 配置使用 FQDN + 调整 ndots
Goroutine 泄露Context 未 canceldefer cancel()
请求被中断没有优雅退出srv.Shutdown() + preStop

核心教训:微服务的复杂度不在于拆分,而在于分布式环境带来的各种边缘情况。每个问题都需要在代码、配置、基础设施三个层面综合考虑。