几乎每个 Go 服务的 main 函数里都有这么一段:
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
srv.Shutdown(shCtx)
看起来很完整:收到信号,给 5 秒让正在处理的请求跑完,然后退出。大家管这叫「优雅退出」。
但 http.Server.Shutdown 只管一件事:HTTP 连接。它不知道你在 handler 里往 channel 丢了一个任务、起了一个 goroutine 去发消息、或者有个 ticker 每分钟刷一次缓存。这些东西在进程退出的那一刻会被直接掐断,不报错,不留日志。
下面是我本地写 demo 实测的四个坑,环境是 Go 1.25 / macOS,代码都能直接跑。
坑一:请求返回了 200,任务却没做
最典型的写法:handler 收到请求后,把耗时的活丢给后台 worker 池,立刻返回「已受理」。
type Worker struct {
jobs chan int
wg sync.WaitGroup
}
func NewWorker(n int) *Worker {
w := &Worker{jobs: make(chan int, 100)}
for i := 0; i < n; i++ {
w.wg.Add(1)
go func(id int) {
defer w.wg.Done()
for job := range w.jobs {
time.Sleep(300 * time.Millisecond) // 模拟写库、发消息
log.Printf("worker %d done job %d", id, job)
}
}(i)
}
return w
}
func (w *Worker) Submit(job int) bool {
select {
case w.jobs <- job:
return true
default:
return false
}
}
handler:
mux.HandleFunc("/order", func(rw http.ResponseWriter, r *http.Request) {
id := nextID()
time.Sleep(200 * time.Millisecond) // 模拟处理请求
if !w.Submit(id) {
http.Error(rw, "busy", http.StatusServiceUnavailable)
return
}
fmt.Fprintf(rw, "accepted %d\n", id)
})
然后用开头那段「标准」退出逻辑。测试方法:起服务,并发打 10 个请求,350ms 后发 SIGTERM。
MODE=naive ./gs > naive.log 2>&1 & P=$!
sleep 0.8
for i in $(seq 1 10); do curl -s http://127.0.0.1:8088/order -o /dev/null -w "%{http_code} " & done
sleep 0.35; kill -TERM $P; wait $P
结果:
200 200 200 200 200 200 200 200 200 200 exit=0
2026/09/28 10:27:38 signal received
2026/09/28 10:27:38 server stopped, exit
10 个请求全部返回 200,日志里一条 done job 都没有。 进程退出码是 0,看起来一切正常。
Shutdown 确实等了那 10 个请求处理完,这点它做得没问题。问题是请求处理完 ≠ 任务处理完,任务还在 channel 里排队,worker 刚 sleep 到一半,main 函数就 return 了。
修法是给 worker 也加一个「停止并排空」的方法,在 HTTP 停完之后调用:
// Stop 关闭入口并等待已入队任务处理完,超时返回错误
func (w *Worker) Stop(ctx context.Context) error {
close(w.jobs)
done := make(chan struct{})
go func() { w.wg.Wait(); close(done) }()
select {
case <-done:
return nil
case <-ctx.Done():
return ctx.Err()
}
}
<-ctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil { // 1. 先停入口,不再接新请求
log.Println("http shutdown:", err)
}
if err := w.Stop(shCtx); err != nil { // 2. 再排空后台任务
log.Println("worker stop:", err)
}
log.Println("all done, exit")
同样的测试:
2026/09/28 10:27:32 worker 0 done job 10
2026/09/28 10:27:32 worker 1 done job 9
2026/09/28 10:27:32 all done, exit
10 个任务全部完成。
顺序很关键:必须先停 HTTP 再关 channel。反过来的话,Shutdown 期间还在处理的请求往已关闭的 channel 里 Submit,会直接 panic:send on closed channel。
坑二:主 goroutine 阻塞在 ListenAndServe 上
另一种很常见的结构,把信号处理放到 goroutine 里,main 阻塞在 ListenAndServe:
go func() {
ch := make(chan os.Signal, 1)
signal.Notify(ch, syscall.SIGTERM)
<-ch
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(ctx)
}()
if err := srv.ListenAndServe(); err != nil {
log.Println("ListenAndServe returned:", err)
}
log.Println("main exit")
我加了一个要跑 2 秒的 /slow 接口,请求发出去 0.3 秒后发 SIGTERM:
curl=000
2026/09/28 10:27:52 ListenAndServe returned: http: Server closed
2026/09/28 10:27:52 main exit
curl 拿到的是 000,连接被直接断掉了。
原因写在 Shutdown 的文档里:调用 Shutdown 后,ListenAndServe 会立即返回 ErrServerClosed,而不是等 Shutdown 完成。main 函数紧接着 return,进程退出,那个还在 sleep 的 handler 连同 Shutdown 自己一起被杀掉了。
修法:main 必须等 Shutdown 返回之后再退出。最简单的是把两者的位置对调,main 等信号、goroutine 跑 ListenAndServe(就是坑一里那种结构);或者用一个 channel 等 Shutdown 结束:
idle := make(chan struct{})
go func() {
<-ctx.Done()
shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
srv.Shutdown(shCtx)
close(idle)
}()
if err := srv.ListenAndServe(); !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
<-idle // 等 Shutdown 真正结束
坑三:长连接 Shutdown 管不了
Shutdown 的文档还有一句容易被忽略的话:它不会尝试关闭或等待被 hijack 的连接,比如 WebSocket。
这意味着如果你的服务有 WebSocket 或者 SSE(SSE 没被 hijack,但它是一个永远不结束的请求,效果类似),Shutdown 会一直等到超时,然后返回 context deadline exceeded,客户端那边看到的是连接被硬断。
处理办法是用 RegisterOnShutdown 注册一个回调,主动通知这些连接收尾:
type Hub struct {
mu sync.Mutex
conns map[*Conn]struct{}
}
func (h *Hub) CloseAll() {
h.mu.Lock()
defer h.mu.Unlock()
for c := range h.conns {
c.SendClose("server restarting") // 发 close 帧 / SSE 里发一个 event: bye
}
}
srv.RegisterOnShutdown(hub.CloseAll)
对 SSE 来说,更简单的做法是 handler 里同时 select 请求 ctx 和一个全局的 shutdown 信号:
func sse(w http.ResponseWriter, r *http.Request) {
flusher := w.(http.Flusher)
w.Header().Set("Content-Type", "text/event-stream")
for {
select {
case <-r.Context().Done():
return
case <-shuttingDown: // 全局 channel,退出时 close
fmt.Fprint(w, "event: bye\ndata: reconnect\n\n")
flusher.Flush()
return
case msg := <-updates:
fmt.Fprintf(w, "data: %s\n\n", msg)
flusher.Flush()
}
}
}
客户端收到 bye 后自己重连,就会连到新实例上。
坑四:K8s 下 SIGTERM 来了,流量还在进
前面三个坑在单机上就能复现。上了 K8s 还有一个:Pod 被删除时,发 SIGTERM 和从 Service 的 Endpoints 里摘掉这个 Pod 是并行发生的。kube-proxy / Ingress 同步规则需要时间,所以收到 SIGTERM 之后的几百毫秒到几秒内,仍然会有新请求打进来。
如果你一收到信号就调 Shutdown,监听端口被关闭,这些请求拿到的就是 connection refused。
常见处理:收到信号后先让 readiness 探针失败、再等几秒,然后才 Shutdown。
var ready atomic.Bool
ready.Store(true)
mux.HandleFunc("/readyz", func(w http.ResponseWriter, r *http.Request) {
if !ready.Load() {
http.Error(w, "shutting down", http.StatusServiceUnavailable)
return
}
w.WriteHeader(http.StatusOK)
})
<-ctx.Done()
ready.Store(false)
time.Sleep(5 * time.Second) // 等负载均衡把自己摘掉,时长按集群实际情况定
srv.Shutdown(shCtx)
或者在 Pod 上配 preStop 执行 sleep 5,效果一样,业务代码不用改。
还要注意一个数字:K8s 默认 terminationGracePeriodSeconds 是 30 秒,超时直接 SIGKILL。「摘流量的等待 + HTTP Shutdown 超时 + 排空后台任务的时间」加起来必须小于它,否则你写的所有优雅逻辑都会在第 30 秒被强杀打断。
把四个坑合在一起
func main() {
w := NewWorker(4)
srv := &http.Server{Addr: ":8080", Handler: newMux(w)}
go func() {
if err := srv.ListenAndServe(); err != nil && !errors.Is(err, http.ErrServerClosed) {
log.Fatal(err)
}
}()
srv.RegisterOnShutdown(hub.CloseAll) // 坑三:长连接主动收尾
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
<-ctx.Done() // 坑二:main 自己等信号
ready.Store(false) // 坑四:先摘流量
time.Sleep(5 * time.Second)
shCtx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
if err := srv.Shutdown(shCtx); err != nil {
log.Println("http shutdown:", err)
}
if err := w.Stop(shCtx); err != nil { // 坑一:排空后台任务
log.Println("worker stop:", err)
}
log.Println("bye")
}
5 + 20 = 25 秒,留 5 秒余量给 30 秒的 grace period。
说说局限
排空只能保证「进程内已经接住的任务」不丢,进程被 SIGKILL、OOM、机器掉电的时候照样丢。真正不能丢的任务(扣款、发货通知),光靠优雅退出是不够的,得先落库或进消息队列再返回 200,worker 从队列里取、处理完再 ack。优雅退出只是减少丢失,不是消灭丢失。
另外坑四的等待时长没有标准答案,我上面写的 5 秒只是一个常见起点,具体要看你集群里 Endpoints 同步有多快。
我自己给 forxi.cn 写后端时有个习惯:每加一种后台 goroutine,都顺手问一句「它在退出时怎么收尾」。这个问题比写 Shutdown 那几行重要得多。