5.2 KiB
tags, create time
| tags | create time | ||||
|---|---|---|---|---|---|
|
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 服务注册详解