服务退出也需要设计
很多入门 HTTP 服务最后都是这样:
log.Fatal(http.ListenAndServe(":8080", mux))
它能启动服务,但没有认真处理停止过程。你按 Ctrl+C,或者部署平台发送 SIGTERM,进程可能立刻退出,正在处理的请求被中断,日志没来得及刷,后台任务也不知道该停。小练习问题不大,真实服务就不够稳。
Go 的 http.Server 提供了 Shutdown 方法,可以停止接收新连接,并给正在处理的请求一点时间完成。配合 os/signal 和 context.WithTimeout,我们能写出一个清楚的优雅关闭流程。
这篇文章会从最小 HTTP 服务改起,逐步加入信号监听、关闭超时和后台任务退出。
使用 http.Server
先不要直接调用 http.ListenAndServe,而是创建 server:
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
fmt.Fprintln(w, "ok")
})
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
启动:
if err := server.ListenAndServe(); err != nil {
log.Fatal(err)
}
有了 server 变量,后面就能调用 server.Shutdown(ctx)。
监听退出信号
常见退出信号是 os.Interrupt 和 syscall.SIGTERM。前者通常来自 Ctrl+C,后者常见于容器或进程管理器停止服务。
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
<-stop
log.Println("shutdown signal received")
这段代码会阻塞,直到收到信号。实际服务中,HTTP server 需要同时运行,所以通常放到 goroutine 里启动服务,主 goroutine 等信号。
完整优雅关闭骨架
func main() {
mux := http.NewServeMux()
mux.HandleFunc("/healthz", healthHandler)
server := &http.Server{
Addr: ":8080",
Handler: mux,
}
errCh := make(chan error, 1)
go func() {
log.Println("listening on :8080")
errCh <- server.ListenAndServe()
}()
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
select {
case sig := <-stop:
log.Printf("received signal: %s", sig)
case err := <-errCh:
if err != nil && err != http.ErrServerClosed {
log.Fatalf("server error: %v", err)
}
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
log.Printf("graceful shutdown failed: %v", err)
if err := server.Close(); err != nil {
log.Printf("force close failed: %v", err)
}
}
log.Println("server stopped")
}
Shutdown 会让 server 停止接收新请求,并等待已有请求结束,直到 context 超时。如果超时还没结束,可以调用 Close 强制关闭。
ListenAndServe 在正常关闭后会返回 http.ErrServerClosed,这不是错误,不应该当成 fatal。
给请求处理留出退出路径
优雅关闭不只是 server 的事,handler 也要尊重请求 context。
func slowHandler(w http.ResponseWriter, r *http.Request) {
select {
case <-time.After(3 * time.Second):
fmt.Fprintln(w, "done")
case <-r.Context().Done():
log.Printf("request cancelled: %v", r.Context().Err())
return
}
}
如果客户端断开,或者 server 正在关闭,请求 context 会被取消。Handler 里做外部调用、数据库查询、长任务时,也应该把 r.Context() 传下去。
不要在 handler 深处随便使用 context.Background(),否则请求取消信号就断了。正确做法是:
user, err := service.GetUser(r.Context(), id)
这样关闭时,内部调用也有机会停止。
后台任务也要停
如果服务里有定时任务,可以共享一个根 context:
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
go runCleaner(ctx)
任务:
func runCleaner(ctx context.Context) {
ticker := time.NewTicker(1 * time.Minute)
defer ticker.Stop()
for {
select {
case <-ticker.C:
log.Println("clean expired data")
case <-ctx.Done():
log.Println("cleaner stopped")
return
}
}
}
收到信号后先 cancel(),再关闭 server:
cancel()
shutdownCtx, shutdownCancel := context.WithTimeout(context.Background(), 5*time.Second)
defer shutdownCancel()
server.Shutdown(shutdownCtx)
后台 goroutine 必须有退出路径。否则服务看似停止了 HTTP,但进程可能还挂着,或者任务在关闭过程中继续写数据。
小结
Go HTTP 服务的优雅关闭核心流程是:使用 http.Server,单独 goroutine 启动服务,主 goroutine 监听 SIGINT 和 SIGTERM,收到信号后创建带超时的 context,调用 server.Shutdown(ctx),必要时再 Close 强制关闭。
同时,handler 和后台任务要尊重 context。请求内部调用传 r.Context(),定时任务监听 ctx.Done()。这样服务停止时,不会粗暴中断所有事情,也不会无限等待。
入门项目也可以从一开始写好关闭逻辑。它不复杂,却能让程序更像真实服务,也能帮助你理解 Go 里 context、signal 和 HTTP server 如何协作。
继续阅读
探索更多技术文章
浏览归档,发现更多关于系统设计、工具链和工程实践的内容。