252 lines
8.6 KiB
Markdown
252 lines
8.6 KiB
Markdown
---
|
||
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 服务注册详解]]
|