← [ BACK_TO_NOTES ]

Go 服务优雅关闭

停止接收新工作后有界排空或取消在途,再释放依赖;区分 HTTP Shutdown、请求 Context 与 worker 的退出策略。

#concept #growing #dev #backend #go

[!info] related notes

Go 服务优雅关闭

这个概念为什么会出现

进程总会停:发版、弹性缩容、节点抢占、人为重启。粗暴 SIGKILL 或直接 os.Exit 会导致:

  • 半写响应、半提交业务
  • 连接被负载均衡认为“活着”却已无服务
  • 后台 goroutine 写到已关的 DB
  • 消息未 ack 造成重复或丢失(视语义)

优雅关闭(graceful shutdown)要在有界时间内完成:停入口 → 按策略排空或取消 → 有界等待 → 释放依赖 → 退出。它不是“永不杀”,而是先礼后兵。

[!abstract] 一句话理解 用信号派生可取消的 root context,停止接收新请求/新任务,对 HTTP 调 Server.Shutdown,对 worker 先停止生产,再按排空或取消策略等待,全程带超时,超时后降级为强制退出。

最小可运行示例

先把示例放进业务场景,再看代码:

场景:发版时让在途「慢任务」接口先收尾再退出

订单服务有一个 GET /work,内部要跑约 2s(模拟慢查询 / 外部 RPC)。
K8s 发版会发 SIGTERM:若进程立刻死掉,负载均衡上的在途请求会半截断开。

优雅关闭路径:

  1. 收到信号 → 停止接新连接
  2. 等在途 Handler 结束(有总预算,如 10s)
  3. 超时再 Close 强制拆掉

Shutdown 不会自动取消在途请求的 Context,而是等待它们自然结束。Handler 应响应客户端或应用的取消,但是否在停机时主动取消它,需要由应用决定。

本例会 listen;另开终端 curl 打 /work 的同时,在服务端终端 Ctrl+C(或 kill -TERM)观察日志。
Windows 下常用 Ctrl+C → os.Interrupt。

package main

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

// handleSlowWork 模拟“发版时可能还在跑的慢接口”。
//
// 业务意图:正常约 2s 返回 done;客户端断开或应用主动取消时退出。
// Server.Shutdown 本身不会取消这个请求的 Context。
func handleSlowWork(w http.ResponseWriter, r *http.Request) {
	select {
	case <-time.After(2 * time.Second):
		// 正常完成路径。
		_, _ = w.Write([]byte("done"))
	case <-r.Context().Done():
		// 客户端或应用取消:协作退出。
		return
	}
}

func main() {
	mux := http.NewServeMux()
	mux.HandleFunc("GET /work", handleSlowWork)

	srv := &http.Server{
		Addr:              ":8080",
		Handler:           mux,
		ReadHeaderTimeout: 5 * time.Second,
	}

	// 根生命周期:把 SIGINT/SIGTERM 变成 ctx.Done()。
	ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
	defer stop() // 退出时兜底释放信号注册

	// Listen 放后台:main 要同时等“听失败”和“停机信号”。
	errCh := make(chan error, 1)
	go func() {
		log.Println("listen", srv.Addr)
		errCh <- srv.ListenAndServe()
	}()

	select {
	case err := <-errCh:
		// 端口占用等启动失败;Shutdown 触发的返回是 ErrServerClosed,不算事故。
		if err != nil && !errors.Is(err, http.ErrServerClosed) {
			log.Fatal(err)
		}
	case <-ctx.Done():
		stop() // 立即解除本次注册;无其他注册时,后续信号恢复默认行为
		log.Println("shutdown signal")
		// 给在途请求收尾预算(与 K8s terminationGracePeriod 对齐思考)。
		shCtx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
		defer cancel()
		if err := srv.Shutdown(shCtx); err != nil {
			log.Println("shutdown:", err)
			// 超时降级:强制关掉残余连接。
			_ = srv.Close()
		}
	}
	log.Println("bye")
}

建议运行:

go run .
# 终端 A 保持服务;终端 B:
curl -s localhost:8080/work
# 在 curl 等待的 2s 内,回终端 A 按 Ctrl+C,观察 shutdown 日志

期望(无中断、请求跑完):

  • curl 输出 done
  • 之后 Ctrl+C:日志含 shutdown signal 与 bye

在途请求若超过 10s 尚未结束,本例调用 Close 关闭残余普通 HTTP 连接。Close 不会强杀任意 goroutine,也不等待已 hijack 的连接。

结合场景再看三个关注点

  1. signal.NotifyContext = 进程级取消根
    发版/缩容信号变成 ctx.Done();本例没有把该 Context 用作请求 BaseContext,因此停机信号不会立刻取消 /work。

  2. Shutdown 有界等待
    停新连接 + 等在途;预算用 WithTimeout 包一层。

  3. Handler 必须协作
    /work 响应客户端取消;停机时则允许预算内完成。取消和排空是不同策略。

核心概念与准确模型

关闭阶段

下面示意 HTTP 排空与 worker 停止协调;worker 的取消或排空策略需预先选定,不能认为 close channel 与 cancel 等价。

sequenceDiagram
    participant OS
    participant Main
    participant HTTP as http.Server
    participant W as workers
    participant Dep as DB/MQ
    OS->>Main: SIGTERM
    Main->>HTTP: Shutdown(ctx)
    Main->>W: cancel / close jobs
    HTTP-->>Main: in-flight done or timeout
    W-->>Main: Wait done
    Main->>Dep: Close
    Main->>OS: exit 0
阶段动作
1. 感知信号、K8s preStop、父 context 取消
2. 停入口摘流量、Shutdown、停消费者 fetch
3. 选择策略排空已接收工作,或主动取消请求/任务;不要混为一谈
4. 排空WaitGroup / errgroup / Shutdown 等待
5. 释放DB、缓存、文件、消息 client
6. 退出可观测日志与非 0 码策略

HTTP:Shutdown vs Close

API行为
Shutdown(ctx)停 listen、等在途、ctx 超时则返回错误
Close()关闭普通活动/空闲连接;不等待 handler 或已 hijack 的连接

生产默认走 Shutdown;超时再 Close。见 Server.Shutdown。

与 context 的关系

  • 请求级:r.Context() 在客户端连接关闭、HTTP/2 请求取消或 handler 返回时取消;Shutdown 本身不触发它。
  • 进程级:signal.NotifyContext 或手动 WithCancel。
  • 依赖级:DB/HTTP 客户端调用传同一派生 ctx。

若把信号 Context 直接用于 Server.BaseContext,信号会立即传给请求,可能放弃原本可排空的工作。需要先排空、超时后取消时,应分开管理信号与业务 Context。context 取消是协作式的:代码不检查就停不了。见 context。

后台 worker / 消费者

// 伪代码结构
jobs := make(chan Job)
var wg sync.WaitGroup
for i := 0; i < n; i++ {
	wg.Add(1)
	go func() {
		defer wg.Done()
		for {
			select {
			case <-ctx.Done():
				return
			case job, ok := <-jobs:
				if !ok {
					return
				}
				handle(ctx, job)
			}
		}
	}()
}

// 关闭:先停生产,再关 channel,再 Wait
stopProduce()
close(jobs)
wg.Wait()

上述 worker 同时监听取消与 jobs;若先取消 ctx,可能丢下缓冲区中的任务,因此不是保证排空的代码。要排空,应停止并等待全部生产者,再由唯一所有者关闭 jobs,让 worker 读到 channel 关闭;任务处理与等待也需要有界策略。WaitGroup.Wait() 自身没有超时。

原则:由协调全部发送方的唯一所有者关闭;关闭顺序与 pipeline 一致(pipeline)。

K8s / 容器场景

  1. Pod 开始终止后,宽限期开始计时;若配置了 preStop,先执行 hook,再发送 TERM。
  2. EndpointSlice 的终止/就绪状态更新与应用关闭并行发生;固定 sleep 不能证明所有代理都已摘流。
  3. preStop、应用排空和清理共同消耗宽限期,应用 Shutdown 预算应小于剩余时间。
  4. 宽限期耗尽仍未退出时,运行时会强制终止。

预算要端到端对齐:反代超时、LB、应用、编排宽限期。

设计动机

  1. 正确性:减少半截事务与重复副作用(仍需幂等兜底)。
  2. 体验:发版不对用户“整页失败风暴”。
  3. 资源:连接、文件描述符、锁有序释放。
  4. 可运维:日志能看出“信号 → 排空 → 退出”时间线。

边界情况与反直觉行为

1. 优雅 ≠ 无限等

必须有 deadline。卡死的下游会拖死发布。

2. Shutdown 不等待你在 Handler 里另起的失控 goroutine

只覆盖该请求的 ServeHTTP 返回;go process() 若未挂 ctx/WaitGroup,进程仍可能提前退出或泄漏。

3. 长连接 / SSE / WebSocket

SSE 通常仍是活动 HTTP 请求,会参与 Shutdown 等待;升级或 hijack 后的 WebSocket 不由 Shutdown/Close 自动等待关闭。应分别设计通知、关闭和完成等待,RegisterOnShutdown 回调本身也不会被 Shutdown 等待完成。

4. 多 Server / 多入口

HTTP metrics、pprof、主 API 可能多个 Listen:统一纳入 errgroup 与同一 shutdown 序列。

5. Windows 信号差异

SIGINT/SIGTERM 可用性因平台而异;开发与容器路径都要测。

常见误区

[!warning] 常见误区:只监听信号但不 Shutdown 错误:<-sig 后 os.Exit(0)。
正确:先 Shutdown/Wait 再退出。

[!warning] 常见误区:Handler 忽略 r.Context() 错误:已经发生客户端或应用取消,阻塞工作仍不退出。 正确:阻塞点全部 select Done 或用带 ctx 的 API。

[!warning] 常见误区:先关 DB 再排空请求 错误:在途请求全部报错。
正确:先停入口与接新任务,再等在途,最后关依赖。

[!warning] 常见误区:关闭时仍往已关 channel 发送 错误:panic。
正确:用 ctx 停生产者,单一关闭点,发送侧负责 close。

与相邻概念对比

概念差异
context取消信号载体;本篇是进程生命周期编排
net/http提供 Shutdown;要嵌入整体策略
worker pool池的排空是关闭子问题
健康检查 fail摘流手段之一,不能替代进程内 Shutdown
强制杀死最后手段;破坏在途

工程实践

  1. 协调统一停机流程,但将“停止接新工作”和“取消已接收工作”的信号按策略分开。
  2. 超时分层:信号到强制退出总预算;内部再切分 Shutdown/worker/DB。
  3. 可观测:日志打印 shutdown 开始、各阶段耗时、超时强制。
  4. 幂等:仍假设会有重试与重复投递。
  5. 测试:写集成测模拟信号或调用 Shutdown,断言在途完成/超时行为。
  6. 与部署:对齐 K8s terminationGracePeriodSeconds、反代 idle/timeout。
  7. errgroup:golang.org/x/sync/errgroup 可把多组件 Run/Wait 与 cancel 绑在一起。
// 结构示意:主 Run
g, ctx := errgroup.WithContext(parent)
g.Go(func() error { return srv.Run(ctx) })
g.Go(func() error { return consumer.Run(ctx) })
// Run 必须主动处理 ctx;取消不会强制停止任意函数
err := g.Wait()

可验证实验

实验 1:在途请求

对 /work 发 curl,在 sleep 期间 Ctrl+C 服务,观察是否仍返回(预算内)。

实验 2:忽略 context

先验证 Shutdown 不取消请求 Context;再主动取消客户端请求,对比 Handler 是否及时退出。只删除 Done 分支,不应被误认为能改变 Shutdown 的取消机制。

实验 3:worker 排空

启动固定 worker,关闭时统计已处理与未处理任务数。

实验 4:超时强制

把 Shutdown 超时调到 100ms,Handler sleep 1s,确认走 Close 与错误日志。

本节总结

  • 本质:有界、有序地停入口、取消、排空、释放。
  • HTTP:Shutdown 等待自然完成;主动取消是独立策略。
  • 后台:明确排空与取消的区别,协调生产者、关闭输入并有界等待。
  • 下一步:把策略嵌进 项目结构 的 cmd;并发侧复盘 取消关系。

自测题

概念题

  1. Shutdown 与 Close 的核心差别?
  2. 为什么优雅关闭必须有超时?
  3. 谁应负责 close(jobs)?

代码推理题

Shutdown 返回后主函数立刻 db.Close(),但某 Handler 里 go writeDB() 无同步。可能发生什么?

工程思考题

K8s terminationGracePeriodSeconds=30,反代超时 60s,应用 Shutdown 60s。有何问题?如何改?

参考答案

展开
  1. Shutdown 停新连接并尝试等在途;Close 立即切断。
  2. 防止故障依赖拖死发布与编排强杀不可控。
  3. 发送方(所有权方),并先停生产。
    代码题:进程退出或 DB 已关时后台写失败/竞态,数据丢失或 panic。
    工程题:编排 30s 会先杀进程,应用 60s 预算无效;应使 应用预算 < 宽限期,并协调反代/LB 超时。

延伸阅读与资料来源

资料类型支撑
Server.Shutdown标准库HTTP 优雅停
signal.NotifyContext标准库信号 → context
Package context标准库取消传播
Go Concurrency Patterns: Context官方博客请求生命周期
Pipelines and cancellation官方博客阶段关闭与取消
Kubernetes Pod termination官方文档preStop、TERM 与终止预算

笔记元信息

  • 建议文件名:go-graceful-shutdown.md
  • 所属阶段:服务工程化
  • 学习顺序:net/http 与 context 之后
  • 建议下一篇:worker pool 或 构建与部署
  • 本篇状态:已深化(结构完整;含 HTTP 与 worker 统一生命周期)
创建于 2026/6/20 更新于 2026/10/6
Digital Biome
Access

键盘导航

j / k / ↑ / ↓
选择对象
Enter / o
打开选择
/ 或 Ctrl+K
全站搜索
i / f / 1–4
筛选当前列表
g → h / l / p / t / a
首页 / 笔记 / 项目 / 工具 / About
r → 1–9
打开所选对象的关联
u
返回上一页
Esc
关闭浮层 / 取消命令;输入框内清空文本