Files
cs-note/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/05-Multi-Port 多端口暴露.md
T
2026-05-24 11:42:38 +08:00

5.2 KiB

tags, create time
tags create time
gRPC
Go
Server
Network
2026-05-13 23:35

Multi-Port — 多端口暴露的正确姿势

概述

有时候你希望同一个服务同时监听多个端口——比如内网和外网分开、HTTP 和 gRPC 分开、IPv4 和 IPv6 双栈。这看起来很自然,但有一个 经典坑 值得单独一篇文章来讲。

❌ 常见误区:一个 Server 多个 Listener

新手最容易想到的写法:

srv := grpc.NewServer(opts...)
pb.RegisterUserServiceServer(srv, svc)

lis1, _ := net.Listen("tcp", ":50051")  // 内网
lis2, _ := net.Listen("tcp", ":50052")  // 外网

go func() { _ = srv.Serve(lis1) }()
go func() { _ = srv.Serve(lis2) }()   // ← 这段有问题

看起来完美对吧?两个端口都监听了,请求都能进来。但这是错误的。

为什么错?

grpc.Server 内部的连接状态、stream 管理器都是非线程安全的。同一个 Serve() 被两个 goroutine 并发调用会导致 race condition——可能在某次调用中出现偶现 panic。

底层原因是 Serve() 内部会修改 server 的连接表(添加/删除连接),而这个操作没有锁保护。

实际运行中,轻则出现偶现的 panic: concurrent goroutine,重则导致服务直接崩溃重启——而且在开发阶段往往不触发,到了高并发生产环境才暴露出来,非常难排查。

[!summary] 误区 vs 正确做法对比

维度 ❌ 一个 Server 多个 Serve() ✅ 每个端口独立 Server
线程安全 未定义行为,race condition 完全隔离,无竞争
调试难度 偶现 panic,极难复现 确定性好,稳定
差异化暴露 只能暴露相同的服务集 可按端口自定义
优雅关闭 需手动协调两个 Serve() 各自 GracefulStop

✅ 正确做法:每个端口独立 Server 实例

// 内网 server:只暴露 UserService
srv1 := grpc.NewServer(opts...)
pb.RegisterUserServiceServer(srv1, userSvc)

// 外网 server:暴露 UserService + OrderService
srv2 := grpc.NewServer(opts...)
pb.RegisterUserServiceServer(srv2, userSvc)
pb.RegisterOrderServiceServer(srv2, orderSvc)

// 分别监听
lis1, _ := net.Listen("tcp", ":50051")
lis2, _ := net.Listen("tcp", ":50052")

go func() {
    if err := srv1.Serve(lis1); err != nil {
        log.Printf("srv1 error: %v", err)
    }
}()

go func() {
    if err := srv2.Serve(lis2); err != nil {
        log.Printf("srv2 error: %v", err)
    }
}()

// GracefulStop 各管各的
<-ctx.Done()
srv1.GracefulStop()
srv2.GracefulStop()

关键点:

  • 每个端口一个独立的 grpc.Server——彼此隔离,没有状态竞争
  • 可以实现复用——同一个 userSvc 实例注册到两个 server 上,节省内存
  • 差异化暴露——内网 server 只挂核心 service,外网 server 额外挂更多 service

架构模式:内外网分离

这种模式在微服务架构中很常见:

flowchart LR
    subgraph Internet["互联网"]
        Client["External Client"]
    end

    subgraph Gateway["API Gateway"]
        GW["网关层<br/>聚合 + 鉴权"]
    end

    subgraph Services["业务服务"]
        extSrv["Server :50052<br/>(外网)<br/>UserService + OrderService"]
        intSrv["Server :50051<br/>(内网)<br/>UserService"]
    end

    Client -->|HTTPS| GW
    GW -->|gRPC| extSrv
    GW -.->|直连| intSrv
    
    style Internet fill:#FFF3E0
    style Gateway fill:#E3F2FD
    style Services fill:#A8E6CF

内网端口供同 VPC 内的其他服务直连调用(低延迟、无需认证),外网端口由 gateway 统一接入(做鉴权、限流、日志)。两者物理隔离,安全边界清晰。

关键实践:Service 复用

虽然 grpc.Server 不能共享,但 Service 实现可以安全复用。同一个服务实例注册到多个 server 上没有任何问题——因为每个 server 的调用都在各自 goroutine 中串行分发,真正并发的只是业务逻辑层本身。

如果需要按需懒加载 server(启动时不占用过多文件描述符),可以用 sync.Once 控制初始化时机:

var (
    userSrv   *grpc.Server
    userSrvOnce sync.Once
)

func getInternalUserServer() *grpc.Server {
    userSrvOnce.Do(func() {
        userSrv = newServer()
        pb.RegisterUserServiceServer(userSrv, userSvc)
    })
    return userSrv
}

这样可以在不同端口上按需组合 server,适合配置驱动的微服务架构。

注意事项

[!warning] 不要共享 ListenConfig 每个端口还是应该各自 net.Listen(),不要尝试共用同一个 listener。Listener 本身也不是并发安全的(多个 Serve() 争用一个 lis 会产生不确定行为)。

[!tip] 相同 options 封装一下 如果多个 server 用相同的选项(TLS、keepalive 等),建议封装一个 helper 函数,避免重复写配置:

func newServer(opts ...grpc.ServerOption) *grpc.Server {
    return grpc.NewServer(append(defaultOpts, opts...)...)
}

关联笔记

  • hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/06-Graceful Shutdown 优雅关闭
  • hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/03-Service Registration 服务注册详解