一个没有处理 SIGTERM 的服务,每次滚动发布都会硬生生掐断一批正在处理的请求,丢单又报错:一次优雅停机缺失的深度复盘

每次上线滚动重启,监控都准时出现一小撮 5xx 和超时,客服收到零星下单失败投诉,赶上高峰就放大成一大批丢单。根因是服务完全没做优雅停机:部署滚动更新时给老进程发 SIGTERM,而进程没捕获它,被立即终止或宽限期后 SIGKILL 强杀,正在处理的请求被硬生生掐断、连接没关、事务可能没提交。本文讲透优雅停机要解决什么和信号机制,给出捕获 SIGTERM、srv.Shutdown 停接新请求等存量带超时、配合 k8s 宽限期/preStop/readiness 先摘流量的正解,梳理服务生命周期常见坑,最后落到'重视结束与收尾的设计、给生命周期终点以应有重视'的认知。

一个没有处理 SIGTERM 的服务,每次滚动发布都会硬生生掐断一批正在处理的请求,丢单又报错:一次优雅停机缺失的深度复盘

那个问题是被发布时段的监控曲线揪出来的:我们每次上线、滚动重启服务,监控上都会准时出现一小撮 5xx 错误和请求超时,客服那边也总在发布后收到零星的"下单失败/支付卡住"投诉。一开始我以为是发布瞬间的抖动,没太在意,直到有一次发布正好赶上业务高峰,这"一小撮"放大成了一大批丢单,我才不得不认真排查。我盯着发布流程看了半天,终于想明白了根源,后背发凉:我们的服务完全没有处理"优雅停机"。部署系统在滚动更新时,会先给老进程发一个 SIGTERM 信号、通知它"该退出了",可我们的进程没有捕获这个信号——于是它要么被默认行为立即终止、要么过一会被 SIGKILL 强行杀死。在它被杀的那一刻,它手里正在处理的那些请求(正在查库、正在调下游、正在写订单)被硬生生掐断,连接没关、事务可能没提交、响应再也回不到客户端——于是就有了那一批 5xx 和丢单。我们只让进程""了,却没给它一个机会"把手头的事干完再停"。这篇就把这次"优雅停机缺失"的坑,从头到尾复盘一遍。

故障现场:一个不处理 SIGTERM、说停就停的服务

问题不在某一行代码,而在缺了一段代码——进程没有任何对 SIGTERM 的处理:

// ✗ 出问题的服务: 启动后直接 ListenAndServe, 没有任何优雅停机处理
func main() {
    http.HandleFunc("/order", handleOrder)   // 下单接口, 一次处理可能耗时几百ms
    http.ListenAndServe(":8080", nil)        // ✗ 没有捕获SIGTERM, 没有优雅关闭
}
// 部署滚动更新时发生了什么:
// 1. 部署系统给老进程发 SIGTERM("请退出");
// 2. 进程【没有捕获】SIGTERM → 走默认行为: 进程被立即终止;
//    (或: 进程没在宽限期内退出 → 部署系统过一会发 SIGKILL 强杀);
// 3. 被终止的【那一刻】, 所有【正在处理中】的请求被硬生生中断:
//    - 正在查数据库/调下游的, 连接被掐断;
//    - 正在写订单事务的, 可能没来得及提交(或提交了没返回);
//    - 客户端拿到的是 连接重置/超时/502 → 下单失败、丢单;
// 4. 同时, 负载均衡可能还在往这个【正在死】的实例转发新请求(它还没被摘掉),
//    这些新请求也全部失败。

// 缺失的两件事:
// - 没有"优雅停机": 收到SIGTERM后, 应该【先停止接收新请求, 把已接收的处理完, 再退出】;
// - 没有和负载均衡/注册中心配合: 应该【先从LB摘掉自己(不再有新请求进来), 再开始停机】。

// 关键: 进程不处理SIGTERM = "说杀就杀", 手头正在处理的请求全部被中断 → 发布即抖动、即丢单。

第一次意识到这个缺失时,我有些懊恼:"我只想着怎么把服务'启动起来',却从来没想过怎么让它'体面地停下来'。"这个坑最容易被忽视的地方在于:只在"停止/重启"的那一刻发作,而服务"跑起来"的时候一切正常;开发时我们盯着的是"功能对不对",几乎从不关心"它被关闭时会怎样"——"停机"这个生命周期阶段,是被遗忘的角落可在持续部署的今天,服务被频繁地滚动重启(每次发布、每次扩缩容、每次故障迁移),"优雅停机"不是可有可无的细节,而是保证"发布无损"的刚需下面就来拆解,优雅停机到底要做什么。

第一件事:搞懂优雅停机要解决什么,以及信号机制

我顺着这次事故,把"优雅停机"的来龙去脉彻底理清了。

优雅停机(graceful shutdown)要解决什么?

【核心: 让进程在"被通知退出"后, 不立即死, 而是"停止接新活、干完手头的活、清理干净、再退出"】

1. 进程是怎么"被通知退出"的——信号(signal):
   - SIGTERM(15): "请你终止"——【可被捕获】, 是优雅停机的信号(部署/k8s默认发它);
   - SIGKILL(9): "立即杀死"——【不可捕获、不可忽略】, 强制终止(宽限期后的最后手段);
   - SIGINT(2): Ctrl+C, 通常也当作请求退出处理。
   - → 部署系统的套路: 先发SIGTERM给你机会优雅退出, 等一个"宽限期"还没退就发SIGKILL强杀。

2. 不做优雅停机的后果:
   - 收到SIGTERM不处理 → 进程立即死(或被SIGKILL强杀);
   - 正在处理的请求被中断、连接被掐、事务可能不一致、响应丢失 → 报错、丢数据。

3. 优雅停机要做的几件事(收到SIGTERM后):
   - ① 从负载均衡/注册中心【摘掉自己】(或先让健康检查失败), 不再有【新请求】进来;
   - ② 【停止接收新请求】, 但【继续处理完已经接收的请求】(等它们做完);
   - ③ 给一个【超时】: 等存量请求一段时间(如30秒), 还没完就该放弃(不能无限等);
   - ④ 【清理资源】: 关闭数据库连接池、刷新缓冲、提交/回滚事务、关闭文件;
   - ⑤ 然后才【正常退出】。

4. 要和编排系统配合(以k8s为例):
   - k8s删Pod时: 先把Pod从Service端点摘除(停止新流量) + 发SIGTERM;
   - terminationGracePeriodSeconds: 给的宽限期(默认30s), 超了发SIGKILL;
   - preStop钩子: 可在发SIGTERM前先sleep几秒, 等LB摘除生效, 避免"摘除还没生效就开始停"。

一句话: 部署靠SIGTERM通知退出、宽限期后SIGKILL强杀; 优雅停机=捕获SIGTERM后先摘流量、
   停收新请求、处理完存量(带超时)、清理资源再退出; 并和编排系统的宽限期/preStop配合。

这套机制,是整个坑的根。进程靠信号被通知退出:SIGTERM(可捕获,优雅停机的信号,部署默认发它)、SIGKILL(不可捕获,强制终止,宽限期后的最后手段);部署套路是先发 SIGTERM 给你机会优雅退出、宽限期还没退就 SIGKILL 强杀不做优雅停机:收到 SIGTERM 不处理则进程立即死,正在处理的请求被中断、事务不一致、响应丢失。优雅停机要做:①从负载均衡摘掉自己(不再有新请求);②停止接新请求、处理完存量;③给个超时(等存量一段时间、不无限等);④清理资源(关连接池、刷缓冲、提交事务);⑤再正常退出还要和编排系统配合:k8s 删 Pod 时先从 Service 摘端点+发 SIGTERM,terminationGracePeriodSeconds 是宽限期,preStop 可先 sleep 等 LB 摘除生效一句话:部署靠 SIGTERM 通知退出、宽限期后 SIGKILL 强杀;优雅停机=捕获 SIGTERM 后先摘流量、停收新请求、处理完存量(带超时)、清理资源再退出;并和编排系统的宽限期/preStop 配合。

第二件事:正解——捕获 SIGTERM,优雅关闭 HTTP server,配合编排宽限期

搞懂了原理,正解就清晰了:捕获 SIGTERM、调用框架的优雅关闭(停止接新请求、等存量处理完、带超时)、清理资源;并配置好编排系统的宽限期和 preStop

// ✓ 正解: 捕获SIGTERM, 优雅关闭 http.Server
func main() {
    srv := &http.Server{Addr: ":8080", Handler: router()}

    // 后台启动服务
    go func() {
        if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
            log.Fatalf("listen: %v", err)
        }
    }()

    // ★ 监听 SIGTERM / SIGINT
    quit := make(chan os.Signal, 1)
    signal.Notify(quit, syscall.SIGTERM, syscall.SIGINT)
    <-quit                                   // 阻塞, 直到收到信号
    log.Println("收到停机信号, 开始优雅关闭...")

    // ★ 给一个超时(如30秒), 等存量请求处理完
    ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    defer cancel()

    // ★ Shutdown: 停止接收新请求, 等已接收的请求处理完(或超时), 再返回
    if err := srv.Shutdown(ctx); err != nil {
        log.Printf("优雅关闭超时, 强制退出: %v", err)
    }

    // ★ 清理其他资源: 关闭数据库连接池、刷新缓冲、关闭消息队列消费者等
    db.Close()
    cache.Close()
    log.Println("已优雅退出")
}
// → 收到SIGTERM后: srv.Shutdown 停止接新请求、等存量处理完(最多等30秒);
//   存量请求得以正常返回、资源被清理 → 发布时不再掐断请求、不再丢单。
# ✓ 配套: Kubernetes 配置, 让摘流量和停机配合好
spec:
  terminationGracePeriodSeconds: 40   # ★ 宽限期要 > 你的优雅关闭超时(30s)+余量
  containers:
  - name: app
    lifecycle:
      preStop:
        exec:
          # ★ 先 sleep 几秒, 等 k8s 把本Pod从Service端点摘除"生效"(传播有延迟),
          #   避免"还在往我转发新请求, 我却已经开始停机"的窗口
          command: ["sh", "-c", "sleep 5"]
    readinessProbe:                     # 健康检查: 停机时让它失败, 帮助尽快摘流量
      httpGet: { path: /healthz, port: 8080 }
# k8s删Pod流程: 摘Service端点 + 执行preStop(sleep 5) + 发SIGTERM →
#   你的进程优雅关闭(最多30s) → 若40s还没退则SIGKILL。
# ★ 关键时序: 先摘流量(没有新请求了), 再停机(处理完存量), 宽限期 > 优雅关闭超时。

修复的核心,是"捕获停机信号,先摘流量、再处理完存量、清理后退出,并和编排宽限期对齐"正解一:捕获 SIGTERM、优雅关闭——signal.Notify 监听 SIGTERM,收到后用 srv.Shutdown(ctx)(停止接新请求、等存量处理完,带 30 秒超时),再关闭数据库连接池等资源、正常退出正解二:配置编排系统——k8s 的 terminationGracePeriodSeconds 要大于优雅关闭超时+余量;preStop 先 sleep 几秒等 Service 端点摘除生效(避免"还在转发新请求就开始停机");readinessProbe 帮助尽快摘流量关键时序是:先摘流量(没有新请求)、再停机(处理完存量)、宽限期 > 优雅关闭超时归根结底:捕获 SIGTERM 后先摘流量、停收新请求、处理完存量(带超时)、清理资源再退出;并让编排的宽限期/preStop 与之对齐。

第三件事:服务生命周期与发布相关的其他常见坑

排查后我把服务停机/发布相关的其他常见坑也系统梳理了一遍。

服务生命周期 / 发布的其他常见坑

# 1. 不处理SIGTERM(本文): 停机掐断请求。→ 捕获SIGTERM优雅关闭。

# 2. 宽限期太短: terminationGracePeriod < 优雅关闭耗时, 还没处理完就被SIGKILL。→ 宽限期要够。

# 3. 没先摘流量就停机: 边停边还在收新请求。→ preStop sleep / readiness失败先摘流量。

# 4. 优雅关闭没设超时: 存量请求hang住导致进程永远不退。→ Shutdown要带超时, 该放弃就放弃。

# 5. 没处理在途的异步任务/消息: 只关了HTTP, 没等消费中的MQ消息/后台任务做完就退。

# 6. 资源没清理: 没关数据库连接、没刷日志缓冲、没注销服务注册。

# 7. 启动没做就绪检查: 新实例还没真正ready, LB就把流量打过来(发布另一端的对称问题)。

# 8. 有状态/长连接没处理: WebSocket/长轮询连接在停机时要通知客户端重连。

# 共同根源: 只关注服务"运行中"的逻辑, 忽视了"启动"和"停机"这两个生命周期阶段的正确处理;
#   而在持续部署下, 服务被频繁启停, 这两个阶段的健壮性直接决定"发布是否无损"。

# 核心: 把"优雅启动"和"优雅停机"当成服务的必备能力; 捕获SIGTERM、先摘流量、处理完存量(带超时)、
#   清理资源; 和编排系统的宽限期/preStop/探针配合; 让每次发布对用户无感、无损。

排查让我把发布相关的其他坑也梳理清了。一、不处理 SIGTERM(本文)。二、宽限期太短(还没处理完就被强杀)。三、没先摘流量就停机(边停边收新请求)。四、优雅关闭没设超时(存量 hang 住永不退)。五、没处理在途异步任务/消息六、资源没清理七、启动没做就绪检查(对称问题)。八、有状态/长连接没处理它们的共同根源是:只关注服务"运行中"的逻辑,忽视了"启动"和"停机"这两个生命周期阶段;而在持续部署下服务被频繁启停,这两个阶段的健壮性直接决定"发布是否无损"核心是:把"优雅启动"和"优雅停机"当成服务必备能力;捕获 SIGTERM、先摘流量、处理完存量(带超时)、清理资源;和编排系统的宽限期/preStop/探针配合;让每次发布对用户无感无损下面这张图,是这次优雅停机缺失坑的成因与解法:

第四件事:常见信号速查表

这次踩坑后,我把和进程停机相关的常见信号整理成一张表。

信号 含义 能否捕获
SIGTERM(15) 请求终止(优雅退出) 能(优雅停机捕获它)
SIGKILL(9) 立即杀死 不能(强制, 最后手段)
SIGINT(2) Ctrl+C 中断
SIGHUP(1) 挂起/重载配置 能(常用于热重载)
SIGQUIT(3) 退出并dump
SIGSTOP(19) 暂停进程 不能

这张表把停机相关的信号钉清了。核心是:进程的退出是通过"信号"来协调的——SIGTERM 是"礼貌的请求退出"(可捕获,你能拦下来做优雅停机)、SIGKILL 是"不容商量的强杀"(捕获不了,内核直接干掉);部署系统的标准做法,就是先发"礼貌的 SIGTERM"给你机会,再用"强硬的 SIGKILL"兜底它给我的最大启发是:这套"先礼后兵"的信号机制,体现了一种优雅的协作式设计——系统先用一个"可被拒绝/可被处理"的温和方式(SIGTERM)请求你配合,给你自我了断的体面和时间;只有当你不配合或超时,才动用不可抗拒的强制手段(SIGKILL);这种"先协商、给宽限、再强制"的模式,在很多健壮系统里都能看到(如事务的提交/回滚、锁的等待/超时、连接的关闭挥手)这让我对"如何设计退出/终止"有了体会:好的"终止"设计,不是"啪一下关掉",而是"给一个优雅退出的机会 + 一个兜底的强制保证"——既照顾了"正常情况下的体面收尾",又防住了"异常情况下的赖着不走";无论是写服务停机、还是设计任何"关闭/取消/超时"逻辑,这种"优雅优先、强制兜底"的思路都值得借鉴理解信号的先礼后兵、用"优雅优先+强制兜底"设计终止逻辑——是这个坑带给我的认知。

第五件事:服务全生命周期的健壮性清单

这次让我意识到,服务的健壮性不只在"运行中",而在它的整个生命周期。我整理成表。

阶段 要做对的事 不做的后果
启动 就绪后才接流量(readiness) 没ready就被打流量, 报错
运行 功能正确+容错 常规bug
停机(本文) 优雅停机, 处理完存量 掐断请求、丢单
异常崩溃 能被拉起+不丢数据 数据不一致
扩缩容 启停都平滑 抖动
配置变更 热重载或平滑重启 重启抖动

这张表道出了一个常被忽视的全局视角。核心是:一个真正健壮的服务,健壮性要覆盖它的整个生命周期——不只是"运行中功能对",还包括"启动时就绪了才接流量""停机时优雅收尾""崩溃后能恢复且不丢数据""扩缩容/配置变更时平滑";任何一个生命周期阶段没处理好,都会成为可用性的破口它给我的深刻启发是:我们做服务时,注意力极度偏向"运行中"这个阶段(写功能、修 bug),却系统性地忽视了"启动、停机、崩溃、变更"这些"过渡时刻"——而恰恰是这些"状态切换的瞬间",最脆弱、最容易出问题(本文的停机、发布抖动都是);"运行稳"只是健壮的一部分,"切换稳(启停、升降、恢复都平滑)"才是更高阶、也更容易被遗漏的健壮性这给了我一种更完整的健壮性视角:设计一个服务,要主动地为它的每一个生命周期阶段和状态切换都考虑健壮性——问自己"它启动时会怎样?停止时会怎样?崩了会怎样?升级时会怎样?",而不只是"它跑起来对不对";"全生命周期的健壮",才是面向生产、面向持续部署的服务该有的样子关注服务全生命周期(尤其启停等过渡时刻)的健壮性——是这个优雅停机坑,带给我的更高视角的认知。

第六件事:上线一个服务前,我现在的停机检查习惯

现在每当我要上线/部署一个服务,我都会按这张图把停机这块过一遍:

这张图的精髓,是"上线前确认捕获信号、优雅关闭、先摘流量、宽限期够、资源清理"依次确认捕获 SIGTERM、优雅关闭(停接新请求等存量带超时)、先摘流量再停机、宽限期 > 优雅关闭超时、在途任务和资源都清理,最后用一次滚动重启验证无 5xx这套习惯,让我从"只管服务能起来"变成了"上线前先确认它能体面地停下来"——核心始终是:长期运行的服务必须能优雅停机,上线前把捕获信号、摘流量、处理存量、清理资源、宽限期对齐都确认好。

我立下的几条规矩

这场"优雅停机缺失、发布丢单"的事故,换来了我做服务时,刻进骨子里的几条铁律:

  1. 长期运行的服务必须捕获 SIGTERM 做优雅停机。这是发布无损的刚需。
  2. 收到停机信号先摘流量,再停机。preStop sleep + readiness 失败,别边停边收。
  3. 优雅关闭:停接新请求、处理完存量、带超时。存量 hang 住也要超时放弃。
  4. 编排宽限期要大于优雅关闭超时。否则还没处理完就被 SIGKILL。
  5. 清理所有资源。关连接池、刷缓冲、提交事务、等在途异步任务做完。
  6. SIGKILL 不可捕获。别指望它,所有体面收尾都靠在 SIGTERM 时做。
  7. 为服务的全生命周期考虑健壮性。启动、停机、崩溃、变更都要平滑。

写在最后

回头看,这场由"没处理 SIGTERM"引发的、每次发布都丢单的事故,真正教给我的,远不止"服务要做优雅停机"这一个技巧。它让我对"一个东西的'结束',和它的'开始'与'运行'一样,需要被认真地设计",有了一次刻骨的体会。我栽跟头,根源在于我对"服务"的认知里,有一个巨大的盲区——"它该怎么结束"。我把全部心思都放在"怎么让它启动、怎么让它正确地运行"上,潜意识里以为"停止"就是"啪一下关掉"那么简单,不需要任何设计。可这次让我明白:对一个"正在做着事(处理着请求、持有着连接、占着事务)"的服务来说,"结束"绝不是一个瞬间的、无代价的动作——它是一个需要"收尾"的过程:手头的活要干完、占的资源要还回去、该通知的要通知到;"怎么体面地结束",和"怎么正确地开始和运行"一样,是一件需要专门设计、不能想当然的事这让我领悟到一个更普适的认知:在编程和系统设计里,"清理/收尾/终止"的逻辑,是和"初始化/执行"同等重要、却被严重低估的一半——对象有构造也要有析构、资源有申请也要有释放、连接有建立也要有关闭、事务有开始也要有提交/回滚、进程有启动也要有优雅停机;我们总是热衷于设计"怎么把事情做起来",却常常忽视"怎么把事情干净利落地收场"——而很多最隐蔽、最致命的 bug(资源泄漏、数据不一致、发布抖动),恰恰出在这个被忽视的"收尾"环节这给了我一种"有始有终"的设计自觉:设计任何"有生命周期"的东西时,都要给它的"结束"以和"开始"同等的重视——每写一个"申请/打开/启动/开始",就立刻想它对应的"释放/关闭/停止/结束"该怎么做、做得干不干净;"善始善终",不只是一种态度,更是健壮系统的一个硬性要求重视"结束/收尾"的设计、给生命周期的终点以应有的重视——这,是我用一次优雅停机缺失的事故,换来的、关于 DevOps、也关于如何设计一切有生命周期的系统的、最朴素也最深刻的领悟。如果这篇复盘,能让你下次写一个长期运行的服务时,在写完启动逻辑后,顺手补上那段捕获 SIGTERM、优雅关闭的代码,那我们那些在发布时丢掉的订单,就还算买了个教训。

—— 别看了 · 2026
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理 邮箱1846861578@qq.com。
技术教程

一个以为发一次就收一次的 TCP 通信程序,在高频发送时把多条消息粘成了一坨、又把一条拆成了两半:一次 TCP 粘包拆包的深度复盘

2026-6-2 15:41:59

技术教程

一个直接把大模型返回当 JSON 来解析的接口,在线上偶发地解析崩溃——因为模型有时会贴心地多说几句话:一次 LLM 结构化输出的深度复盘

2026-6-2 15:52:23

0 条回复 A文章作者 M管理员
    暂无讨论,说说你的看法吧
个人中心
购物车
优惠劵
今日签到
有新私信 私信列表
搜索