首页 / 资讯动态 / 后端服务优雅停机与未完成请求的安全处理

后端服务优雅停机与未完成请求的安全处理

线上服务半夜报警,CPU没飙升,内存没打满,但新请求全部超时,旧连接却还在跑。查了半天才发现,是发版时直接 kill 进程,没等那些正在执行的慢请求跑完。流量再小,只要涉及资金结算、数据库写入或长连接推送,这种生硬的停机方式就是在埋雷。解决这个问题的核心不在于容器编排层面的简单延迟,而在于应用自身需要具备感知“终止信号”的能力,并在限定时间内完成存量请求的排空。

不要混淆 SIGTERM 与 SIGKILL

很多人以为容器平台停止 Pod 就是瞬间消失,其实中间有一个关键的缓冲期。当删除 Pod 或触发滚动更新时,kubelet 会先向容器主进程发送 SIGTERM 信号。这是操作系统级别的优雅终止信号,应用可以捕获它。如果应用没做任何处理,默认行为就是立即退出,导致正在处理的 HTTP 请求连接被直接切断。如果应用在 terminationGracePeriodSeconds 时间内没有退出,kubelet 才会发送 SIGKILL 强制杀死进程。SIGKILL 是信号 9,进程无法捕获也无法忽略,属于物理层面的暴力清除。因此,优雅停机的唯一机会窗口就在收到 SIGTERM 到收到 SIGKILL 之间。

第一步:让应用“听到”停机指令

不同的技术栈捕获信号的方式大同小异,但必须确保进程能正确接收。以 Go 语言为例,不能只监听 HTTP 的 Shutdown 方法,而要在 main 函数中建立信号通道。代码逻辑通常是创建一个 os.Signal 通道,使用 signal.Notify 注册 syscall.SIGTERM 和 syscall.SIGINT。当收到信号时,程序不应立即 os.Exit,而是调用一个自定义的 shutdown 函数。Java 应用则通过 Runtime.getRuntime().addShutdownHook 注册钩子线程,但要注意钩子线程中不能做耗时过长的操作,且必须防止死锁。Node.js 则监听 process.on('SIGTERM') 事件。无论哪种语言,核心原则一致:主线程阻塞等待信号,收到信号后触发清理逻辑。

第二步:停止接收新流量

收到停机信号后,第一件事不是关闭数据库连接,而是把应用的“健康检查”标记为失败。在 Kubernetes 中,这对应 readinessProbe 的失效。一旦 /healthz 或 /ready 接口返回非 200 状态码,kube-proxy 会立即将该 Pod 从 Service 的 Endpoints 列表中摘除。这个摘除动作是异步的,通常需要 1 到 3 秒才能同步到所有节点的 iptables 或 IPVS 规则中。因此,应用在标记不可读之后,不能马上关闭 HTTP Server,而是必须主动 sleep 一段时间,通常建议 5 到 10 秒,确保负载均衡器不再向该实例转发新请求。这段时间被称为“排水等待期”,它直接决定了新请求被拒绝的概率。

第三步:处理未完成请求的“最长等待”策略

这是最容易被忽视的环节。即便 Pod 已经从负载均衡摘除,HTTP Server 内部可能仍有数十个甚至数百个正在处理的长连接。直接关闭 Server 会导致这些请求的客户端收到 connection reset 错误。正确的做法是调用 HTTP 库提供的优雅关闭接口。以 Go 的 net/http 为例,server.Shutdown(context) 会首先关闭所有空闲的 keep-alive 连接,然后阻塞等待所有活跃连接处理完毕,直到传入的 context 超时。这个 context 的超时时间设置非常讲究。如果设置得太长,比如 60 秒,可能导致发版卡死;如果设置得太短,比如 2 秒,慢请求依然会被强制中断。合理的策略是取 terminationGracePeriodSeconds 减去排水等待期和清理资源时间的差值。例如,Pod 总宽限期为 30 秒,排水等待 5 秒,预留 2 秒关闭数据库连接,那么 Shutdown 的超时时间应设为 23 秒。

第四步:请求的“幂等”与“断点续传”设计

即使给了 23 秒,依然可能存在超长的请求,比如生成大型报表或批量转账。优雅停机无法无限期等待,必须在业务层面解决这些超长任务的存活问题。对于写操作,必须引入幂等机制。如果客户端收到 504 超时或连接断开,它必须有能力重试,而服务端必须能识别出这是同一个操作。通常做法是客户端生成唯一的请求 ID 放入 Header,服务端根据请求 ID 和业务主键去重。对于读操作或计算密集型任务,可以考虑将同步请求转为异步任务。当服务端收到停机信号时,不再接受新的同步长连接,但可以将正在执行的任务状态序列化到外部存储,由其他健康节点接管继续执行。这种“断点续传”能力虽然开发成本高,但在处理关键业务时是必要的兜底方案。

第五步:资源清理的严格顺序

当所有 HTTP 请求处理完毕或超时后,进入资源清理阶段。这个顺序必须严格遵守“先外后内、先死后活”的原则。最先关闭的是对外暴露的网络连接,如 HTTP Server Listener。接着是消息队列的消费者,必须显式提交当前 Offset 并暂停消费,防止消息被拉取后处理失败而丢失。然后是数据库连接池,调用 Close 方法等待所有归还的连接物理关闭。最后才是内部的缓存刷新或日志缓冲区同步。很多人习惯在代码中随意使用 defer 来关闭资源,但在优雅停机场景下,defer 的执行顺序是后进先出,容易造成依赖混乱。建议在 shutdown 函数中显式按顺序调用各个模块的 Close 方法,而不是依赖 defer 栈。

数据库事务的“最后一秒”安全提交

停机信号到达时,可能恰好有一个数据库事务执行到一半。如果直接中断连接,该事务会被数据库自动回滚。这本身符合 ACID 特性,不会导致数据错乱,但会导致客户端超时后重试,如果重试逻辑不完善,可能产生重复扣款或重复下单的严重事故。更稳妥的做法是,在优雅停机流程中,优先等待数据库事务自行完成。可以在 ORM 层或数据库连接代理层增加一个计数器,记录当前活跃事务数。Shutdown 过程中,先停止接收新请求,然后轮询等待活跃事务数降为 0,再关闭连接池。如果等待超时,再强制关闭连接,依靠数据库的死锁检测或超时机制回滚事务,同时配合客户端的重试与幂等机制兜底。

消息队列消费者的特殊处理

后端服务往往同时扮演 HTTP Server 和 MQ Consumer 双重角色。优雅停机时,消费者端的处理比生产者端更棘手。当收到 SIGTERM 时,不能简单地关闭消费者连接。如果消费者正在处理一条消息,直接关闭会导致该消息处理失败,消息中间件会在超时后将其重新投递给其他节点。这本身没问题,但如果该消息处理了一半,比如已经扣除了库存但还没创建订单,重试时就会导致重复扣库存。因此,消费者在收到停机信号后,应该立即调用 Pause 或 Stop 方法停止拉取新消息,然后等待当前处理中的消息完成。对于支持批量拉取的消费者,必须将未处理完的消息 Nack 回队列,确保不丢失不重复。

容器编排层面的配合配置

应用代码写得再完美,如果 Kubernetes 的配置不当,优雅停机就是空谈。关键参数有三个。第一个是 Pod 的 terminationGracePeriodSeconds,这个值必须大于应用内部 Shutdown 超时时间加上排水等待时间,否则 Pod 会被强制杀死。第二个是 preStop 钩子。虽然应用本身捕获了信号,但在容器环境下,有时需要在 preStop 中执行 curl 命令调用本地接口触发排水,或者 sleep 几秒等待 iptables 规则更新。第三个是 Service 的 publishNotReadyAddresses 属性。默认情况下,Pod 一旦 Ready 状态为 False,就会被移出 Endpoints。但如果开启了该属性,即使 Pod 未就绪,流量依然可能被路由过来,这会直接绕过优雅停机的排水机制,必须保持关闭。

验证优雅停机的“混沌测试”方法

写完代码不代表万事大吉,必须通过破坏性测试来验证。测试方法很简单:使用压测工具对服务施加恒定 QPS,其中混合一部分耗时较长的慢请求,比如模拟 10 秒的延迟响应。在压测过程中,执行 kubectl delete pod 模拟发版或故障迁移。观察压测客户端的错误日志,如果出现 connection refused 或 reset by peer,说明排水等待时间不够或 Shutdown 逻辑有缺陷。如果出现 5xx 错误,说明请求被接收了但处理中断。理想情况下,客户端应该零错误,或者只有极少数因为超时设置过短导致的客户端侧超时。通过反复调整代码中的 sleep 时间和 Shutdown 超时参数,直到错误率降为零,才算真正完成优雅停机的调优。

总结

优雅停机的本质是对流量和数据的双重尊重。流量层面,它确保请求不丢失,通过排水和摘流机制让负载均衡器平滑遗忘这个节点。数据层面,它确保事务不中断,通过等待提交和幂等重试让业务逻辑闭环。这不是一个可以靠中间件或框架默认配置解决的问题,必须根据业务的具体延迟特性,在代码层面精确控制每一个等待环节的时长和顺序。只有把停机过程当作一次精心编排的撤退,而不是一次仓促的断电,才能真正做到未完成请求的安全处理。