news 2026/10/27 2:14:41

TCP关闭语义与优雅停机:为何发布仍丢请求

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
TCP关闭语义与优雅停机:为何发布仍丢请求

做过线上运维的开发者大概率都遇到过这样一类故障:某个服务发布之后,监控上一切正常,但客户端那边不断报“连接被重置”“502”“消息发出去没回音”。反复排查代码,逻辑没有改动,最后发现问题出在“谁先开口说再见”这件事上——服务端在还握着连接的时候被直接杀掉,客户端却把这当成一次普通的网络抖动,继续往一条已经死掉的通道上发数据。

“再见”这个词在工程里其实有两副面孔。一副是约定:我们保持心跳,你确认我还活着,下次还连;另一副是告别:这段连接到此为止,之后不再有任何数据。麻烦在于,这两种“再见”在网络字节层面长得几乎一样,都是发一个结束标记,然后停手。真正决定它是约定还是告别的,不是那一帧数据本身,而是双方事先有没有对“说完再见之后该怎么办”达成一致。

这篇文章想讨论的就是这个被很多人忽略的中间地带。它不聊 TCP 协议的教科书定义,而是落在一个很实际的问题上:为什么你的服务“优雅停机”配置了却依然丢请求,为什么你的 WebSocket 客户端在断网恢复后不再自动重连,为什么服务端CLOSE_WAIT堆积到几千个连接把句柄耗尽。读完你应该能判断自己的系统里哪些“再见”属于约定、哪些属于告别,并且能把该说清的那部分补上。

下面的内容会从 TCP 的四次挥手讲到应用层的关闭码,从 HTTP/1.1 的 Keep-Alive 讲到 Kubernetes 的 preStop,中间给出可以复制运行的验证代码和排查命令。所有示例都基于通用做法,具体版本和参数请以你自己的项目为准。

1. 把“再见”翻译成工程语言

先做一个概念翻译。在分布式系统里,一条连接的生命周期只有三个阶段:建立、维持、终止。人们习惯性地把注意力放在“建立”上,因为它有明确的成功信号——握手完成,连接可用;也习惯性地关注“维持”,因为它对应性能、吞吐、长连接复用这些能量化的指标。唯独“终止”被当成一个自动发生的、不需要设计的环节。

这种忽略会带来一个认知偏差:以为连接关闭是原子操作,要么活着要么死掉。实际不是。连接关闭是一个过程,中间存在大量中间态,比如半关闭、半开连接、TIME_WAIT、CLOSE_WAIT。这些中间态里,双方的认知是不一致的。一方认为“我们已经说再见了”,另一方认为“我们还在约定有效期内”。所有由关闭引发的故障,本质都是这种认知不一致。

所以“再见是约定还是告别”这个问题的工程版本是:在连接终止的每一个阶段,双方对当前状态的判断是否同步。如果同步,那这就是一次干净的告别;如果不同步,那它就是一个迟早要出问题的约定。

判断自己系统需不需要认真处理这件事,可以看三个信号:是否持有长连接(WebSocket、gRPC 流、数据库连接池、消息队列消费者);是否在滚动发布时出现过偶发的请求失败;是否在监控里看到过CLOSE_WAIT、TIME_WAIT数量异常。任意命中一个,后面几节的内容都会用得上。

还有一类读者不直接写服务端代码,只是用 HTTP 客户端调别人的接口。这一类同样需要关注,因为客户端库里大量的“自动重试”默认行为,正是把一次明确的告别误读成了暂时性的约定,从而制造出重复写入、重复扣款这类事故。

2. 核心概念:告别与约定的三种技术形态

在进入实操之前,需要先把三个容易混淆的东西分开:四次挥手、心跳保活、半开连接。它们在代码里经常被混为一谈,实际处理的是完全不同的问题。

2.1 四次挥手:告别的物理过程

TCP 关闭连接需要四次交互,原因很简单:TCP 是全双工的,两个方向的数据流相互独立。A 不再发数据,不代表 B 也不再发数据。所以关闭需要拆成两个方向分别进行。

主动关闭方发 FIN,表示“我这边没有数据要发了”;被动关闭方回 ACK,表示“收到了,但我可能还有数据要发”;等它数据发完,再发自己的 FIN;主动方回最后一个 ACK。主动方在发完最后一个 ACK 之后会进入 TIME_WAIT 状态,等待 2MSL 才真正释放。

这个过程的关键信息是:FIN 只表示“我不再发送”,不表示“我不再接收”。这就是 TCP 的半关闭(half-close)能力。很多应用层的 bug 就出在这里——收到 FIN 之后立刻把整个 socket 关掉,结果对方还在路上的数据全部被 RST 掉。

TIME_WAIT 存在两个目的:一是让最后一个 ACK 有机会重传,避免被动方因为没收到 ACK 而反复重发 FIN;二是让这条连接的旧报文在网络中自然消亡,不至于污染后面复用同一四元组的新连接。所以 TIME_WAIT 多不等于有问题,它更多是主动关闭方承担的代价。

真正需要警惕的是 CLOSE_WAIT。它表示“对方已经发了 FIN,我回了 ACK,但我这边的应用还没有调用 close”。如果 CLOSE_WAIT 数量持续增长,说明代码里有地方拿到了连接却没有正确释放,这是典型的资源泄漏,不是内核参数能调好的。

状态出现在哪一方含义常见成因
FIN_WAIT_1主动关闭方已发 FIN,等对方 ACK正常关闭流程
FIN_WAIT_2主动关闭方收到 ACK,等对方 FIN对方迟迟不关,可能应用没 close
TIME_WAIT主动关闭方等 2MSL 释放四元组大量短连接、服务端主动关闭
CLOSE_WAIT被动关闭方收到 FIN 但应用未 close连接泄漏、异常分支没释放
LAST_ACK被动关闭方已发 FIN,等最后 ACK正常关闭流程

2.2 心跳保活:约定需要持续付费

如果说四次挥手是告别,那心跳就是维持约定的成本。长连接在网络中间设备上并不受保护,NAT 网关、负载均衡、防火墙都会维护自己的会话表,表项有超时时间。超时一到,中间设备悄悄把表项删掉,两端却毫不知情,这条连接就废了。

心跳的作用不是“检测对方活着”,而是“让中间设备刷新表项”。这两个目的经常被混淆,导致心跳间隔设置得很随意。如果目的是刷新中间设备,那心跳间隔必须小于链路上最短的那个空闲超时;如果目的是检测对端存活,那间隔取决于你能容忍多长的故障发现延迟。

更关键的是,心跳必须能区分“没收到回复”和“连接已断”。前者可能是网络拥塞或对端 GC 停顿,后者需要重建连接。所以业界的通用做法是:心跳超时后不立刻判定连接死亡,而是连续失败 N 次再触发重连,避免网络抖动引发的连接风暴。

2.3 半开连接:最危险的中间态

半开连接指的是这样一条连接:一端认为它还活着,另一端其实已经不存在了。典型场景是客户端进程被强杀、机器断电、网线被拔。这些情况下没有 FIN 发出,对端只能靠超时或心跳来发现。

半开连接的危害在于它会伪装成健康状态。连接池会把它当成可用连接分配出去,请求发出去之后一直等到超时。如果一个连接池里有几条这样的僵尸连接,表现出来的症状就是“偶发超时,重试就好”,非常难定位。

对抗半开连接的手段有三个层次:应用层心跳(能发现)、TCP Keepalive(能发现但太慢,默认两小时)、连接池的健康检查(分配前验证)。生产环境推荐至少做到应用层心跳加连接池校验,纯靠 TCP Keepalive 基本等于没有保护。

3. 环境准备与观测工具链

本节说明验证所需的运行环境和工具。由于不同项目使用的技术栈差异很大,这里的版本只作参考,实际请以团队既有规范为准。

3.1 运行环境

  • 操作系统:Linux(Ubuntu 22.04 或 CentOS 7 以上均可),部分命令在 macOS 上需要调整参数。
  • Python:3.10 及以上,用于编写最小化的 TCP 复现脚本。
  • JDK:17 及以上,配合 Spring Boot 3.x 做优雅停机验证。
  • Node.js:18 及以上,用于运行 WebSocket 客户端示例。
  • 容器环境:如要验证 Kubernetes 部分,需要可用的集群和kubectl。

依赖安装方面,Python 脚本只用标准库,不需要额外安装;WebSocket 示例使用浏览器原生WebSocket,也不需要 npm 包;Spring Boot 项目通过 Maven 或 Gradle 引入spring-boot-starter-web和spring-boot-starter-actuator即可。

3.2 观测工具

排查连接状态,掌握三个命令基本够用:

# 查看所有 TIME_WAIT 连接数量 ss -tan state time-wait | wc -l # 查看 CLOSE_WAIT 明细,重点关注 Recv-Q 和进程信息 ss -tanp state close-wait # 抓取指定端口的握手与挥手报文 sudo tcpdump -i any -nn port 9527 -c 30

ss比netstat更快,在连接数上万的机器上差距明显。tcpdump用于确认“到底是谁先发的 FIN”,这是定位关闭责任方的关键证据。

如果需要更细的协议层分析,可以用 Wireshark 打开tcpdump -w保存的 pcap 文件,它会把四次挥手的过程按序号标出来,比肉眼看报文直观得多。但注意生产环境抓包要控制时长和数量,-c参数限制包数是必要的,避免磁盘被写满。

4. TCP 层实操:谁先开口说再见

理论说完,用一个最小脚本把挥手过程跑出来,比看十遍定义都有用。这个例子的目的是让你亲眼看到:服务端调用 close 之后,客户端并不会立刻感知,两者之间存在时间窗口。

4.1 最小复现代码

服务端脚本放在tcp_farewell_server.py:

# 文件路径:tcp_farewell_server.py import socket import time HOST = "127.0.0.1" PORT = 9527 with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as srv: srv.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1) srv.bind((HOST, PORT)) srv.listen(1) print(f"[server] listening on {HOST}:{PORT}") conn, addr = srv.accept() with conn: print(f"[server] accepted from {addr}") data = conn.recv(1024) print(f"[server] recv: {data!r}") # 模拟业务处理耗时,制造关闭窗口 time.sleep(2) conn.sendall(b"bye") print("[server] send bye, about to close") # 退出 with 块触发 close,服务端成为主动关闭方 print("[server] connection closed, entering close wait chain") time.sleep(60)

客户端脚本放在tcp_farewell_client.py:

# 文件路径:tcp_farewell_client.py import socket import time with socket.create_connection(("127.0.0.1", 9527), timeout=5) as cli: cli.sendall(b"hello") print("[client] sent hello") # 半关闭:只关写方向,读方向仍然可用 cli.shutdown(socket.SHUT_WR) print("[client] shutdown write side") while True: chunk = cli.recv(1024) if not chunk: print("[client] peer closed, recv returned empty") break print(f"[client] recv: {chunk!r}") time.sleep(30)

这里故意让客户端调用shutdown(SHUT_WR),它做的是半关闭,只发出 FIN 表示不再发送,但仍然可以接收。服务端随后调用sendall依然能成功,这就是半关闭的意义。如果客户端改成直接cli.close(),服务端的sendall就可能触发 RST,表现为连接被重置。

运行方式:两个终端分别执行python3 tcp_farewell_server.py和python3 tcp_farewell_client.py。预期看到客户端打印出recv: b'bye',然后recv returned empty。

4.2 用 ss 和 tcpdump 验证

在脚本运行期间,另一个终端执行观察命令。你会看到这样一组典型输出:

$ ss -tan state close-wait State Recv-Q Send-Q Local Address:Port Peer Address:Port CLOSE-WAIT 1 0 127.0.0.1:9527 127.0.0.1:49872

这一条记录说明服务端已经收到了客户端的 FIN 并回了 ACK,但应用还在time.sleep(30)里没有调用 close。此时如果服务端进程被强杀,这条连接就会以 RST 结束,客户端看到的是异常断开而不是正常关闭。

抓包能更直接地看出顺序:

$ sudo tcpdump -i lo -nn port 9527 -c 20 12:00:01.100 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [S], seq 1000 12:00:01.100 IP 127.0.0.1.9527 > 127.0.0.1.49872: Flags [S.], seq 2000, ack 1001 12:00:01.100 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [.], ack 1 12:00:01.101 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [P.], length 5 12:00:01.101 IP 127.0.0.1.9527 > 127.0.0.1.49872: Flags [.], ack 6 12:00:01.102 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [F.], seq 6 12:00:01.102 IP 127.0.0.1.9527 > 127.0.0.1.49872: Flags [.], ack 7 12:00:03.103 IP 127.0.0.1.9527 > 127.0.0.1.49872: Flags [P.], length 3 12:00:03.103 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [.], ack 4 12:00:03.104 IP 127.0.0.1.9527 > 127.0.0.1.49872: Flags [F.], seq 4 12:00:03.104 IP 127.0.0.1.49872 > 127.0.0.1.9527: Flags [.], ack 5

重点看两处:客户端在12:00:01.102发出 FIN,服务端在12:00:03.104才发出自己的 FIN,中间隔了将近两秒。这段间隔就是业务代码的 sleep。在这两秒里,服务端还在正常发送数据,说明半关闭状态是合法的、可用的。

这个实验最想传递的一个判断是:主动关闭方承担 TIME_WAIT,被动关闭方承担 CLOSE_WAIT 的风险。如果服务端是主动关闭方,那它会在大量短连接场景下堆积 TIME_WAIT;如果客户端先关,服务端就要及时释放,否则 CLOSE_WAIT 会一直涨。很多团队把CLOSE_WAIT多归咎于网络问题,实际上百分之九十以上是代码里异常分支没有正确释放连接。

5. 应用层协议:HTTP Keep-Alive 与 WebSocket 的关闭语义

TCP 层只负责传输,真正决定“这是约定还是告别”的是应用层协议。同样是关闭,HTTP 的Connection: close和 WebSocket 的 close frame 语义完全不同。

5.1 HTTP/1.1 的长连接约定

HTTP/1.1 默认开启持久连接,响应头不写Connection: close就意味着这条连接还能复用。这本身就是一种“约定”:我不关,你下次可以继续发。问题在于这个约定的有效期并不明确,服务端通常会配置一个空闲超时(例如 Nginx 的keepalive_timeout),超过之后由服务端主动关闭。

这就制造了一个经典的竞态窗口:客户端从连接池里取出一条连接准备发送请求,恰好服务端在这一刻因为空闲超时把它关掉了,客户端不知道,请求发出去石沉大海。这类问题在压测和低流量时段最容易暴露,因为低流量更容易让连接闲置到超时边界。

应对方式有两层。客户端侧,连接池要有“取用前校验”机制,比如发请求前先检查 socket 是否可读(可读往往意味着收到了 FIN)。服务端侧,keepalive_timeout应该设置得比客户端的空闲回收时间更长,让客户端先关,服务端只做被动关闭,从而避免服务端堆积 TIME_WAIT。

Nginx 的典型配置可以这样写:

upstream demo_api { server 127.0.0.1:8080 max_fails=3 fail_timeout=10s; keepalive 32; } server { listen 80; server_name demo.local; location / { proxy_http_version 1.1; # 清空 Connection 头,才能与上游保持长连接 proxy_set_header Connection ""; proxy_set_header Host $host; proxy_connect_timeout 3s; proxy_send_timeout 10s; proxy_read_timeout 60s; proxy_next_upstream error timeout http_502 http_503; proxy_pass http://demo_api; } }

proxy_set_header Connection ""这一行经常被漏掉。HTTP/1.1 的默认行为是长连接,但 Nginx 为了防止头信息错误传递,默认会发Connection: close给上游,导致每条请求都新建连接。清空这个头之后,keepalive 32才有意义。

5.2 WebSocket 的关闭码:约定里的显式告别

WebSocket 在设计上比 HTTP 更明确地区分了关闭原因。它的关闭帧带一个状态码,用数字表达“为什么告别”。

关闭码含义应否重连
1000正常关闭否,除非业务需要
1001端点离开,如页面关闭、服务重启是,通常是临时状态
1002协议错误否,重连也会出错
1006异常关闭,未收到关闭帧是,但要限制频率
1011服务端内部错误是,配合退避
1012服务重启是
1013服务过载,稍后重试是,必须退避

这张表最有价值的判断是:1000 是明确的告别,不应该重连;1006 是意外的失联,必须重连但不能无限重连。很多客户端代码把所有onclose一视同仁地重连,结果在服务端主动下线维护时,客户端形成重连风暴,把刚启动的服务又打挂了。

5.3 带退避与抖动的重连实现

下面这段代码演示一个相对完整的重连策略,包含指数退避、随机抖动、正常关闭不重连三个要点。文件放在static/ws-client.js:

// 文件路径:static/ws-client.js class ReconnectingSocket { constructor(url, options = {}) { this.url = url; this.baseDelay = options.baseDelay ?? 1000; this.maxDelay = options.maxDelay ?? 30000; this.maxAttempts = options.maxAttempts ?? Infinity; this.attempts = 0; this.manualClose = false; this.ws = null; this.connect(); } connect() { this.ws = new WebSocket(this.url); this.ws.onopen = () => { this.attempts = 0; console.log("[ws] connected"); }; this.ws.onmessage = (event) => { console.log("[ws] message:", event.data); }; this.ws.onerror = () => { console.log("[ws] error occurred"); }; this.ws.onclose = (event) => { console.log("[ws] closed, code =", event.code, "reason =", event.reason); // 1000 是显式告别,1001 可能是服务端重启,需要区别对待 if (this.manualClose || event.code === 1000) { console.log("[ws] normal shutdown, stop reconnecting"); return; } this.scheduleReconnect(); }; } scheduleReconnect() { if (this.attempts >= this.maxAttempts) { console.log("[ws] max attempts reached, give up"); return; } const delay = Math.min(this.baseDelay * 2 ** this.attempts, this.maxDelay); const jitter = Math.floor(Math.random() * 1000); this.attempts += 1; console.log(`[ws] reconnect in ${delay + jitter}ms, attempt ${this.attempts}`); setTimeout(() => this.connect(), delay + jitter); } close() { this.manualClose = true; if (this.ws) { this.ws.close(1000, "client shutdown"); } } } // 使用示例 const socket = new ReconnectingSocket("ws://127.0.0.1:8080/ws", { baseDelay: 1000, maxDelay: 30000, maxAttempts: 10 }); // 页面卸载时主动告别,避免触发重连 window.addEventListener("beforeunload", () => socket.close());

关键设计有三点。manualClose标志用于区分“我主动关的”和“被动断的”,这是很多实现漏掉的地方。指数退避配合maxDelay上限,避免重连间隔无限增长导致长时间无法恢复。随机抖动是为了让大量客户端不在同一毫秒同时发起重连,这个细节在集群规模上到几百之后会变得非常重要。

beforeunload里调用close(1000)也有讲究。如果不显式关闭,浏览器在页面卸载时会直接丢弃连接,服务端收到的是 1006 或者干脆只看到 TCP 断开,日志里会多出一批“异常断开”记录,干扰真实故障的判断。

6. 服务端优雅停机:把“告别”提前变成“约定”

客户端处理完,回到服务端。服务端最典型的“被迫告别”场景就是发布和缩容。进程收到终止信号后如果直接退出,所有在途请求都会失败。优雅停机的目标是把这件事变成一次提前通知的约定:我先告诉所有人“我要走了”,等大家把手里的事做完,再关连接。

6.1 Spring Boot 的优雅停机配置

Spring Boot 2.3 之后内置了优雅停机支持,配置方式如下,放在src/main/resources/application.yml:

server: port: 8080 shutdown: graceful spring: lifecycle: timeout-per-shutdown-phase: 30s management: endpoints: web: exposure: include: health,info,metrics endpoint: health: probes: enabled: true

server.shutdown: graceful表示收到终止信号后不再接受新请求,已接受的请求继续处理。timeout-per-shutdown-phase是等待上限,超过之后强制中断,这个值要结合业务最长耗时来定,不能照抄。

对应的验证接口可以写成一个故意慢的接口,文件路径src/main/java/com/example/demo/GracefulDemoController.java:

// 文件路径:src/main/java/com/example/demo/GracefulDemoController.java package com.example.demo; import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RestController; @RestController public class GracefulDemoController { @GetMapping("/slow") public String slow(@RequestParam(defaultValue = "3000") long sleepMs) throws InterruptedException { long start = System.currentTimeMillis(); Thread.sleep(sleepMs); long cost = System.currentTimeMillis() - start; return "done, sleepMs=" + sleepMs + ", cost=" + cost; } @GetMapping("/fast") public String fast() { return "ok"; } }

验证步骤是:先发一个耗时 8 秒的请求,在它还没返回的时候,对进程发送SIGTERM。观察这个请求能否正常返回完整结果。

# 终端一:发起慢请求 curl -s "http://127.0.0.1:8080/slow?sleepMs=8000" # 终端二:请求发出后约 1 秒,发送终止信号 kill -TERM <pid>

如果配置生效,curl 会正常拿到done, sleepMs=8000, cost=8000...,而新发起的请求会被拒绝。如果没配置,curl 会直接返回Connection reset或空响应。这个对比非常直观。

这里容易踩的一个坑是:SIGTERM和SIGKILL完全不同。kill -9发送的是SIGKILL,进程无法捕获,任何优雅停机机制都不会生效。容器编排里如果在 grace period 结束之后才强杀,效果等同于SIGKILL,所以超时时间要留足。

6.2 Kubernetes 场景下的 preStop 与就绪探针

即便 Spring Boot 配好了优雅停机,在 Kubernetes 里依然可能丢请求。原因在于“摘除流量”和“发送终止信号”这两个动作是并行的,甚至后者更快。kubelet 在终止 Pod 时,会同时做两件事:从 Service 的 Endpoints 里移除该 Pod,以及执行容器的停止流程。而 Endpoints 的移除需要时间传播到所有节点的 kube-proxy 或 Ingress 控制器。

在这段传播时间里,仍会有新请求被转发到这个正在关闭的 Pod。所以 industry 的通用做法是加一个preStop钩子,先 sleep 一小段时间,等流量摘干净再让主进程退出。

apiVersion: apps/v1 kind: Deployment metadata: name: demo-api spec: replicas: 2 selector: matchLabels: app: demo-api template: metadata: labels: app: demo-api spec: # 必须大于 preStop 时长与业务最长处理时间之和 terminationGracePeriodSeconds: 45 containers: - name: demo-api image: registry.example.com/demo-api:1.0.0 ports: - containerPort: 8080 lifecycle: preStop: exec: command: ["sh", "-c", "sleep 10"] readinessProbe: httpGet: path: /actuator/health/readiness port: 8080 initialDelaySeconds: 10 periodSeconds: 5 livenessProbe: httpGet: path: /actuator/health/liveness port: 8080 initialDelaySeconds: 20 periodSeconds: 10

三个参数之间存在依赖关系,必须满足terminationGracePeriodSeconds > preStop.sleep + 最长请求耗时。上例中 preStop 睡 10 秒,业务最长 30 秒,所以 45 秒是合理的。如果设置成 15 秒,那么在第 15 秒时容器会被强杀,正在处理的请求全部失败,优雅停机等于没配。

还有一点值得强调:就绪探针和存活探针必须指向不同的检查端点。Spring Boot 3.x 配合management.endpoint.health.probes.enabled=true会自动提供/actuator/health/readiness和/actuator/health/liveness。优雅停机期间,readiness 会自动变成 DOWN,进一步帮助流量摘除;而 liveness 保持不变,避免被误判为故障而重启。

7. 完整验证流程与结果判断

把上面几部分串起来,形成一套可以照着走的验证流程。这套流程的价值在于:每一步都有明确的成功判据,不是“看起来没问题”。

第一步,验证 TCP 层的关闭责任方。运行第 4 节的脚本,用tcpdump抓包,确认 FIN 由谁先发。如果服务端是主动关闭方,就要评估 TIME_WAIT 的承载能力,必要时调整net.ipv4.tcp_tw_reuse或改用长连接。

第二步,验证应用层的关闭语义。用浏览器开发者工具连上 WebSocket 服务,手动触发服务端关闭,观察 close code。如果是 1000,客户端应停止重连;如果是 1006,客户端应启动退避重连。这一步能发现大量客户端逻辑缺陷。

第三步,验证服务端优雅停机。执行curl /slow加kill -TERM的组合,看请求是否完整返回。同时观察日志里是否出现“graceful shutdown”相关记录。如果失败,第一个要看的是信号是不是SIGKILL,第二个要看的是timeout-per-shutdown-phase是否小于业务耗时。

第四步,验证 K8s 场景。用kubectl delete pod触发删除,同时用脚本持续请求 Service 地址,统计失败率。预期失败率为零或极低。如果失败率明显,依次检查preStop是否存在、terminationGracePeriodSeconds是否足够、就绪探针是否正确指向 readiness 端点。

第五步,验证连接泄漏。发布前后各采样一次ss -tanp state close-wait,对比数量。如果发布后显著上升且持续不降,说明代码里有连接没有释放。这时候不要盲目调优内核参数,直接去查代码里的异常分支。

判断成功的标准可以归纳成三条:在途请求零失败、关闭码符合预期、CLOSE_WAIT 数量稳定。三条都满足,才说明“再见”这件事在系统里被正确处理了。

8. 常见问题与排查思路

下面这张表覆盖了实际工作中最高频的几类问题。排查顺序建议从上往下,因为前面几项是后面几项的前提。

问题现象可能原因排查方式解决方案
发布期间偶发 502 或连接重置preStop 缺失或时间不足,流量未摘净就退出查看 Deployment 是否配 preStop,对比发布窗口的错误日志增加 preStop sleep,确保terminationGracePeriodSeconds足够
慢请求在停机时被中断未开启优雅停机,或超时时间小于业务耗时检查server.shutdown配置和timeout-per-shutdown-phase开启 graceful,超时设为业务 P99 的 1.5 倍以上
CLOSE_WAIT 持续增长不下降应用收到 FIN 后未调用 close,常见于异常分支ss -tanp state close-wait看进程,结合代码审查用 try-with-resources 或 finally 块确保释放
TIME_WAIT 数量极多短连接频繁,服务端主动关闭`ss -tan state time-waitwc -l`
WebSocket 断线后不再重连onclose未处理,或把所有关闭码都当正常关闭浏览器控制台看 close code按关闭码区分,1006 触发退避重连
重连风暴打挂新启动的服务无退避、无抖动、无重试上限观察新服务的连接建立速率指数退避加随机抖动,设置最大重试次数
请求发出后长时间无响应连接池中存在半开连接抓包看是否有 FIN 未被感知增加心跳与连接池取用前校验
Docker 环境下kill -TERM无效进程 PID 不是 1,信号未转发查看容器内进程树使用 exec 形式启动,或加 init 进程
优雅停机日志出现但请求仍失败注册中心未及时注销检查服务注册与发现的健康检查周期停机时先主动注销,再等待一个检查周期
HTTP 客户端重复写入自动重试把非幂等请求重发查看客户端重试配置只对幂等方法开启重试,或加幂等键

这张表里有两条值得单独展开。第一条是“Docker 环境下信号不转发”,这是非常经典的坑:如果用 shell 形式启动CMD java -jar app.jar,那么 PID 1 是 shell,SIGTERM发给 shell 之后未必转发给 Java 进程,结果就是优雅停机配置完全没生效,容器只能等超时被强杀。正确写法是用 exec 形式CMD ["java", "-jar", "app.jar"]。

第二条是“自动重试导致重复写入”。很多 HTTP 客户端库默认对连接失败做重试,而连接失败恰恰是最难判断请求是否已经到达服务端的场景。如果接口不是幂等的(比如下单、扣款),重试就可能造成重复。稳妥做法是不在传输层做盲目重试,而是在业务层引入幂等键,让重复请求在服务端被识别并丢弃。

9. 最佳实践与工程收尾

把前面所有内容收敛成几条可以落地的原则。

第一条,明确谁先关闭。短连接场景下尽量让客户端先关,服务端做被动关闭,这样 TIME_WAIT 分散在客户端侧,不容易压垮单台服务器。长连接场景下由服务端在维护窗口主动关闭,但必须提前通知,让客户端有准备。

第二条,关闭必须带原因。无论是 WebSocket 的关闭码,还是 HTTP 的响应头,尽量把“为什么关”传递出去。1000 和 1006 的区别,决定了客户端是该安静退出还是该立刻重连。这一点在客户端数量多的时候价值极大,能省掉大量“为什么用户端反复重连”的排查时间。

第三条,重连必须有边界。指数退避、随机抖动、最大次数,三个要素缺一不可。没有退避会造成压力放大,没有抖动会造成同时重连,没有上限会造成异常场景下无限循环。这三条的实现成本很低,但没有做的团队非常多。

第四条,停机时间必须可计算。terminationGracePeriodSeconds不是一个可以随手填的数字,它应该等于 preStop 时长加上业务最长处理时间,再留一定余量。建议把这个计算过程写进部署文档,避免后续有人随意调整。

第五条,监控要覆盖关闭过程。连接数、CLOSE_WAIT 数量、关闭码分布、优雅停机耗时,这四个指标应该进入监控面板。尤其是关闭码分布,它能直接告诉你客户端是在正常退出还是在异常断开,是判断连接质量最直接的信号。

最后回到标题那个问题。在系统里,“再见”究竟是约定还是告别,答案取决于你有没有把后续的处理规则写清楚。写了,它就是约定,双方都知道下一步该做什么;没写,它就是告别,而且往往是一次没人准备好的告别。TCP 的四次挥手保证了字节层面的干净收尾,但应用层要不要重连、要不要摘流量、要不要等待在途请求,这些都得靠设计来补。把这些补上,发布时那几条偶发的 502 和那些莫名其妙的“消息发出去没回音”,基本就会消失了。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/27 2:14:23

本地部署与接口批处理:从环境自检到显存排查

项目名“等你”第一次看到时确实有点特别&#xff0c;它不像常见的模型名或框架名那样自带技术标识&#xff0c;乍一看更像一句留白。但从项目定位来看&#xff0c;它对应的是一个需要在本地跑起来、能被外部调用、能够承接重复任务的技术项目。对于这类项目&#xff0c;读者最…

作者头像 李华
网站建设 2026/10/27 2:13:17

大数据面试题不是背答案:沿数据链路吃透Shuffle与数据倾斜

简介&#xff1a;面向大数据开发、运维、云计算、数据治理与架构岗位的求职者&#xff0c;这份2023年整理的面试题集围绕大数据技术栈常见考点与项目追问展开&#xff0c;适合用于系统复习、面试前查漏补缺和梳理知识框架。资源为1个docx文档&#xff0c;压缩包约491KB&#xf…

作者头像 李华
网站建设 2026/10/27 2:13:12

MATLAB K-means实战工程包:可调试、可复现、可部署的聚类闭环

简介&#xff1a;本资源是一份面向MATLAB初学者与数据挖掘入门者的K-means聚类算法实践指南&#xff0c;聚焦无监督学习核心方法&#xff0c;帮助读者快速掌握聚类原理、代码实现与结果可视化。文档以清晰逻辑展开&#xff1a;先阐释算法思想与四步迭代流程&#xff0c;再结合二…

作者头像 李华
网站建设 2026/10/27 2:12:20

PL/0词法分析与递归子程序语法分析避坑指南

简介&#xff1a;这份编译原理实验报告面向东北大学秦皇岛分校计算机与通信工程学院学生及初学编译原理的读者&#xff0c;围绕PL/0语言词法分析程序的设计与实现展开&#xff0c;可用于课程实验参考、C词法分析练习与实验报告撰写对照。压缩包共1个doc文件&#xff0c;约122KB…

作者头像 李华
网站建设 2026/10/27 2:11:53

联想EDU启动盘制作工具DDDTOOL:机房批量装机实战指南

简介&#xff1a;联想EDU启动盘制作工具DDDTOOL.zip面向教育行业IT管理员与机房维护人员&#xff0c;用于制作可引导的USB启动盘或光盘&#xff0c;以便部署、维护联想EDU保护程序并完成系统恢复与批量装机。压缩包共3个文件&#xff0c;以可执行程序为主&#xff0c;辅以INI配…

作者头像 李华