go服务平滑重启不丢长连接,核心是旧进程用shutdown()等请求结束、新进程通过fd继承复用监听socket、所有handler响应ctx.done();shutdown对websocket/sse等升级连接无感知,需手动管理其生命周期并设合理超时。

Go服务平滑重启不丢长连接,核心就三点:旧进程用http.Server.Shutdown()等请求自然结束、新进程通过文件描述符继承复用监听 socket、所有 handler 必须响应 ctx.Done()。缺一不可。
为什么Shutdown()后长连接还是断了
常见现象是客户端收到 connection reset by peer 或大量 CLOSE_WAIT,根本不是 Shutdown() 没起作用,而是它只管 HTTP 连接生命周期,对已升级的长连接(如 WebSocket、SSE)完全无感知。
- WebSocket 升级后脱离
http.Server管理,底层 TCP 连接还在跑,但Shutdown()不会等它 - SSE 响应头设了
text/event-stream,连接保持打开,但 handler 里没监听req.Context().Done(),就会卡住 - 没手动维护长连接池:升级后的
conn没存进sync.Map,shutdown 信号来了也找不到对象去 Close() - 超时时间设太短(比如 5s),上传大文件或流式响应直接被强制中断
新进程怎么拿到老 listener 的 fd
Linux 下不能“复制” fd,只能靠 fork + exec 时继承,且必须显式传递——Go 标准库不自动做这事,得自己编码。
- 父进程收到
SIGUSR2后,先调ln.(*net.TCPListener).File()拿到*os.File,记下file.Fd()(比如是 3) - 启动子进程时,用
os.StartProcess并在sys.ProcAttr.Files中填入:[]uintptr{os.Stdin.Fd(), os.Stdout.Fd(), os.Stderr.Fd(), 3} - 子进程启动后第一件事:读环境变量(如
os.Getenv("LISTEN_FD")),用os.NewFile(3, "")恢复*os.File,再调net.FileListener(file)转成net.Listener - 验证是否成功:
lsof -i :8080应同时看到两个 PID;若只看到一个,说明 fd 传递失败或子进程没调net.FileListener
handler 里哪些地方必须检查 ctx.Done()
只要可能阻塞的操作,都得主动响应取消信号,否则 Shutdown() 会一直等,直到超时强杀 goroutine。
- 数据库查询:用
db.QueryContext(ctx, sql),别用db.Query() - HTTP 客户端请求:用
http.NewRequestWithContext(ctx, ...),再传给client.Do() - 定时等待:不用
time.Sleep(),改用select { case - WebSocket 读写:在
conn.ReadMessage()和conn.WriteMessage()前加select { case ,或用 <code>conn.SetReadDeadline()/SetWriteDeadline()配合 - 中间件里启动的 goroutine:比如日志上报、指标采集,必须监听
ctx.Done()并 clean up
最容易被忽略的时序陷阱
新旧进程之间存在竞态窗口,稍不注意就导致连接丢失或端口冲突。
- 父进程必须在
fork子进程 *之后*、发送信号 *之前*,调用listener.SetDeadline(time.Now().Add(-1))或类似方式让 listener 拒绝新 accept,否则新连接可能被旧进程收走又丢弃 - 子进程拿到 listener 后不能立刻
Accept(),要先验证是否能正常Accept()再启动 HTTP server,否则可能因 TIME_WAIT 或父进程未释放而报bind: address already in use -
context.WithTimeout()的 parent context 别带cancel(),否则 defer cancel() 可能在Shutdown()返回前就触发,导致 ctx 提前取消 - systemd 管理时,service 文件必须设
Type=forking并配PIDFile,否则systemctl reload会误判服务已死,直接发SIGKILL











