Contents

Go 服务优雅退出实战:从信号捕获到连接排空,告别连接中断

部署时你的用户正在被踢下线

每次 kubectl rollout 或者 systemctl restart,你的服务收到 SIGTERM 信号后会发生什么?如果答案是"进程直接退出",那你的用户正在经历连接中断——正在处理的请求被截断,正在上传的文件丢失,数据库事务回滚,甚至触发客户端的重试风暴。

优雅退出(Graceful Shutdown) 的目标很简单:收到退出信号后,停止接收新请求,等待已有请求处理完毕,然后才退出进程。听起来简单,但生产环境的坑远比你想象的多。

核心机制:信号 + Context + WaitGroup

Go 标准库从 1.8 起就内置了 http.Server.Shutdown() 方法,它会优雅地关闭所有监听的端口,并等待所有活跃连接处理完毕。但光有这个还不够,你需要一套完整的退出流程。

完整的可运行示例

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
package main

import (
	"context"
	"log"
	"net/http"
	"os"
	"os/signal"
	"sync"
	"syscall"
	"time"
)

func main() {
	var wg sync.WaitGroup

	mux := http.NewServeMux()
	mux.HandleFunc("/work", func(w http.ResponseWriter, r *http.Request) {
		wg.Add(1)
		defer wg.Done()

		// 模拟耗时请求
		select {
		case <-time.After(5 * time.Second):
			w.Write([]byte("done"))
		case <-r.Context().Done():
			log.Println("客户端已断开连接")
			return
		}
	})
	mux.HandleFunc("/health", func(w http.ResponseWriter, r *http.Request) {
		w.WriteHeader(http.StatusOK)
		w.Write([]byte("ok"))
	})

	srv := &http.Server{
		Addr:         ":8080",
		Handler:      mux,
		ReadTimeout:  10 * time.Second,
		WriteTimeout: 30 * time.Second,
		IdleTimeout:  60 * time.Second,
	}

	// 启动 HTTP 服务
	go func() {
		log.Printf("服务启动,监听 %s", srv.Addr)
		if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
			log.Fatalf("服务异常: %v", err)
		}
	}()

	// 等待退出信号
	quit := make(chan os.Signal, 1)
	signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
	sig := <-quit
	log.Printf("收到信号: %v,开始优雅退出...", sig)

	// 第一步:立即从负载均衡摘除(健康检查返回 503)
	// 实际项目中可以替换为主动调用负载均衡 API 注销
	shutdownHealthCheck()

	// 第二步:给负载均衡一点时间感知摘除
	time.Sleep(2 * time.Second)

	// 第三步:停止接收新连接,等待已有请求完成
	ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
	defer cancel()

	if err := srv.Shutdown(ctx); err != nil {
		log.Printf("优雅退出超时,强制关闭: %v", err)
	}

	// 第四步:等待后台 goroutine 完成
	wg.Wait()

	log.Println("服务已安全退出")
}

var healthOK = true

func shutdownHealthCheck() {
	healthOK = false
	log.Println("健康检查已切换为不可用状态")
}

注意 /health 端点在实际代码中应该返回 healthOk 的值,这里为了简洁省略了判断逻辑。

每一步都在解决一个真实问题

第一步:信号捕获

1
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)

关键点:不要捕获所有信号。只处理 SIGINT(Ctrl+C)和 SIGTERM(容器/k8s 发送的标准退出信号)。SIGHUP 在某些场景下用于重载配置而非退出,混在一起会出问题。

第二步:健康检查降级

这是最容易被忽略的一步。收到退出信号后,第一时间把健康检查端点返回 503。这样负载均衡器在下一轮探测时就会把你的实例从轮询池中摘除,新的请求不会再进来。

如果你跳过这步直接 Shutdown(),在 Shutdown 等待期间仍然可能有新请求涌入,导致退出时间变长甚至超时。

第三步:等待摘除传播

1
time.Sleep(2 * time.Second)

负载均衡器的健康检查有间隔周期(通常 3~5 秒)。你把健康检查切成 503 后,负载均衡器可能还需要一两个周期才能感知到。这个 sleep 就是给它一个反应窗口。具体时间取决于你的负载均衡配置。

第四步:Shutdown + 超时兜底

1
2
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
srv.Shutdown(ctx)

Shutdown 会停止接受新连接,并等待所有活跃请求完成。但如果某个请求卡住了怎么办?context.WithTimeout 就是兜底——超过 15 秒还没完成,就强制返回错误,进程退出。

这个超时时间应该大于你最慢请求的处理时间。如果你的 API 最慢 30 秒,这里设 15 秒就会杀掉正在处理的请求。

生产环境踩坑清单

坑 1:忘了处理后台 goroutine

http.Server.Shutdown() 只管 HTTP 连接,不管你启动的后台 goroutine。如果你有后台消费队列的 worker、定时任务、数据库连接池,它们不会自动退出。

解决方案:用 WaitGroup 跟踪所有后台任务,在 Shutdown 完成后 wg.Wait()

坑 2:k8s 的 terminationGracePeriodSeconds 太短

k8s 发送 SIGTERM 后,默认等待 30 秒就会发 SIGKILL 强杀。如果你在代码里设了 15 秒 Shutdown 超时 + 2 秒摘除等待 + 后台任务清理时间,很容易超过 30 秒。

1
2
3
4
5
# k8s deployment 需要调大这个值
spec:
  template:
    spec:
      terminationGracePeriodSeconds: 45

坑 3:数据库连接没关

Shutdown 完成后别忘了关闭数据库连接池和其他资源:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
if err := srv.Shutdown(ctx); err != nil {
    log.Printf("关闭异常: %v", err)
}

// 关闭数据库连接
	if err := db.Close(); err != nil {
		log.Printf("关闭数据库连接失败: %v", err)
	}

// 关闭 Redis 连接
	_ = redisClient.Close()

// 等待后台任务
	wg.Wait()

坑 4:用了 http.ListenAndServe 而不是 http.Server

1
2
3
4
5
6
7
// ❌ 这样无法优雅退出
http.ListenAndServe(":8080", mux)

// ✅ 必须用 http.Server 结构体
srv := &http.Server{Addr: ":8080", Handler: mux}
srv.ListenAndServe()
srv.Shutdown(ctx)

http.ListenAndServe 内部创建了一个无法从外部控制的 Server 实例,你拿不到引用就没法调 Shutdown

用脚本验证你的退出流程

写个简单的验证脚本,部署前先测一遍:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
#!/bin/bash
# 启动服务
go run main.go &
PID=$!

sleep 1

# 发起一个耗时请求(后台运行)
curl -s http://localhost:8080/work &
CURL_PID=$!

# 等待 1 秒让请求进入处理
sleep 1

# 发送 SIGTERM
kill -TERM $PID

# 等待 curl 完成
wait $CURL_PID
EXIT_CODE=$?

if [ $EXIT_CODE -eq 0 ]; then
    echo "✅ 优雅退出验证通过:耗时请求正常完成"
else
    echo "❌ 优雅退出验证失败:请求被中断 (exit=$EXIT_CODE)"
fi

如果看到 ✅,说明你的 Shutdown 逻辑是对的。如果看到 ❌,说明请求在完成之前就被杀了,需要排查。

总结

优雅退出不是一行 srv.Shutdown() 就能搞定的事。完整流程是:

  1. 捕获信号 → 只处理 SIGINT 和 SIGTERM
  2. 健康检查降级 → 立即返回 503,让负载均衡摘除
  3. 等待摘除传播 → 给负载均衡 2~5 秒反应时间
  4. Shutdown 等待 → 设合理的超时,等活跃请求完成
  5. 清理后台资源 → WaitGroup 等待 goroutine,关闭数据库/Redis 连接
  6. k8s 配置兜底terminationGracePeriodSeconds 要大于上述所有时间之和

把这套流程落地到你的每个服务里,部署时的连接中断问题就彻底解决了。