图 1:本文封面,SYN 发出去没回来,状态停在半空。
先说明属性:这是一篇历史笔试题复盘,题目出自本地历史笔试资料,与任何公司的当前招聘无关。题面大意:在一台机器上执行 netstat 查看 TCP 连接,发现大量连接处于 SYN_SENT 状态,这说明什么问题?要答好它,不能只背一个状态名,得先回到三次握手的状态机。
先把建立期状态在状态机里的位置摆清楚。
按 RFC 9293 的 TCP 状态机,主动发起方调用 connect 后发出 SYN,自己进入 SYN_SENT;对端(服务端)收到 SYN、回了 SYN+ACK,自己进入 SYN_RECV;发起方收到 SYN+ACK 再回 ACK,双方先后进入 ESTABLISHED,握手完成。SYN_SENT 的含义因此非常具体:SYN 已经发出去了,但 SYN+ACK 还没回来。netstat 手册对它状态的描述就是「正在主动尝试建立连接」。偶发几条 SYN_SENT 是正常的——每个连接都要经过这个状态;大量、持续、指向同一个目标的 SYN_SENT 才异常,它说明「发出去的 SYN 没有回音」这件事在成片发生。
SYN_SENT 成片堆积,往三个方向查。
第一个方向是对端:目标机器宕机,或者端口根本没在监听但中间设备把 RST 也吞了,SYN 石沉大海。第二个方向是中间链路:防火墙或安全组策略把 SYN 静默丢包(DROP 而不是 REJECT),发起方只能等重传和超时。第三个方向是本端:瞬时并发建连太高,或者路由、网卡出了问题。区分的办法很朴素:换一台机器连同一个目标,如果只有这台机器堆 SYN_SENT,问题在本端或它到对端的路上;如果多台机器连这个目标都堆,问题在对端。
顺手把命令也记住:netstat -tan(t 只看 TCP、a 含监听、n 不解析主机名,不然几千条连接逐个反解 DNS 会卡得你看不动),再配合 awk 按状态聚合计数。netstat 手册自己也注明了这个工具已基本废弃,新系统上用 ss——ss -tan state syn-sent 可以直接按状态过滤,速度快得多。
三种典型分布,用模拟跑一遍。
下面这段代码模拟客户端内核里建立期状态的演化:对端正常、对端不可达、应用不消费三种场景各跑九轮,观察状态分布随时间的变化,用 Node.js 22.15.0 直接运行(node tcpstates.js):
'use strict';
// 模拟一台客户端向三类对端发起 TCP 连接的内核状态变化,
// 观察 netstat/ss 里建立期状态的分布各指向什么故障。
// 三次握手:本端发 SYN 进 SYN_SENT -> 收到 SYN+ACK 回 ACK 进 ESTABLISHED;
// 服务端收到 SYN 进 SYN_RECV -> 收到 ACK 进 ESTABLISHED。(RFC 9293 状态机)
function simulate(name, peers, rounds) {
// conn: { peer, state, retries }
let conns = [];
const timeline = [];
for (let t = 0; t < rounds; t++) {
// 每一轮发起一批新连接
for (const p of peers) conns.push({ peer: p, state: 'SYN_SENT', retries: 0 });
const next = [];
for (const c of conns) {
if (c.state === 'SYN_SENT') {
if (c.peer.behavior === 'normal') { c.state = 'ESTABLISHED'; /* 建立后正常收发,随即关闭回收 */ }
else if (c.peer.behavior === 'unreachable') { // SYN 石沉大海:重传,最终超时放弃
c.retries++;
if (c.retries >= 6) { /* 连接失败被回收 */ } else next.push(c);
} else if (c.peer.behavior === 'slow-app') { c.state = 'ESTABLISHED'; next.push(c); } // 能建立但应用不消费
} else if (c.state === 'ESTABLISHED') {
if (c.peer.behavior === 'normal') { /* 正常收发完关闭 */ } // 快速回收
else next.push(c); // slow-app:应用不读不写不关,连接只进不出
}
}
conns = next;
const dist = {};
for (const c of conns) dist[c.state] = (dist[c.state] || 0) + 1;
if (t % 3 === 2 || t === rounds - 1) timeline.push(`t=${t + 1} ${JSON.stringify(dist)}`);
}
console.log(`【${name}】`);
for (const line of timeline) console.log(` ${line}`);
}
// 场景一:对端正常,连接随建随用随关,状态分布始终接近空
simulate('对端正常', [{ behavior: 'normal' }], 9);
// 场景二:对端宕机/端口不通/被防火墙丢包——SYN 发出去没有回应
simulate('对端不可达(SYN_SENT 堆积)', [{ behavior: 'unreachable' }], 9);
// 场景三:握手能完成,但应用不消费——ESTABLISHED 只涨不落
simulate('应用不消费(ESTABLISHED 堆积)', [{ behavior: 'slow-app' }], 9);
// 服务端视角补充:SYN_RECV 是服务端状态
console.log('【服务端视角】收到 SYN 回 SYN+ACK 后进入 SYN_RECV,等 ACK;');
console.log(' ACK 迟迟不来则 SYN_RECV 堆积——指向 SYN flood 或半连接队列打满。');
实测输出:
【对端正常】
t=3 {}
t=6 {}
t=9 {}
【对端不可达(SYN_SENT 堆积)】
t=3 {"SYN_SENT":3}
t=6 {"SYN_SENT":5}
t=9 {"SYN_SENT":5}
【应用不消费(ESTABLISHED 堆积)】
t=3 {"ESTABLISHED":3}
t=6 {"ESTABLISHED":6}
t=9 {"ESTABLISHED":9}
【服务端视角】收到 SYN 回 SYN+ACK 后进入 SYN_RECV,等 ACK;
ACK 迟迟不来则 SYN_RECV 堆积——指向 SYN flood 或半连接队列打满。
三组对照读出三个定性结论:对端正常时建立期状态转瞬即逝,分布基本为空;SYN 收不到回应时,SYN_SENT 先涨后稳在「重传上限」附近——堆积、但有顶;而 ESTABLISHED 只涨不落是另一种病:握手全部成功,应用却不读不写不关闭,指向连接池泄漏或消费逻辑卡死。同样叫「连接很多」,一个是建不上,一个是建了不用,处置完全不同。
图 2:状态分布翻译成故障方向,这就是读 netstat 的核心动作。
可能的追问。
「服务端看到 SYN_RECV 堆积是什么?」SYN_RECV 是服务端状态:收到 SYN、回了 SYN+ACK,在等最后的 ACK。成片 SYN_RECV 说明 ACK 回不来——要么是伪造源地址的 SYN flood 攻击(ACK 永远不会来),要么是半连接队列被打满、正常连接也进不来。「SYN_SENT 会永远挂着吗?」不会,内核会按指数退避重传 SYN,重试若干次后宣告失败回收,所以 SYN_SENT 的堆积量有上限——这也解释了模拟里它先涨后稳。「和 TIME_WAIT 有什么区别?」TIME_WAIT、CLOSE_WAIT 是挥手之后的状态,那是另一个话题;本文只覆盖建立期。一句话记住:netstat 的价值不在背状态名,而在把「状态分布」翻译成「包死在哪一段」。
模型 API 调用大面积超时的排障现场,第一步定性往往就靠它:SYN_SENT 堆积指向对端不可达,ESTABLISHED 堆积指向应用不消费。你在排查连接问题时,有没有被某一类状态堆积误导过的经历?当时是怎么绕出来的?欢迎交流。