Go 企业级工程能力实战(12):服务下线不丢一个请求——优雅关闭从原理到实战

本文是《Go 企业级工程能力实战》系列的第 12 篇,基于我的开源项目 user-service 的真实代码,从零开始拆解 Go HTTP 服务优雅关闭的全过程。


一、开篇引入:一个午夜惨案

小张是公司的一名后端工程师,今天是他值班。晚上 11 点,运维发来消息:"服务要发版了,你确认一下。"

小张看了一眼监控面板,一切正常。他回复:"发吧。"

K8s 开始了滚动更新。旧 Pod 被发送 SIGTERM 信号,迅速终止,新 Pod 启动。

三分钟后,客服群里突然炸了锅。多个用户投诉:"我在加好友,点了确认之后页面卡死了,刷新后发现好友根本没加上!"

小张查了日志,发现就在那一分钟内,有 23 个"同意好友请求"的 API 调用返回了 connection reset by peer

原因很简单:K8s 在发送 SIGTERM 后立刻杀掉了进程,正在处理中的请求还没来得及完成就被中断了。

更糟糕的是,AddFriend 的实现是一个事务------在 dao/FriendsDao.go:148-178 中,它需要写两行数据(双向好友关系)。如果事务写到一半进程被杀死,就会出现"A 的好友列表里有 B,但 B 的好友列表里没有 A"的数据不一致。

小张通宵修复了数据,第二天顶着黑眼圈写了优雅关闭的代码。

这个故事不只是小张一个人的惨案。如果你现在还不会写优雅关闭,那下一个通宵修复数据的可能就是你了。


二、概念铺垫:kill -9 和 SIGTERM 是一回事吗?

在讲优雅关闭之前,先搞清楚操作系统是怎么让一个程序停下来的。

2.1 两种"杀进程"方式

Linux 系统中,停止一个进程有几种信号:

信号 编号 含义 进程能否捕获?
SIGTERM 15 礼貌地请进程自行退出
SIGINT 2 Ctrl+C 发出的中断信号
SIGKILL 9 立即终止,不给任何反应时间 不能

SIGTERM 是"请你走",SIGKILL 是"给我滚"。

当你执行 kill <pid>(不带参数时默认 SIGTERM),操作系统会通知进程:"嘿,该下班了。"进程可以:

  • 完成手头的工作(处理中的请求)
  • 保存数据(flush 日志、关闭数据库连接)
  • 通知依赖方(从注册中心摘除)

当进程做完这些事后再退出------这就是"优雅关闭"。

kill -9 <pid> 是直接抽掉脚下的地板------进程连说一句话的机会都没有。

2.2 K8s 世界的优雅关闭流程

在 Kubernetes 中,Pod 停止会经历以下步骤:

复制代码
1. Pod 被标记为 Terminating → 从 Service 的 Endpoint 列表中移除(不再分配新流量)
2. K8s 向 Pod 内所有容器的主进程发送 SIGTERM 信号
3. 等待 terminationGracePeriodSeconds 秒(默认 30 秒)
4. 如果进程还没退出 → 发送 SIGKILL 强制终止

关键点是第 3 步:K8s 给你了一段"善后时间",你需要在代码里利用好这段时间。

很多同学以为 K8s 会等自己,于是在代码里什么都不做。结果是:进程收到 SIGTERM 后直接退出(Go 的默认行为),K8s 等到超时才发 SIGKILL,但为时已晚------请求已经丢了。

2.3 一个生活类比

优雅关闭就像一个餐厅打烊的过程:

  • kill -9:老板直接拉电闸,客人被关在黑暗里,正在上的菜摔在地上
  • 直接退出:服务员大喊一声"关门了"然后下班,正在点菜的客人愣在原地
  • 优雅关闭:服务员站在门口不再迎客(stop accepting new),等现有客人都用完餐(drain in-flight),厨房关火清理(close DB),然后才锁门下班(exit process)

一个好的服务程序,应该像一个有职业操守的服务员。


三、循序渐进:从"直接退出"到"优雅关闭"的三次迭代

3.1 第一代:裸奔 HTTP 服务

最简单的 Gin 服务启动方式:

go 复制代码
func main() {
    r := gin.Default()
    r.GET("/healthz", func(c *gin.Context) {
        c.JSON(200, gin.H{"status": "ok"})
    })
    r.Run(":8080")  // 阻塞直到进程被 kill
}

太简陋了,Ctrl+C 或者 K8s 发 SIGTERM 后,进程直接退出。所有正在处理的请求全部中断。

3.2 第二代:手动捕获信号但不等待

进阶一点,你会自己启动 http.Server,并捕获操作系统的终止信号:

go 复制代码
func main() {
    srv := &http.Server{Addr: ":8080", Handler: router}
  
    go func() {
        srv.ListenAndServe()  // 在 goroutine 中启动
    }()
  
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
    <-quit  // 等待信号
  
    srv.Close()  // 立即关闭,不等请求完成!
}

Close() 会立即关闭所有活跃连接,本质上和 kill 没区别。我们需要的是 Shutdown()

3.3 第三代:优雅关闭的完整实现

看一下 user-service 项目 cmd/main.go:170-195 中的完整实现:

go 复制代码
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
zapLog.Infof("received signal %v, shutting down...", sig)

ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if httpsServer != nil {
    if err := httpsServer.Shutdown(ctx); err != nil {
        zapLog.Errorf("HTTPS server shutdown error: %v", err)
    }
}
if httpServer != nil {
    if err := httpServer.Shutdown(ctx); err != nil {
        zapLog.Errorf("HTTP server shutdown error: %v", err)
    }
}

sqlDB, err = db.DB()
if err == nil {
    sqlDB.Close()
}

zapLog.Sync()

这就是一个企业级优雅关闭的完整模板。让我们逐行拆解。


四、代码实战:逐行拆解优雅关闭

4.1 signal.Notify:监听系统信号

go 复制代码
quit := make(chan os.Signal, 1)
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
sig := <-quit
  • signal.Notify 注册了 SIGINT(Ctrl+C)和 SIGTERM(K8s 终止信号)
  • channel 容量设为 1,因为信号到来后我们立刻消费,不会积压
  • 进程运行到这里会阻塞,直到接收到这两个信号之一
  • 注意这里没有捕获 SIGKILL------因为它根本不能被捕获,这是操作系统层面的设计

你可能会问:为什么不顺便捕获 SIGHUP?在某些场景下,SIGHUP 用于热加载配置。这是一个可选扩展,但核心的优雅关闭只需要 SIGINTSIGTERM

4.2 http.Server.Shutdown(ctx):关键的一行代码

go 复制代码
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()

if err := httpsServer.Shutdown(ctx); err != nil {
    zapLog.Errorf("HTTPS server shutdown error: %v", err)
}

Shutdown(ctx) 是 Go 1.8 引入的方法,它的行为分两步:

  1. 停止接受新连接 :关闭 net.Listener,不再 accept 新的 TCP 连接
  2. 等待现有连接完成:对于已经建立的连接,等待它们处理完当前请求后自然关闭

传入的 ctx 是超时控制------"我给你 10 秒处理完手头的事,10 秒后不管你做完没有,我都要强制关闭"。

为什么是 10 秒?

看一下 k8s/deployment.yaml:29

yaml 复制代码
spec:
  terminationGracePeriodSeconds: 30

K8s 给了 30 秒的"善后时间"。但我们代码里的 ctx.WithTimeout 只设了 10 秒。

这是一个保守设计:代码超时应该小于 K8s 超时。如果代码里设 40 秒而 K8s 只给 30 秒,那么 30 秒后 K8s 直接 SIGKILL,你的优雅关闭代码根本没跑完。

实际时间线应该是这样的:

复制代码
0s    K8s 发送 SIGTERM
0s    代码捕获信号,开始 Shutdown(不再接受新连接)
0s~10s   处理完已接受的请求(最多等 10s)
10s   Shutdown 完成(超时则强制关闭)
10s   关闭数据库连接
10s   同步日志缓冲区
11s   进程正常退出
30s   K8s 的 terminationGracePeriodSeconds 到期(但我们早就退出了)

代码实际用了约 11 秒(10s 等待 + 1s 清理),远在 K8s 的 30 秒容限之内。这就是一个有安全余量的设计。

4.3 关闭数据库连接

go 复制代码
sqlDB, err = db.DB()
if err == nil {
    sqlDB.Close()
}

这是优雅关闭中容易被忽略的一步。sqlDB.Close() 会:

  • 等待所有正在使用的连接返回连接池
  • 关闭所有空闲连接
  • 通知 MySQL 服务端这些连接已经关闭(发送 FIN 包)

不关闭会怎样?MySQL 服务端会累积大量 TIME_WAIT 状态的连接,直到这些连接自然超时。在频繁滚动更新的 K8s 场景下,可能导致 MySQL 的 max_connections 被打满。

4.4 同步日志缓冲区

go 复制代码
zapLog.Sync()

Zap 日志库默认是异步写入的,有内部缓冲区。如果进程退出前不调用 Sync(),最后几条日志可能还在缓冲区里没来得及刷盘。

这是一个极细微的 bug:你以为日志里记录了"shutdown complete",但实际上这条日志在刷盘之前进程就退出了。下次排查问题时你会对着日志文件百思不得其解------"为什么每次优雅关闭的最后几秒日志都不完整?"

Sync() 在这里是优雅关闭的最后一环,确保所有日志完整落盘。

4.5 你可能会忽略的事:确保 Shutdown 不在 goroutine 内

cmd/main.go:144-152,服务是在 goroutine 中启动的:

go 复制代码
go func() {
    zapLog.Infof("HTTP server starting on :8080")
    if err := httpServer.ListenAndServe(); err != nil && err != http.ErrServerClosed {
        log.Fatalf("HTTP server error: %v", err)
    }
}()

注意 err != http.ErrServerClosed 这个判断。Shutdown() 会触发 ListenAndServe 返回 http.ErrServerClosed 错误,这是正常的,不应当作致命错误处理。

Shutdown() 本身是在主 goroutine 中执行的------它阻塞等待所有请求完成(或超时),所以不能放在服务 goroutine 中。这是正确姿势:

go 复制代码
go srv.ListenAndServe()   // 服务 goroutine
// ... 主 goroutine 阻塞等待信号 ...
srv.Shutdown(ctx)          // 主 goroutine 中执行 Shutdown

4.6 完整的启动+关闭全景图

回顾 cmd/main.go:74-195runServer() 函数,完整的生命周期是:

复制代码
1. 初始化日志
2. 初始化 TracerProvider(OpenTelemetry)
3. 初始化数据库连接池
4. 注册 Prometheus 指标
5. 初始化各种 DAO
6. 初始化限流器
7. 创建 Gin 路由
8. 启动 HTTP(S) 服务(在 goroutine 中)
9. ---- 服务运行中 ----
10. 收到 SIGTERM / SIGINT
11. Shutdown HTTP 服务(10 秒超时)
12. Shutdown TracerProvider(5 秒超时,在 defer 中)
13. 关闭数据库连接
14. Sync 日志
15. 进程退出

你注意到步骤 12 了吗?看 cmd/main.go:81-85

go 复制代码
defer func() {
    ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
    defer cancel()
    tp.Shutdown(ctx)
}()

TracerProvider 的关闭是通过 defer 实现的,这意味着它会在 runServer 函数返回时自动执行。defer 的执行顺序是 LIFO(后进先出),所以实际执行顺序是:

  1. runServer 返回
  2. 执行 defer:tp.Shutdown(flush 所有未发送的 trace span)
  3. 回到 main 函数,进程退出

这就是一个完整的、不丢一个 trace span、不漏一条日志、不遗留一个数据库连接的优雅关闭。


五、高阶话题:优雅关闭与分布式系统的交互

5.1 为什么需要先从注册中心摘除?

在微服务架构中,优雅关闭不只是进程内部的事。你还需要:

  1. 从服务发现(如 Consul/Etcd)中摘除实例:让负载均衡器不再把流量导向即将关闭的实例
  2. 等待一段时间(如 5 秒):让负载均衡器刷新它的路由表
  3. 然后才开始 Shutdown:此时已经没有新流量进来了

user-service 项目没有使用服务发现(它是通过 K8s Service 暴露的),K8s Service 在 Pod 进入 Terminating 状态时会自动从 Endpoint 中移除------这就是第 4.2 节提到的"不再分配新流量"。

但如果你用的是 Consul 等外部注册中心,你需要在捕获信号后、Shutdown 之前,主动调用 deregister 接口。

5.2 优雅关闭和长连接

Gin 用的是 HTTP/1.1,默认启用 keep-alive 连接复用。Shutdown() 会等待当前连接上的请求完成,但不会主动关闭空闲的 keep-alive 连接------这可能导致 Shutdown 超时。

你可以在 Shutdown 之前,主动设置 Connection: close 响应头,让客户端知道不要复用连接。或者干脆在 Shutdown context 超时后接受强制关闭------这就是 10 秒超时的另一个价值。

5.3 initContainer 中的 migrate

再看 k8s/deployment.yaml:34-47

yaml 复制代码
initContainers:
  - name: migrate
    image: user-service:latest
    imagePullPolicy: IfNotPresent
    command: ["./UserServer", "migrate"]

在 Pod 启动时,先运行 migrate initContainer,执行数据库迁移(cmd/main.go:41-46runMigrate()),成功后才启动主容器。

migrate 也不需要优雅关闭------它执行完就退出,不接收 HTTP 请求。所以它的代码非常简短,就是 db.AutoMigrate + sqlDB.Close + zapLog.Sync


六、总结

优雅关闭的核心只有三行代码:

go 复制代码
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
<-quit
srv.Shutdown(context.WithTimeout(context.Background(), 10*time.Second))

但围绕这三行代码,需要思考的问题远不止于此:

考量点 实践
代码超时 vs K8s 超时 代码 10s < K8s 30s,留出安全余量
数据库连接 sqlDB.Close() 避免连接泄漏
日志缓冲区 zapLog.Sync() 确保日志完整落盘
链路追踪 tp.Shutdown() flush 未发送的 trace span
错误处理 区分http.ErrServerClosed(预期)和其他错误(异常)
服务发现 K8s Service 自动摘除 / 外部注册中心需主动 deregister
数据库迁移 initContainer 独立执行,不参与优雅关闭

记住这个公式:捕获信号 → 停止接新 → 等现有完成(设超时)→ 关 DB → sync 日志 → 退出。

下次发版前,先检查你的代码里有没有这三行。如果没有,今晚你可能就是那个通宵修数据的人。


完整代码

本文所有示例代码来自开源项目 user-service,一个基于 Go + Gin + GORM 构建的企业级用户管理与社交关系 REST API 微服务。

项目地址:https://github.com/binbin3828/user

本系列 14 篇完整目录:

① 从面条代码到三层架构 ② API 安全洋葱模型 ③ 配置管理与密钥保护 ④ 单元测试 ⑤ 可观测性

⑥ 部署进化 ⑦ 好友请求状态机 ⑧ Redis 实战 ⑨ 中间件链 ⑩ Geohash

⑪ API 响应设计 ⑫ 优雅关闭 ⑬ GORM 避坑 ⑭ Makefile