直接调用http.server.shutdown()不等于热重启,因为它仅控制旧进程优雅退出(等待请求完成),既不启动新进程,也不传递监听socket的文件描述符;真正热重启需shutdown()、net.listener.file()提取fd、sigusr2触发子进程复用fd三者协同。

为什么直接调 http.Server.Shutdown() 不等于热重启
http.Server.Shutdown() 只负责旧进程的退出时机控制,它不会启动新进程,也不会传递监听 socket。只写这一句就以为“支持热重启”,结果是服务停了,新二进制根本没起来,curl 直接报 connection refused。
常见错误现象包括:
- 监控看到 5xx 突增、请求失败率飙升
-
lsof -i :8080只显示一个 PID,且重启后端口短暂不可用 - 客户端偶发
connection reset by peer—— 这是旧进程粗暴os.Exit(0)或未等完请求就关 listener 导致的 RST
真正热重启必须三者协同:Shutdown() 等请求、net.Listener.File() 提取 fd、SIGUSR2 触发子进程拉起并复用该 fd。
如何用标准库手写 SIGUSR2 热重启逻辑
别依赖 endless 或 graceful:前者最后更新在 2021 年,不兼容 Go 1.22+ 的 syscall 变更;后者已归档,且默认监听 SIGHUP,而 Kubernetes 和 systemd 默认发 SIGTERM,信号收不到就卡死。
手写要点(Unix-like 系统):
- 主进程监听
syscall.SIGUSR2(Linux/macOS 通用),不是SIGHUP - 收到信号后,立刻调
ln.(*net.TCPListener).File()拿到*os.File,记下其Fd()(通常是3) - 用
exec.Command(os.Args[0], os.Args[1:])启动子进程,并把 fd 加入cmd.ExtraFiles:例如[]*os.File{file} - 父进程紧接着调
server.Shutdown(ctx),ctx 来自context.WithTimeout(context.Background(), 45*time.Second);切勿写defer cancel(),否则 ctx 提前取消
子进程启动后第一件事是检查 os.Stdin.Fd() 之后的 extra file(即第 0 个 extra 对应 fd=3),然后 os.NewFile(3, "") → net.FileListener(file)。此时 http.Server.Addr 必须为空字符串 "",否则 server.Serve(ln) 会尝试重新 bind,报错 address already in use。
新进程复用 listener 时为什么还报 address already in use
不是代码写错了,而是系统级配置或初始化顺序问题。典型原因有:
- 父进程没在
exec前调ln.(*net.TCPListener).File(),导致子进程无 fd 可用 - 监听 socket 未启用
SO_REUSEPORT(Linux)或SO_REUSEADDR(macOS/BSD):内核不允许父子进程同时 bind 同一地址。创建 listener 后加一句:ln.(*net.TCPListener).SetReusePort(true)(Linux)或ln.(*net.TCPListener).SetReuseAddr(true)(macOS) - 子进程启动太快,父进程的 listener 还在 TIME_WAIT 或 shutdown 中;需确保子进程拿到 fd 后才触发父进程
Shutdown(),而非并发执行 -
lsof -i :8080应同时看到两个 PID 在监听同一端口;若只看到一个,说明 fd 传递失败或子进程未正确调用net.FileListener()
http.Server.Shutdown() 配超时 context 的坑
超时不是越长越好,也不是越短越安全。关键在 handler 是否配合 ctx 生命周期:
- 超时设为 30–60 秒较合理:太短(如 5 秒)会强杀文件上传、长轮询等合法请求;太长(如 5 分钟)拖慢发布节奏,且可能掩盖 handler 内部阻塞 bug
- handler 里所有阻塞操作必须用传入的 ctx 控制,比如
db.QueryContext(ctx, ...)、http.DefaultClient.Do(req.WithContext(ctx));若写time.Sleep(10 * time.Second)却不检查ctx.Done(),Shutdown()就会卡满超时才强制断开 - ctx 的 parent 不能带 cancel:例如
ctx, _ := context.WithCancel(context.Background())再传给WithTimeout(),可能导致 shutdown 流程被意外提前终止
真正容易被忽略的是:handler 内部的 goroutine 如果没接收 ctx.Done(),就会在 Shutdown() 返回后继续运行,造成内存泄漏或数据不一致——这不是重启机制的问题,而是业务代码的隐性负债。
golang免费学习笔记(深入):立即使用
在学习笔记中,你将探索golang的核心概念和高级技巧!











