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

252 lines
8.6 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
---
tags: [gRPC, Go, Server, Production, Shutdown]
create time: 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()` | 拒绝新连接,等待活跃流完成 | 继续处理直到结束 |
```go
s.Stop() // 粗暴退出,适合紧急场景(panic recovery)
s.GracefulStop() // 优雅退出,生产环境首选
```
> [!tip] 记忆口诀
>
> - **紧急情况** → `Stop()` ——保命要紧
> - **正常关停** → `GracefulStop()` ——对下游负责
> - **兜底保护** → 超时 + `Stop()` ——永远不要完全信任业务代码
## 为什么需要超时兜底?
`GracefulStop` **没有内置超时**。这意味着如果某个 handler 因为缺少 `ctx.Done()` 检测而永远不返回,整个关闭流程就会被卡死——进程永远退不出。
> [!danger] 一个忘记 ctx 的 handler 就能让 GracefulStop 形同虚设
>
> `GracefulStop` 的设计哲学是"等所有请求完成再退出"。但如果你的业务代码里没有响应取消信号,它就真的会无限等下去。生产环境中见过多次因单个慢查询导致关停卡住 20 分钟以上的案例。
所以需要一层超时保护:
```go
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 分别处理运行期错误和关停信号**:
```go
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` |
## 执行流程图
```mermaid
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 写法
```go
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 兜底
### 不检测会怎样?
```go
// ❌ 危险写法
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() }`,没有自然的退出点:
```go
// ❌ 永远不退出的 streaming handler
func (s *orderService) StreamOrders(stream pb.OrderService_StreamOrdersServer) error {
for {
req, err := stream.Recv() // 永远在等,不会停
if err != nil { return err }
// 处理...
}
}
```
```go
// ✅ 正确写法:结合 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 服务注册详解]]