Files
cs-note/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/05-Multi-Port 多端口暴露.md
T

155 lines
5.2 KiB
Markdown
Raw Normal View History

2026-05-24 11:42:38 +08:00
---
tags: [gRPC, Go, Server, Network]
create time: 2026-05-13 23:35
---
# Multi-Port — 多端口暴露的正确姿势
## 概述
有时候你希望同一个服务同时监听多个端口——比如内网和外网分开、HTTP 和 gRPC 分开、IPv4 和 IPv6 双栈。这看起来很自然,但有一个 **经典坑** 值得单独一篇文章来讲。
## ❌ 常见误区:一个 Server 多个 Listener
新手最容易想到的写法:
```go
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 实例
```go
// 内网 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
## 架构模式:内外网分离
这种模式在微服务架构中很常见:
```mermaid
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` 控制初始化时机:
```go
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 函数,避免重复写配置:
```go
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 服务注册详解]]