一个 Go 服务如何优雅退出
停止接收、等待在途、释放资源。把进程退出写成可测试的流程,而不是最后一行 os.Exit。
服务启动路径通常得到很多关注,退出路径却常常只剩一个信号处理函数。实际上,发布、扩容、配置更新都会频繁触发退出。如果停机流程没有顺序,请求可能完成了一半,消息可能被重复处理,日志也可能来不及写出。优雅退出应该从服务设计之初就被当作正常路径。
第一步:不再接收新工作
收到终止信号后,先让就绪检查返回不可用,并停止消费新的队列消息。负载均衡器感知状态通常需要时间,因此需要根据部署环境保留适当的摘流窗口。不要在这一步就关闭数据库,因为仍在处理的请求可能还需要它。退出顺序应与依赖关系的逆序一致。
第二步:给在途请求一段时间
http.Server.Shutdown 会关闭监听并等待活动连接,但这个等待不应该没有期限。使用一个独立于已经取消的根 context 的清理上下文,为剩余请求分配最终预算。这里创建新的 Background 是有意义的:它属于退出阶段,而不是继续假装请求仍然有效。
signals, stop := signal.NotifyContext(
context.Background(), os.Interrupt, syscall.SIGTERM,
)
defer stop()
<-signals.Done()
shutdownCtx, cancel := context.WithTimeout(
context.Background(), 10*time.Second,
)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("shutdown deadline: %v", err)
_ = server.Close()
}
长期连接、后台任务和自建 goroutine 不一定在 HTTP 服务的管理范围内,需要明确注册到自己的生命周期协调器。每个模块提供停止接收和等待完成两个动作,通常比暴露一个含义不清的 Close 更容易组织。等待期限耗尽后可以强制结束,但要留下足够的诊断信息。
第三步:按依赖顺序释放资源
等业务工作结束后,再关闭连接池、临时目录和遥测客户端。日志系统尽量最后关闭,这样清理阶段的错误仍然可见。避免在多个组件里重复关闭同一个资源;资源创建者负责释放,使用者只负责停止使用。对于不可避免的清理失败,记录上下文,但不要无限重试。
让退出流程可重复验证
为关闭过程编写集成测试:先创建一个可控的慢请求,开始退出,再确认请求在预算内完成,而新请求无法进入。还要验证第二次信号、超时强制退出和空闲服务退出。不要仅用进程最终结束来判断成功,因为丢失的任务同样会让进程很快结束。
当关闭逻辑能独立运行、能被观察、能在测试中重放,发布就会变得平常。一个服务的可靠性,不仅取决于它运行时做得多好,也取决于它离开时留下了什么。
感谢阅读,希望这些思考对你有所启发。