--- 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["网关层
聚合 + 鉴权"] end subgraph Services["业务服务"] extSrv["Server :50052
(外网)
UserService + OrderService"] intSrv["Server :50051
(内网)
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 服务注册详解]]