Go 服务优雅停机不是等待,而是分配退出预算

一个 Go 服务准备滚动更新:进程收到终止信号后不再接受新连接,等待正在处理的请求结束,然后退出。这个描述听起来已经足够“优雅”,但只要服务还包含消息消费、后台定时任务和批量写入,简单调用 Server.Shutdown 就不再等于安全停机。
设想一个明确的例子:运行环境给进程 30 秒退出期限;HTTP 请求通常在 2 秒内完成;消息处理最长可能占用 12 秒;内存中的写入批次每 5 秒刷新一次。若所有组件同时得知退出并各自等待自己的上限,30 秒并不会自动够用。更危险的是,某个组件可能一直产生新工作,让另一个组件永远排不空。
停机因此不是“等大家做完”,而是一次有截止时间的资源调度。需要决定谁先停止产生工作,谁负责消化存量,每一步最多使用多少时间,以及时间耗尽后允许丢掉什么。
先停入口

服务退出时首先要关闭工作来源,而不是立刻取消所有上下文。HTTP、消息队列、定时器和内部重试器都是入口,只停监听端口会遗漏后三种。入口仍然开放,排空速度就可能小于新增速度,系统看似在等待,实际工作量却没有下降。
入口也有先后关系。先把实例从流量选择中摘除,再停止接收新请求,可以降低连接在路由状态传播期间落到即将退出实例上的概率。消息消费者应先暂停拉取,再处理已经取得的消息。定时任务应阻止下一轮启动,但允许已经进入关键区段的这一轮继续。
这里的“停止”不是把所有 goroutine 一次性杀掉,而是建立一条单向边界:从某个时刻开始,系统不再承诺接受新工作。只有边界清楚,后续的存量才可计算。
计算预算

退出期限必须从外部强制终止时间反推。以上述 30 秒为示例,可以给摘流与入口关闭 3 秒,处理存量 20 秒,刷新状态 4 秒,并保留 3 秒给日志落盘、连接关闭和调度抖动。这些数字不是通用配置,而是一份可以检查的预算:3 + 20 + 4 + 3 = 30。
预算不能由各组件分别从 30 秒开始计时,否则顺序执行时总耗时会相加,达到 57 秒;并行执行时又可能争抢数据库连接和 CPU,使最长步骤超过预期。更稳妥的做法是创建一个总截止时间,再给每个阶段分配不超过剩余时间的子上下文。每进入一个阶段,都重新计算“距强制退出还剩多久”。
还要给强制退出预留明确余量。若应用把全部 30 秒交给业务排空,运行环境可能先杀进程,最后的指标、日志以及连接释放都没有机会执行。把预算用满并不代表利用率高,而是没有为不确定性留边界。
区分两种取消
Go 服务常用一个根 context.Context 管理后台 goroutine,但停机至少需要两种语义。第一种是“停止生产”:通知循环不要再领取任务、启动重试或触发下一轮定时作业。第二种是“立即中止”:总期限耗尽后,让尚未完成的操作尽快返回。
如果收到信号就取消同一个根上下文,正在提交数据库事务的任务也会立刻失败。若完全不取消,卡住的网络调用又可能拖过强制退出期限。把两者分开后,正常排空阶段可以使用仍然有效但带截止时间的上下文;只有预算耗尽,才触发全局强制取消。
这种区分还避免一个常见误判:goroutine 退出不一定表示任务安全完成。它可能因为上下文取消而返回,却没有确认消息,也没有把状态写入数据库。停机检查应关注业务承诺是否结束,而不只是 WaitGroup 是否归零。
排空有界
可排空的任务必须满足三个条件:存量可计数、单项耗时有上界、失败后有恢复路径。HTTP 请求的存量可以由服务器维护;工作池可以记录已领取与排队任务;消息消费者可以区分已拉取但未确认的消息。若连存量是多少都不知道,就无法判断停机是在前进还是停滞。
任务耗时上界不能只靠平均值。示例中的消息处理最长允许 12 秒,而排空预算是 20 秒,那么停机开始后至多适合完成一轮已领取任务;此时不应再领取第二轮。若任务内部还会进行三次重试,每次超时 5 秒,理论路径已经达到 15 秒,再叠加退避便可能突破预算。因此停机阶段通常要停止新的应用层重试,把失败交给队列重投或下次启动恢复。
并发也不宜在退出时盲目提高。扩大并发能更快消化队列,却可能压满数据库连接池,使正在完成的事务相互等待。停机应沿用已经验证的并发边界,并以剩余任务数、最老任务耗时和未确认消息数判断进度。
状态要可恢复
即使预算合理,也不能保证所有任务完成。进程可能被直接终止,数据库可能在停机期间不可用,单个请求也可能超过预计上限。真正的可靠性来自“没做完也能恢复”,而不是假设优雅停机永远执行完整。
消息任务应在副作用成功后才确认;重复投递可能发生,因此写入需要幂等键或状态条件。内存批次若属于不可重建数据,就不应只依赖停机钩子刷新,而应缩短未持久化窗口或先写可靠日志。定时任务若可能跨进程重启,应把执行租约和完成状态放在进程外,而不是用内存布尔值判断。
反过来,不是所有状态都值得保存。缓存、可重新计算的聚合值、未命中的短期记录可以直接丢弃。停机设计需要为状态分类:必须提交、允许重试、可以丢弃。分类越明确,最后几秒就越不会被无价值的清理占用。
失败要可见
停机路径平时很少运行,日志又容易随着进程消失,因此需要少量但结构化的观测。至少记录停机开始原因、总期限、各阶段开始与结束、剩余任务数,以及最终是正常完成还是预算耗尽。指标可以区分正常退出次数、强制取消次数和各阶段耗时,但不能等待远端指标系统响应而阻塞退出。
测试也应围绕时间边界,而不是只验证函数被调用。可以用可控的处理器分别模拟立即完成、接近截止时间完成、忽略取消和返回错误四条路径;再检查入口是否先关闭、未完成任务是否保留恢复条件、总耗时是否小于外部期限。时间测试应留出调度余量,避免把“恰好 20 秒”当成稳定断言。
优雅停机最终是一份降级契约:在固定时间内,先阻止新工作,再尽量兑现已经接受的承诺;无法兑现的部分必须能重试、恢复或被明确舍弃。Shutdown、WaitGroup 和信号处理只是实现工具。只有入口顺序、时间预算、取消语义与恢复边界同时明确,服务退出才不会变成一次碰运气的等待。