Files
cs-note/hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/06-Graceful Shutdown 优雅关闭.md
T
2026-05-24 11:42:38 +08:00

8.6 KiB
Raw Blame History

tags, create time
tags create time
gRPC
Go
Server
Production
Shutdown
2026-05-13 23:40

Graceful Shutdown — 优雅关闭机制

概述

gRPC 基于 HTTP/2 的长连接设计——一个连接上同时跑着多个 RPC 流。如果直接 kill 进程,所有正在处理的请求会突然断掉,client 收到的是 TCP RST(连接重置),而不是正常的结束信号。这在订单系统、支付场景中可能导致重复扣款、状态不一致。

[!question] 为什么不能等 client 发现连接断了再重试?

能。但从业务角度看就晚了——用户可能看到"重复扣款"的账单投诉到客服。优雅关闭是服务端对下游负责的最后一步。

优雅关闭的目标:拒绝新流量 → 排空已有请求 → 干净退出。

Stop vs GracefulStop

gRPC server 提供两种停止方式:

方法 行为 未完成请求
s.Stop() 立即断开所有连接,不再接受新请求 直接报错断开
s.GracefulStop() 拒绝新连接,等待活跃流完成 继续处理直到结束
s.Stop()       // 粗暴退出,适合紧急场景(panic recovery)
s.GracefulStop() // 优雅退出,生产环境首选

[!tip] 记忆口诀

  • 紧急情况 → Stop() ——保命要紧
  • 正常关停 → GracefulStop() ——对下游负责
  • 兜底保护 → 超时 + Stop() ——永远不要完全信任业务代码

为什么需要超时兜底?

GracefulStop 没有内置超时。这意味着如果某个 handler 因为缺少 ctx.Done() 检测而永远不返回,整个关闭流程就会被卡死——进程永远退不出。

[!danger] 一个忘记 ctx 的 handler 就能让 GracefulStop 形同虚设

GracefulStop 的设计哲学是"等所有请求完成再退出"。但如果你的业务代码里没有响应取消信号,它就真的会无限等下去。生产环境中见过多次因单个慢查询导致关停卡住 20 分钟以上的案例。

所以需要一层超时保护:

shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()

done := make(chan struct{})
go func() {
    srv.GracefulStop()
    close(done)
}()

select {
case <-done:
    log.Println("优雅退出成功")
case <-shutdownCtx.Done():
    log.Println("30s 超时,强制关闭")
    srv.Stop()  // 超时无情斩断
}

30 秒够大部分正常请求完成了。如果一个 handler 超过 30 秒还没返回,那大概率是有问题的 handler 逻辑——继续等下去只会拖慢整个关闭流程。

Signal Handling 完整流程

生产环境的标准写法将上面的片段串起来,核心思路是用两个 select 分别处理运行期错误和关停信号:

func main() {
    // 1. 监听 OS 信号 (Ctrl+C, docker stop, kubectl delete pod)
    ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
    defer stop()

    // 2. 创建 server
    srv, err := NewGRPCServer()
    if err != nil {
        log.Fatal(err)
    }

    // 3. 启动监听(阻塞)
    lis, _ := net.Listen("tcp", ":50051")
    errCh := make(chan error, 1)
    go func() {
        errCh <- srv.Serve(lis)
    }()

    // 4. 等待信号或错误
    select {
    case err := <-errCh:
        log.Printf("server error: %v", err)
        return
    case <-ctx.Done():
        // 收到 SIGTERM/SIGINT → 开始优雅关闭
    }

    // 5. 优雅关闭 + 超时兜底
    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    done := make(chan struct{})
    go func() {
        srv.GracefulStop()
        close(done)
    }()

    select {
    case <-done:
        log.Println("server stopped gracefully")
    case <-shutdownCtx.Done():
        log.Println("shutdown timeout, forcing stop")
        srv.Stop()
    }
}

[!note] 关键设计细节

设计点 原因
signal.NotifyContext Go 1.21+ 推荐用法,比手动 signal.Ch 更简洁安全
errCh 缓冲大小 1 防止 goroutine 在 channel 未准备好时 panic
两个独立的 select 运行期错误直接退出,不需要走优雅关闭流程
srv.Serve 放 goroutine 它本身是阻塞的,不放goroutine会卡住 select

执行流程图

flowchart TD
    SIGTERM["收到 SIGTERM<br/>信号"] --> StopNew["停止接收新连接<br/>新请求返回 UNAVAILABLE"]
    StopNew --> Drain["排空活跃 Stream<br/>等待 handler 处理完成"]
    Drain --> Check{"全部完成?"}
    Check -->|"是"| CleanExit["干净退出<br/>所有回调执行完毕"]
    Check -->|"否, 等待中..."--> Wait["等待中..."]
    Wait --> Check
    Check -->|"超时 30s"| Force["超时触发<br/>srv.Stop() 强制断开"]
    Force --> ForcedExit["强制退出<br/>可能丢失未完成请求"]
    
    style CleanExit fill:#00D866,color:#fff
    style ForcedExit fill:#EE5A24,color:#fff
    style Wait fill:#FFEAA7,color:#000

最大的坑:Handler 里必须检测 ctx.Done()

GracefulStop 能工作的关键前提是:你的 handler 必须响应 context 取消信号。

如果 handler 里完全不检 ctx.Done(),那 GracefulStop 基本等于白写——进程要等到最后一个 handler 跑完才退出。

正确的 handler 写法

func (s *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
    // 随时检查 ctx,收到取消信号立即返回
    select {
    case <-ctx.Done():
        return nil, ctx.Err()  // context canceled / deadline exceeded
    default:
        // 继续执行业务逻辑
    }
    
    // ... 业务逻辑
    
    // 再次检查(长时间操作的中间点)
    select {
    case <-ctx.Done():
        return nil, ctx.Err()
    default:
    }
    
    return &pb.CreateUserResponse{Id: generatedId}, nil
}

[!tip] 什么时候该检 ctx?

  • 调用外部服务时:gRPC/DHTTP client 默认会传递 parent context,你不需要额外处理
  • 长时间循环/睡眠中:在循环体和 sleep 前加 select,避免白白等待
  • 数据库查询前:长查询最好在查询前检测一次,查询中也能靠 db timeout 兜底

不检测会怎样?

// ❌ 危险写法
func (s *userService) CreateUser(ctx context.Context, req *pb.CreateUserRequest) (*pb.CreateUserResponse, error) {
    s.db.Exec("INSERT INTO users ...")  // 不依赖 ctx,永远不会提前返回
    time.Sleep(60 * time.Second)        // 60 秒后才返回
    return &pb.CreateUserResponse{Id: "1"}, nil
}

即使 server 调用了 GracefulStop(),这个 handler 也会继续跑完 60 秒才返回。如果多个 handler 都这样,关停时间会线性叠加。

Streaming Handler 更要小心

Streaming 场景下这个问题更严重——一个 stream 可能是无限循环的 for { recv() },没有自然的退出点:

// ❌ 永远不退出的 streaming handler
func (s *orderService) StreamOrders(stream pb.OrderService_StreamOrdersServer) error {
    for {
        req, err := stream.Recv()  // 永远在等,不会停
        if err != nil { return err }
        // 处理...
    }
}
// ✅ 正确写法:结合 ctx 和 stream.Recv
func (s *orderService) StreamOrders(stream pb.OrderService_StreamOrdersServer) error {
    for {
        select {
        case <-stream.Context().Done():
            return nil  // stream 断开或 ctx 取消 → 安全退出
        default:
        }
        
        req, err := stream.Recv()
        if err != nil {
            return err
        }
        // 处理...
    }
}

[!caution] Streaming handler 不要只用 Recv 的错误返回值判断退出

Recv() 返回非 io.EOF 错误时,不代表 client 断开了连接——可能是权限校验失败、消息格式错误等各种原因。只有在收到 cancel 信号(如 context.Canceled)时才应该 clean up 资源并退出。而 正常请求被中断 正是 GracefulStop 期间最常见的场景。

详见 hhs/gRPC/3. 服务端实现/09-Streaming Handler。

小结

要点 一句话
用 GracefulStop 而非 Stop 优先优雅退出
始终加 30s 超时兜底 防止 handler 永远不返回
handler 里必须检测 ctx.Done() 否则 GracefulStop 形同虚设
Streaming handler 更要小心 for { Recv() } 模式需要主动 break

关联笔记

  • hhs/gRPC/3. 服务端实现/09-Streaming Handler
  • hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/05-Multi-Port 多端口暴露
  • hhs/gRPC/3. 服务端实现/08-Server 搭建与注册/03-Service Registration 服务注册详解