Go HTTP 服务优雅关闭入门:停止接新请求,等旧请求收尾

HTTP 服务发布或重启时,如果直接杀进程,正在处理的请求可能被中断,用户看到连接错误,后台写入也可能只完成一半。Go 的 可以帮助服务优雅关闭:停止接收新连接,等待已有请求完成,直到超时。本文用一个标准 HTTP 服务讲关闭流程、信号处理和常见边界。

HTTP 服务发布或重启时,如果直接杀进程,正在处理的请求可能被中断,用户看到连接错误,后台写入也可能只完成一半。Go 的 http.Server.Shutdown 可以帮助服务优雅关闭:停止接收新连接,等待已有请求完成,直到超时。

本文用一个标准 HTTP 服务讲关闭流程、信号处理和常见边界。

基本结构

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("/healthz", healthz)
	mux.HandleFunc("/api/items", items)

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

	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop()

	errCh := make(chan error, 1)
	go func() {
		errCh <- server.ListenAndServe()
	}()

	select {
	case err := <-errCh:
		if err != nil && err != http.ErrServerClosed {
			log.Fatal(err)
		}
	case <-ctx.Done():
		shutdownCtx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
		defer cancel()
		if err := server.Shutdown(shutdownCtx); err != nil {
			log.Printf("shutdown failed: %v", err)
		}
	}
}

注意 Shutdown 使用的是新的 context.Background() 派生出的 context。原来的 ctx 已经被信号取消了,如果直接传进去,Shutdown 会立刻返回。

Shutdown 做了什么

Shutdown 会关闭监听器,停止接收新连接,然后等待已有连接处理完成。它不会强行杀正在执行的 handler,除非 shutdown context 超时。handler 自己也应该尊重 r.Context(),在客户端断开或服务关闭时尽快停止。

func items(w http.ResponseWriter, r *http.Request) {
	items, err := store.List(r.Context())
	if err != nil {
		http.Error(w, "query failed", http.StatusInternalServerError)
		return
	}
	writeJSON(w, items)
}

如果 handler 内部调用数据库、外部 HTTP、队列,都应该传递 r.Context()

健康检查和滚动发布

在容器或负载均衡环境里,优雅关闭通常还需要“先从流量里摘除,再 shutdown”。比如收到 SIGTERM 后,健康检查先返回失败,让负载均衡不再转发新请求,然后等待一小段时间,再关闭 server。

可以维护一个原子状态:

var shuttingDown atomic.Bool

func healthz(w http.ResponseWriter, r *http.Request) {
	if shuttingDown.Load() {
		http.Error(w, "shutting down", http.StatusServiceUnavailable)
		return
	}
	fmt.Fprintln(w, "ok")
}

收到信号后:

shuttingDown.Store(true)
time.Sleep(3 * time.Second)
server.Shutdown(shutdownCtx)

这段等待要和部署平台配合。不是所有环境都需要,但滚动发布时很常见。

后台 goroutine 也要停

HTTP server 关闭不等于所有后台任务都自动停。你自己启动的 worker、ticker、消费者都要监听 context:

func runCleaner(ctx context.Context) {
	ticker := time.NewTicker(time.Minute)
	defer ticker.Stop()
	for {
		select {
		case <-ticker.C:
			cleanOnce(ctx)
		case <-ctx.Done():
			return
		}
	}
}

服务关闭流程应该统一管理这些 goroutine。否则 HTTP 端口已经关了,进程却因为后台 goroutine 卡住不退出。

测试 handler 是否尊重 context

func TestHandlerContextCanceled(t *testing.T) {
	ctx, cancel := context.WithCancel(context.Background())
	cancel()

	req := httptest.NewRequest(http.MethodGet, "/api/items", nil).WithContext(ctx)
	rec := httptest.NewRecorder()
	items(rec, req)
	// 根据你的 handler 语义断言
}

更常见的是在 store 层测试:传入已取消 context,确认查询或循环能尽快返回。优雅关闭不是只测 main 函数,而是整个调用链都要传播取消。

RegisterOnShutdown

http.Server 还提供了 RegisterOnShutdown,可以注册一些关闭回调。它适合通知旁路组件,比如告诉 WebSocket hub 不再接收新连接、给指标系统打一个状态标记。但不要把耗时清理都塞进去,因为真正控制等待时间的仍然是外层 Shutdown(ctx) 的 context。

srv := &http.Server{Addr: ":8080", Handler: mux}

srv.RegisterOnShutdown(func() {
	log.Println("server is shutting down, stop accepting live sessions")
})

如果清理逻辑需要返回错误,建议放在主流程里显式调用。回调没有返回值,失败只能记录日志,调用方不容易做决策。

Close 和 Shutdown 的区别

Close 会立即关闭监听器和活动连接,更像“立刻停”。Shutdown 会先关闭监听器,不再接收新连接,然后等待已有连接处理完。线上服务默认应该优先使用 Shutdown

if fastExit {
	_ = srv.Close()
	return
}

ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second)
defer cancel()
_ = srv.Shutdown(ctx)

Close 不是没用。测试里为了快速释放端口、进程收到第二次退出信号、或者服务已经处于不可恢复状态时,它可以作为最后手段。关键是不要把两者混着用,也不要在优雅关闭流程刚开始就调用 Close

长连接和流式接口

如果服务里有 SSE、长轮询、流式下载,优雅关闭要额外设计。因为这些请求可能持续几十秒甚至几分钟,Shutdown 会一直等到超时。更好的做法是收到关闭信号后通知这些 handler 主动结束。

func stream(w http.ResponseWriter, r *http.Request, closing <-chan struct{}) {
	ticker := time.NewTicker(time.Second)
	defer ticker.Stop()

	for {
		select {
		case <-r.Context().Done():
			return
		case <-closing:
			return
		case t := <-ticker.C:
			fmt.Fprintf(w, "data: %s\n\n", t.Format(time.RFC3339))
			if f, ok := w.(http.Flusher); ok {
				f.Flush()
			}
		}
	}
}

这样发布时服务不会被少数长连接拖住。用户看到的是连接自然结束,而不是部署系统强杀进程。

小结

Go HTTP 服务优雅关闭的基本流程是:监听信号,停止健康检查,停止接收新请求,使用 Server.Shutdown 等待旧请求完成,并给关闭过程设置超时。handler 内部要传递 r.Context(),后台 goroutine 也要监听取消。

优雅关闭不是一行 Shutdown 就结束,它和部署平台、健康检查、后台任务、数据库调用一起工作。把这些边界连起来,滚动发布才会更平滑。

常见问题与解答

优雅关闭时为什么用新的 context?

因为 signal.NotifyContext 返回的 context 已经被信号取消了。如果直接传给 Shutdown,它会立刻返回 context canceled。必须创建一个新的带超时的 context。

Shutdown 会等多久?

由你传入的 context timeout 决定。15 到 30 秒是常见选择,但更取决于业务。如果 handler 里平均只需要几百毫秒,10 秒可能就够;如果有大文件上传,可能需要 60 秒以上。

优雅关闭失败怎么办?

如果 Shutdown 返回超时错误,意味着某些 handler 或后台任务没有在规定时间内结束。此时你有两个选择:一是记录日志后调用 Close 强制关闭;二是延长超时。无论选哪个,都应该先排查为什么 handler 不结束。

容器环境里必须优雅关闭吗?

是的。Kubernetes 在终止 Pod 时会先发送 SIGTERM,给 Pod 一段 graceful period,然后再发 SIGKILL。如果不处理 SIGTERM 并优雅关闭,用户请求可能在发布过程中被强制中断。

实际生产中的发布流程

一个结合优雅关闭的容器发布流程通常如下:

  1. 收到 SIGTERM:负载均衡器或编排平台告知 Pod 即将终止。
  2. 停止健康检查返回 200:让负载均衡不再分发新请求。
  3. 等待几秒缓冲:给负载均衡发现状态变化的时间。
  4. 调用 Server.Shutdown:停止接收新连接,等待已有请求完成。
  5. 等待后台 goroutine 退出:通知所有后台 worker 停止。
  6. 关闭数据库连接:释放连接池资源。
  7. 进程自然退出:所有 goroutine 结束后 main 函数返回。
func gracefulShutdown(srv *http.Server, workers *WorkerPool, db *sql.DB) {
    ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
    defer stop()

    <-ctx.Done()
    log.Println("shutdown signal received")

    shutdownCtx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    if err := srv.Shutdown(shutdownCtx); err != nil {
        log.Printf("server shutdown error: %v", err)
    }

    workers.Stop(shutdownCtx)
    db.Close()
    log.Println("graceful shutdown completed")
}

真实项目注意事项

日志不要丢:关闭过程中要记录收到什么信号、哪些步骤成功、哪些超时。发布卡住时,这些日志是排查的唯一线索。

数据库连接Server.Shutdown 只等 HTTP handler,不等你手动管理的数据库连接。如果 handler 里用了连接池,确保 handler 退出时连接已经归还。

第三方回调:如果服务需要消费第三方 webhook,优雅关闭期间要考虑回调是否会被重试。有些平台只在特定时间内重试,如果关闭时间太长,回调可能超时。

Read Header Timeout:除了 handler 执行时间,还要注意连接建立和请求头读取阶段的时间。设置 ReadHeaderTimeout 可以防止恶意慢连接拖住关闭过程。

server := &http.Server{
    Addr:              ":8080",
    Handler:           handler,
    ReadTimeout:       5 * time.Second,
    ReadHeaderTimeout: 2 * time.Second,
    WriteTimeout:      10 * time.Second,
    IdleTimeout:       60 * time.Second,
}

实践练习

完成以下练习以巩固所学知识:

  1. 阅读 Go 官方文档相关章节
  2. 编写一个完整的示例程序
  3. 为示例程序编写单元测试
  4. 使用 go testgo benchmark 验证实现
  5. 尝试优化内存分配和运行时间

推荐阅读

真实项目用例

在实际团队协作中,下面是几个推荐的工作流:

代码审查清单

  • 函数是否处理了所有 error 返回值
  • 并发代码是否有明确的退出路径和 WaitGroup
  • 用户输入是否经过校验和清洗
  • 敏感配置是否通过环境变量或加密存储注入
  • 测试是否覆盖了正常路径和至少一个错误路径
  • 日志是否包含足够的上下文信息但不泄露敏感数据
  • 接口设计是否符合最小接口原则

CI/CD 集成建议

  • 每次提交前运行 go fmt ./...
  • CI 中运行 go vet ./...golangci-lint run
  • 单元测试使用 go test -race ./... 检测数据竞争
  • 关键路径的 benchmark 加入回归测试
  • 使用 go mod verify 确保依赖完整性

性能调优检查点

  • 使用 pprof 分析 CPU 和内存使用
  • 关注 benchmark 的 allocs/op,减少高频路径的堆分配
  • 检查数据库查询是否使用索引
  • 确认外部 HTTP 调用有合理的超时设置
  • 缓存热点数据,但注意缓存一致性和过期策略

面试高频考点

如果你正在准备 Go 相关面试,以下概念是高频考点:

  1. goroutine 和线程的区别
  2. channel 的缓冲和非缓冲用法
  3. defer 的执行顺序和与返回值的关系
  4. map 的并发不安全性和解决方案
  5. interface 的隐式实现和类型断言
  6. slice 的底层数组和 append 机制
  7. GC 的基本原理和调优参数
  8. context 的使用场景和超时控制
  9. error 的包装和 errors.Is/errors.As
  10. sync.Mutex vs sync.RWMutex vs atomic

掌握这些概念意味着你具备了独立开发 Go 服务的基础能力。继续在实际项目中磨练,你会越来越熟悉 Go 的工程风格和最佳实践。

常见问题(FAQ)

Q: 这个特性在实际项目中真的有用吗?
A: 是的。本文介绍的技术来源于真实后端开发场景。无论是标准库工具还是工程实践,在日常服务开发中都会反复用到。

Q: Go 版本会影响示例代码吗?
A: 本文代码主要针对 Go 1.20+ 编写。较新版本(如 1.22、1.23)的语法可能有微调,但核心概念保持不变。如有版本差异,文中会特别说明。

Q: 学习 Go 应该先学标准库还是直接上框架?
A: 强烈建议先学标准库。框架是对标准库的封装和扩展。只有理解了标准库的能力边界,才能正确选择和使用框架,也才能在框架出问题时快速定位。

Q: 代码里的错误处理为什么都是显式的 if err != nil
A: 这是 Go 的设计哲学。显式错误处理让失败路径清晰可见,不会隐藏在任何 try-catch 之后。习惯了之后,你会发现这种写法实际上降低了排查错误的难度。

Q: 并发相关代码怎么测试?
A: 使用 Go 内置的 -race 标志检测数据竞争:go test -race ./...。结合 sync.WaitGroupcontext.WithTimeout 编写有退出路径的并发测试,避免 goroutine 泄漏。

常见坑与避坑指南

  1. 不要信任用户输入:无论表单、JSON、Cookie 还是 HTTP Header,都当作不可信数据处理,做校验和转义。
  2. 资源要释放:文件、数据库连接、HTTP 响应体都要及时关闭。defer 是一个好习惯。
  3. 不要忽略错误:即使 defer file.Close() 可能返回错误,至少记录日志。完全忽略错误是 bug 的温床。
  4. 不要滥用 goroutine:每个 goroutine 都要有明确的退出路径。使用 sync.WaitGroupcontext 管理生命周期。
  5. 不要硬编码配置:端口、路径、超时时间、密钥都应该从配置读取,让程序适应不同环境。
  6. 不要过早优化:先让代码正确和可读,再用 benchmark 和 profile 找到真正的热点。

延伸阅读与实践建议

读完本文后,建议完成以下实践:

  1. 把文中所有示例代码在自己的机器上跑一遍
  2. 给示例代码补充错误分支的测试用例
  3. 尝试基于本文内容构建一个小型完整项目
  4. 在 review 他人的 Go 代码时,检查本文提到的边界是否被覆盖
  5. 订阅 Go 官方博客,关注语言演进和最佳实践更新

参考资源

  • Go 官方网站:https://go.dev/
  • Go 标准库文档:https://pkg.go.dev/std
  • Go by Example:https://gobyexample.com/
  • Effective Go:https://go.dev/doc/effective_go
  • Go 常见问题:https://go.dev/doc/faq
  • Go 项目实战社区案例和开源项目源码

本文力求在讲解技术细节的同时兼顾工程实用性。Go 语言的设计简洁但不简单,掌握它需要持续的实践和反思。希望这篇文章能成为你学习道路上的一个可靠参考。

继续阅读

探索更多技术文章

浏览归档,发现更多关于系统设计、工具链和工程实践的内容。

全部文章 返回首页

「golang」更多文章

  1. 熔断、降级与限流:Go 微服务韧性设计完全指南
  2. 事件溯源与 CQRS 在 Go 中的实践:复杂业务系统的架构升级
  3. TinyGo 嵌入式开发与物联网实战:微控制器编程完全指南