前阵子接手一个连接超时的排查,同事第一句话就是“我们是不是该调一下 TCP 参数”。我问他调哪个参数、依据什么、期望变成什么效果,他答不上来。这其实是我见过最常见的状态——手里存着一堆sysctl参数列表,但对 TCP 握手的过程缺乏可观测的认知,出了问题只能逐个试,试完也不知道到底有没有用。这篇文章想把这块补上。我会从 TCP 握手的实际时间线讲起,把抓包、队列、重传和调参串成一条完整的观测链路。重点是:与其问“该把哪个参数调大”,不如先学会“怎么看懂握手到底发生了什么”。内容偏实践,适合后端开发、运维、SRE 以及刚接触网络基础设施的读者。1. 握手不是瞬间完成的:从状态机看三次交互的真相1.1 三次交互与两端各自的状态变化教科书上把 TCP 握手画成三个箭头:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK,连接建立。这个图没错,但它给人一种“三次交互瞬间完成”的错觉。实际上每一次交互之间,客户端和服务端的内核状态都在变化,而且两端不是同时进入“可收发数据”的状态。把完整的过程拆开看:客户端调用connect(),内核发出 SYN 包,客户端进入SYN_SENT状态。服务端收到 SYN 后,在监听 socket 对应的半连接队列里创建一个请求记录(request_sock),状态变为SYN_RCVD,同时回应 SYN+ACK。客户端收到 SYN+ACK,校验序列号无误,内核将 socket 置为ESTABLISHED,然后发出 ACK。服务端收到这个 ACK,才从半连接队列中取出请求记录,构建完整的 socket 结构,放入全连接队列,此时服务端才进入ESTABLISHED。应用进程调用accept(),从全连接队列里取走这个连接。有两个容易忽略的点。第一,客户端在发完第三步 ACK 之后就已经觉得自己“连上了”,但这个 ACK 还在路上,服务端要等收到之后才算完成握手。如果这个 ACK 因为网络延迟或者服务端负载原因没有及时处理,客户端可能已经开始发送业务数据了,服务端这边未必准备好了。第二,accept()不是“创建连接”,而是“取走连接”。连接在第三步 ACK 到达时就已经在内核的全连接队列里了,应用程序什么时候来取,取决于业务代码的执行速度。这也解释了为什么“握手完成”和“应用真正开始处理请求”之间还有一段不可忽略的时间窗口。1.2 握手过程中携带的“隐形契约”很多人只关注握手的标志位,忽略了 SYN 和 SYN+ACK 包里还带着一组 TCP 选项。这些选项虽然不决定“能不能连上”,但决定了“连上之后跑得快不快”。常用的有这么几个:MSS:告诉对端自己能接受的最大报文段长度。通常由 MTU 计算而来,1500 字节的 MTU 去掉 IP 头和 TCP 头,默认是 1460。两端协商时取较小值。SACK:选择性确认。开启后,丢包时可以只重传丢失的段,而不是把窗口内所有数据都重传一遍。Timestamps:用于计算 RTT 和保护序列号不会绕回,同时它也是后面讲到的tcp_tw_reuse生效的前提。Window Scale:把 TCP 窗口从 16 位扩展到 30 位,支持更大的拥塞窗口,是高带宽长肥管道的必需品。这些选项的协商不是“哪边强就按哪边来”,而是取交集或取较小值。如果中间链路有网络设备强行剥掉某些选项,两边就只能退回到基础版本。这也是为什么有些连接能建、但吞吐始终上不去——不是带宽问题,而是握手时就把性能选项丢了。1.3 服务端“握手完成”与“accept 完成”之间的空档从状态机可以看出,一个连接从客户端视角是“三步握手结束即可用”,但从服务端视角要经历“收到 ACK 入队 → 等待 accept → 业务处理”三个阶段。其中“入队后等待 accept”这段时间在全连接队列里是不可见的黑盒。如果全连接队列满了,内核的处理方式会直接影响客户端体验,这个内容我在第 3 节展开。这里先记住一个结论:客户端 connect 成功不等于服务端已经能够接收数据,中间还隔着一个队列水位。所以,做网络调优第一步不是动参数,而是建立“握手是一个多阶段事件”的认知。只有把三个阶段都看清了,才能知道问题到底出在哪个环节。2. 观测工具三件套:tcpdump、ss 与计数器2.1 tcpdump 抓包:用最原始的方式看握手抓包是观测握手最直接的手段。它不看任何内核统计,就是记录网络上真实发生的包。一条最简单的命令:tcpdump -nn -i any tcp port 8080拆解一下参数:-nn不做域名和端口反查,输出里直接显示 IP 和端口,避免因为反查慢丢包;-i any抓所有网卡;tcp port 8080只过滤目标或源端口为 8080 的 TCP 包。正常握手的输出长这样:IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.6.8080 > 10.0.0.5.52340: Flags [S.], seq 876543210, ack 4242424243, win 65160 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [.], ack 876543211, win 64240三个包对应的标志位依次是[S](SYN)、[S.](SYN+ACK)、[.](纯 ACK)。这里有个小细节:第一个包的 seq 是 4242424242,第二个包的 ack 是 4242424243,也就是第一个包的 seq 加 1。这个“加一”表示 SYN 消耗了一个序列号。第三个包的 ack 同样是第二个包 seq 加 1。如果抓包时看到 ack 值不符合这个规律,说明中间有包被丢弃或者被设备篡改。想看到 TCP 选项,加-v:tcpdump -nn -i any tcp port 8080 -v输出里会多出mss 1460,sackOK,TS val ...这类内容。这里的sackOK就是双方协商允许 SACK,TS是时间戳选项,wscale表示窗口缩放系数。如果-v之后发现某些选项缺失,那就要考虑是不是中间设备做了手脚。抓包过程中最常见的异常是同一个 SYN 包重复出现:IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240三次 SYN 包的 seq 完全一样,说明客户端在重传 SYN。至于为什么重传,得继续往下查。2.2 ss 的视角:队列积压比状态突变更值得关注ss是当前 Linux 上查看 socket 状态的首选工具,比netstat快,信息也更全。观测握手时,最关键是这一条:ss -lnt-l只看监听 socket,-n不解析名称,-t只看 TCP。输出类似:State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*注意这里的Recv-Q和Send-Q含义跟在已建立连接上完全不一样。对监听中的 socket 来说:Recv-Q表示当前已完成握手、等待应用调用 accept() 的连接数量,也就是全连接队列里积压了多少。Send-Q表示全连接队列的上限,也就是实际生效的 backlog。很多人把这两个字段理解成“收了多少数据、发了多少数据”,这是监听场景下最常见的误解。在已建立的连接里,Recv-Q是接收缓冲区待读取的数据量,Send-Q是发送缓冲区的排队数据量,二者完全不同。把两套含义分开记,排查时就不会被输出带偏。Send-Q里的 128 是从哪来的?如果应用调用listen(fd, backlog)时传入了 512,net.core.somaxconn只有 128,内核取二者的较小值,最终队列上限就是 128。想确认这个值,直接看ss就行,不用翻代码。2.3 系统计数器:netstat -s 与 nstat 里的隐藏信号抓包能看单条连接,但看不出整体趋势。要看“系统层面丢了多少握手包”,靠的是内核计数器。下面两条命令各有用处:netstat -s | grep -i listen nstat -az | grep -E "ListenOverflows|ListenDrops"netstat -s输出里的SYNs to LISTEN sockets dropped统计的是被丢弃的 SYN 包数量。这个计数只要在增长,就说明有握手请求在服务端入队之前就被放弃了。nstat读的是内核网络统计模块的数据,ListenOverflows表示全连接队列溢出的累计次数,ListenDrops表示半连接队列或者监听队列丢弃请求的次数。注意一点,nstat -az里的-z会把为 0 的项也打印出来,第一次跑的时候全部是 0 属于正常现象,关键是看后续是否增长。这三个工具的分工可以这样概括:工具观测粒度核心作用tcpdump单条连接看清握手包的时序、重传、TCP 选项ss单端口队列看全连接队列当前水位和上限netstat -s / nstat系统全局看队列溢出和丢 SYN 的累计趋势实际排查时,我通常先用ss -lnt看有没有积压,再用netstat -s和nstat确认溢出计数,最后用tcpdump抓典型的几条连接看包时序。工具之间是配合关系,不是替代关系。3. backlog 的全貌:半连接队列与全连接队列的溢出逻辑3.1 两个队列的分工:谁排队,谁等待Linux 内核处理新连接时实际上有两个队列,很多人把它们混成一个“backlog”,这是排查时思路混乱的根源。第一个是半连接队列(syn queue),存放的是只收到 SYN、还没完成三次握手的请求记录。这个队列的大小不完全由net.ipv4.tcp_max_syn_backlog单独决定,实际生效值还受到系统内存和tcp_syncookies开关的影响,默认常见的配置是 1024 或 2048。第二个是全连接队列(accept queue),存放的是已完成三次握手、等待应用调用accept()的 socket。它的上限就是上一节说的min(backlog, somaxconn)。两个队列在握手过程中的切换时机,恰好对应第 1 节的状态机:SYN 到达后进半连接队列,ACK 到达后从半连接队列取出并放入全连接队列。所以,任何一个队列满了,握手都会失败,但表现不一样。3.2 队列满的时候,内核做了什么先说全连接队列溢出。这是生产环境里最常见的握手故障源,因为业务代码accept()不够快或者罗辑里串了耗时操作,队列很快就顶到上限。此时新到达的握手完成包怎么处理,取决于一个容易被忽略的参数:net.ipv4.tcp_abort_on_overflow默认值是 0,意思是当全连接队列满时,内核丢弃这个连接。对应到网络包层面,服务端会丢掉客户端发来的第三次 ACK,或者在某些内核版本中直接不再处理新到的 SYN。客户端那边呢?它已经进入ESTABLISHED了,等到开始发业务数据时发现没人响应,运气好一点可能收到 RST,运气不好就一直等,直到应用层超时。如果把这个参数改成 1,队列满时内核直接给客户端回 RST,客户端会立刻看到connection reset by peer。看起来比干等更“痛快”,但生产环境一般不建议开,因为 RST 会把瞬时过载的后果直接暴露给客户端,造成大面积的连接错误。再看半连接队列溢出。如果 SYN 来得太快,半连接队列满了,内核的行为取决于tcp_syncookies:关闭时,新到的 SYN 被直接丢弃,客户端收不到 SYN+ACK,会一直重传 SYN,表现为 connect 卡住直到超时。开启时,内核不依赖半连接队列,而是通过 SYN cookie 机制验证客户端。只要 cookie 校验通过,即使队列满了也能建立连接。所以tcp_syncookies其实是半连接队列的“保险丝”,它把“队列满了就拒绝”改成“队列满了也能继续服务”,换来的是握手过程中部分 TCP 选项受限(因为状态不在队列里,后续选项协商不完整)。这也是为什么不要轻易关 syncookies,尤其在高 SYN 并发场景下。3.3 从客户端视角看队列溢出客户端不知道服务端队列长什么样,它只能看到结果。整理一下:服务端状态客户端可观察现象抓包特征半连接队列满且未开 syncookiesconnect 超时同一 seq 的 SYN 反复重传,无 SYN+ACK半连接队列满但开了 syncookies握手仍能完成正常三次握手,但 TCP 选项可能不完整全连接队列满,abort_on_overflow=0connect 成功但业务超时或 RSTSYN+ACK 正常,第三次 ACK 后无响应全连接队列满,abort_on_overflow=1connect 直接收到 RST服务端在三次握手最后阶段回 RST这些现象不是你背下来就完事,而是要在实际抓包里对得上号。有一次我排查一个“客户端偶发 connect 超时”的问题,抓包看到 SYN 重传到第三次,服务端才回 SYN+ACK,明显是全连接队列满导致内核没来得及处理。接着ss -lnt一看,Recv-Q已经顶满Send-Q,问题就定位了。队列溢出还有一个副作用:它不会像磁盘满那样报错,只会默默丢包。业务日志里看到的可能是“上游连接超时”“连接重置”,如果没有计数器佐证,很容易被误判成网络丢包或者防火墙拦截。4. 调参前必读:握手重传、超时与加速的取舍4.1 重传节奏:为什么是 1 秒、2 秒、4 秒SYN 发出后如果没有收到 SYN+ACK,TCP 不会傻等,而是按指数退避重传。Linux 默认的首次重传间隔是 1 秒,之后依次变成 2 秒、4 秒、8 秒,以此类推,直到达到重传上限。这个上限由net.ipv4.tcp_syn_retries控制,默认是 6。也就是说,如果 SYN 一直在丢,客户端会在第 1、2、4、8、16、32 秒各重传一次,加起来大约 63 秒后放弃。服务端回 SYN+ACK 后的重传则由net.ipv4.tcp_synack_retries控制,默认 5,总时长约 31 秒。这两个参数经常被人“为了快速失败”调成 1 或 2。调小确实能让 connect 快速失败,但它带来一个代价:网络出现短暂抖动时,本来能够恢复的连接也会被提前放弃。我见过把tcp_syn_retries调成 1 的服务,客户端偶尔报 connect 超时,抓包一看,SYN 第一次没回包,1 秒后重发一次,还是没回,就放弃了。而这一秒的抖动如果放在默认参数下,第三次重传大概率就成功了。所以这个参数的调整准则是“你愿意等多久才知道目标不可达”,而不是“越快越好”。对内部服务调用可以适当调小,对公网服务建议保留默认,或者只下调到 3 左右。4.2 参数组合表:什么场景动什么,什么东西别动调参与其说是“调一个数”,不如说是“选一个组合”。下面是几个典型场景的参数组合思路:场景优先调整的参数方向备注全连接队列积压net.core.somaxconn、应用层 backlog调大需要同时改应用配置,否则只有一端变化高 SYN 并发net.ipv4.tcp_max_syn_backlog调大同时确认tcp_syncookies为 1客户端快速失败net.ipv4.tcp_syn_retries调小根据业务容忍度设置服务端减少半连接占用net.ipv4.tcp_synack_retries调小慎用,可能影响弱网用户大量 TIME_WAITnet.ipv4.tcp_tw_reuse开启仅对连接发起方有效,需 timestamps 开启有一个参数我要单独提醒,就是net.ipv4.tcp_tw_recycle。它曾经被用来加速 TIME_WAIT 回收,但坑很大,尤其当你前面有 NAT 设备时,它会因为时间戳比较机制误杀正常连接。这个参数在新内核里已经不受支持,无论网上哪篇老文章说它有用,我都建议直接忽略。4.3 握手加速的诱惑与代价:TFO、reuse 与 syncookies握手加速无非三种思路:减少往返次数、复用旧连接、跳过队列。减少往返次数靠TCP Fast Open,核心思想是在 SYN 里直接携带应用数据,省掉一个 RTT。Linux 通过net.ipv4.tcp_fastopen控制,有 1、2、3 三个档位,分别表示只做客户端、只做服务端、两端都做。听起来很美,但实际部署要考虑两个问题:一是首次连接拿不到 TFO cookie,该走完整握手还得走;二是中间有些老旧设备会直接把带数据的 SYN 当异常包丢掉。所以在公网大范围开启之前,最好先在小流量上验证链路兼容性。复用它是在 TIME_WAIT 上做文章。前面说过tcp_tw_reuse允许内核在安全条件下复用 TIME_WAIT 连接,前提是双方都开了时间戳。这个参数对大量短连接的客户端(比如频繁请求外部 API 的服务)很有用,能显著降低端口被 TIME_WAIT 占满的风险。但注意,它是给连接发起方用的,服务端改这个参数对自己的 TIME_WAIT 没有帮助。服务端如果想减少 TIME_WAIT,更该关心的是自己为什么频繁主动关闭连接,而不是用参数掩盖。至于 syncookies,它解决的是“半连接队列满”的问题,代价是部分握手选项不完整。在高并发或者 SYN 泛洪场景下,它是最便宜的保命手段,不要关。但在正常流量下,也不要把tcp_max_syn_backlog调到极大值去“省掉 syncookies”,因为半连接队列里的每条记录都占内存,队列越大,内存和哈希表的开销越高。5. 一次连接超时的实测复盘:从抓包到定位的完整链路5.1 现象与初步判断某天收到告警,某个业务端口偶发连接耗时飙升,平均值从原来的 2 毫秒涨到 300 毫秒,部分请求直接报 connect 超时。服务器 CPU、内存、带宽都正常,业务日志里也没有明显异常。这个现象最大的迷惑点在于:服务器指标正常,容易让人往网络方向想。但我没有先去查交换机,而是直接在服务器上抓包,因为如果是网络链路的问题,tcpdump 同样能看到痕迹;如果是服务端队列的问题,抓包也能定位。5.2 逐步排查:抓包、队列、计数器第一步,确定问题连接长什么样。在服务端抓包,过滤目标端口:tcpdump -nn -i any tcp port 8080 -c 200 -w /tmp/handshake.pcap停下来分析时,过滤掉正常的连接,只看 SYN 重传:tcpdump -nn -r /tmp/handshake.pcap 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'结果发现一个规律:客户端发来的 SYN 包 seq 相同但时间间隔为 1 秒、2 秒、4 秒,说明客户端在重传 SYN,而服务端一直没有回 SYN+ACK。这就把问题范围缩小到了服务端——要么是内核没处理,要么是中间链路把 SYN 丢了。第二步,看全连接队列。执行:ss -lnt | grep 8080输出显示Recv-Q已经到了 127,而Send-Q是 128。队列几乎顶满。这说明服务端确实收到了 SYN,但三次握手完成之后进不了全连接队列,才导致后续处理缓慢。第三步,用计数器确认这是不是持续性的。连续两次跑:netstat -s | grep -i listen nstat -az | grep -E "ListenOverflows|ListenDrops"第一次读到ListenOverflows是 1500,第二次变成 1523,明显在增长。到这里,根因已经清楚了:全连接队列上限太小,应用 accept 速度跟不上高峰期的连接建立速率,队列反复被打满。为什么服务器 CPU 和内存都正常?因为 accept 慢不是 CPU 不够,而是业务代码里出了一个耗时操作。这是另一个维度的排查,跟网络调优没有关系。5.3 调整与复测确认是全连接队列问题后,调整分两步走。先改内核参数:sysctl -w net.core.somaxconn=2048 echo "net.core.somaxconn = 2048" >> /etc/sysctl.conf再改应用层监听 backlog。大多数服务框架会暴露这个配置,例如常见的 Web 服务器通过backlog参数或者环境变量设置。把应用层的listen()backlog 也调到 2048,重启应用进程,让配置生效。调整之后不要只看“问题有没有消失”,而是要看两组数据:一是ss -lnt里的Recv-Q是否回落到低位;二是nstat -az里的ListenOverflows是否停止增长。如果Recv-Q降了但溢出计数还在涨,说明上限还不够;如果Recv-Q一直很低但计数也在涨,那可能是瞬时尖峰问题,需要再看抓包确认。实测复测结果:Recv-Q从 127 降到稳定在 10 以下,ListenOverflows连续观察 30 分钟没有新增,客户端连接耗时恢复到 2 毫秒左右。5.4 我常用的握手观测清单把这个案例收尾之后,我把平时排查握手问题时用的检查清单整理成了固定顺序,每次遇到类似问题都按这个节奏走:先抓包,确认有没有 SYN 重传、三次握手是否完整。再看ss -lnt,确认全连接队列水位和实际上限。然后连续读两次netstat -s和nstat -az,确认溢出计数是否在增长。最后才是想“该调什么参数”——而且每次只动一个变量,调完重复以上三步验证。说到底,TCP 握手不是一个黑盒,而是一条可以完整观测的事件链。抓包让你看到单个连接的微观时序,ss让你看到队列水位的瞬时快照,计数器让你看到系统层面的整体趋势。把这三层观测能力建立起来之后,再回头去看那些sysctl参数,每一个都有了明确的适用场景和验证方法,不会再有“盲调”的困惑。
前阵子接手一个连接超时的排查,同事第一句话就是“我们是不是该调一下 TCP 参数”。我问他调哪个参数、依据什么、期望变成什么效果,他答不上来。这其实是我见过最常见的状态——手里存着一堆sysctl参数列表,但对 TCP 握手的过程缺乏可观测的认知,出了问题只能逐个试,试完也不知道到底有没有用。这篇文章想把这块补上。我会从 TCP 握手的实际时间线讲起,把抓包、队列、重传和调参串成一条完整的观测链路。重点是:与其问“该把哪个参数调大”,不如先学会“怎么看懂握手到底发生了什么”。内容偏实践,适合后端开发、运维、SRE 以及刚接触网络基础设施的读者。1. 握手不是瞬间完成的:从状态机看三次交互的真相1.1 三次交互与两端各自的状态变化教科书上把 TCP 握手画成三个箭头:客户端发 SYN,服务端回 SYN+ACK,客户端再回 ACK,连接建立。这个图没错,但它给人一种“三次交互瞬间完成”的错觉。实际上每一次交互之间,客户端和服务端的内核状态都在变化,而且两端不是同时进入“可收发数据”的状态。把完整的过程拆开看:客户端调用connect(),内核发出 SYN 包,客户端进入SYN_SENT状态。服务端收到 SYN 后,在监听 socket 对应的半连接队列里创建一个请求记录(request_sock),状态变为SYN_RCVD,同时回应 SYN+ACK。客户端收到 SYN+ACK,校验序列号无误,内核将 socket 置为ESTABLISHED,然后发出 ACK。服务端收到这个 ACK,才从半连接队列中取出请求记录,构建完整的 socket 结构,放入全连接队列,此时服务端才进入ESTABLISHED。应用进程调用accept(),从全连接队列里取走这个连接。有两个容易忽略的点。第一,客户端在发完第三步 ACK 之后就已经觉得自己“连上了”,但这个 ACK 还在路上,服务端要等收到之后才算完成握手。如果这个 ACK 因为网络延迟或者服务端负载原因没有及时处理,客户端可能已经开始发送业务数据了,服务端这边未必准备好了。第二,accept()不是“创建连接”,而是“取走连接”。连接在第三步 ACK 到达时就已经在内核的全连接队列里了,应用程序什么时候来取,取决于业务代码的执行速度。这也解释了为什么“握手完成”和“应用真正开始处理请求”之间还有一段不可忽略的时间窗口。1.2 握手过程中携带的“隐形契约”很多人只关注握手的标志位,忽略了 SYN 和 SYN+ACK 包里还带着一组 TCP 选项。这些选项虽然不决定“能不能连上”,但决定了“连上之后跑得快不快”。常用的有这么几个:MSS:告诉对端自己能接受的最大报文段长度。通常由 MTU 计算而来,1500 字节的 MTU 去掉 IP 头和 TCP 头,默认是 1460。两端协商时取较小值。SACK:选择性确认。开启后,丢包时可以只重传丢失的段,而不是把窗口内所有数据都重传一遍。Timestamps:用于计算 RTT 和保护序列号不会绕回,同时它也是后面讲到的tcp_tw_reuse生效的前提。Window Scale:把 TCP 窗口从 16 位扩展到 30 位,支持更大的拥塞窗口,是高带宽长肥管道的必需品。这些选项的协商不是“哪边强就按哪边来”,而是取交集或取较小值。如果中间链路有网络设备强行剥掉某些选项,两边就只能退回到基础版本。这也是为什么有些连接能建、但吞吐始终上不去——不是带宽问题,而是握手时就把性能选项丢了。1.3 服务端“握手完成”与“accept 完成”之间的空档从状态机可以看出,一个连接从客户端视角是“三步握手结束即可用”,但从服务端视角要经历“收到 ACK 入队 → 等待 accept → 业务处理”三个阶段。其中“入队后等待 accept”这段时间在全连接队列里是不可见的黑盒。如果全连接队列满了,内核的处理方式会直接影响客户端体验,这个内容我在第 3 节展开。这里先记住一个结论:客户端 connect 成功不等于服务端已经能够接收数据,中间还隔着一个队列水位。所以,做网络调优第一步不是动参数,而是建立“握手是一个多阶段事件”的认知。只有把三个阶段都看清了,才能知道问题到底出在哪个环节。2. 观测工具三件套:tcpdump、ss 与计数器2.1 tcpdump 抓包:用最原始的方式看握手抓包是观测握手最直接的手段。它不看任何内核统计,就是记录网络上真实发生的包。一条最简单的命令:tcpdump -nn -i any tcp port 8080拆解一下参数:-nn不做域名和端口反查,输出里直接显示 IP 和端口,避免因为反查慢丢包;-i any抓所有网卡;tcp port 8080只过滤目标或源端口为 8080 的 TCP 包。正常握手的输出长这样:IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.6.8080 > 10.0.0.5.52340: Flags [S.], seq 876543210, ack 4242424243, win 65160 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [.], ack 876543211, win 64240三个包对应的标志位依次是[S](SYN)、[S.](SYN+ACK)、[.](纯 ACK)。这里有个小细节:第一个包的 seq 是 4242424242,第二个包的 ack 是 4242424243,也就是第一个包的 seq 加 1。这个“加一”表示 SYN 消耗了一个序列号。第三个包的 ack 同样是第二个包 seq 加 1。如果抓包时看到 ack 值不符合这个规律,说明中间有包被丢弃或者被设备篡改。想看到 TCP 选项,加-v:tcpdump -nn -i any tcp port 8080 -v输出里会多出mss 1460,sackOK,TS val ...这类内容。这里的sackOK就是双方协商允许 SACK,TS是时间戳选项,wscale表示窗口缩放系数。如果-v之后发现某些选项缺失,那就要考虑是不是中间设备做了手脚。抓包过程中最常见的异常是同一个 SYN 包重复出现:IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240 IP 10.0.0.5.52340 > 10.0.0.6.8080: Flags [S], seq 4242424242, win 64240三次 SYN 包的 seq 完全一样,说明客户端在重传 SYN。至于为什么重传,得继续往下查。2.2 ss 的视角:队列积压比状态突变更值得关注ss是当前 Linux 上查看 socket 状态的首选工具,比netstat快,信息也更全。观测握手时,最关键是这一条:ss -lnt-l只看监听 socket,-n不解析名称,-t只看 TCP。输出类似:State Recv-Q Send-Q Local Address:Port Peer Address:Port LISTEN 0 128 0.0.0.0:8080 0.0.0.0:*注意这里的Recv-Q和Send-Q含义跟在已建立连接上完全不一样。对监听中的 socket 来说:Recv-Q表示当前已完成握手、等待应用调用 accept() 的连接数量,也就是全连接队列里积压了多少。Send-Q表示全连接队列的上限,也就是实际生效的 backlog。很多人把这两个字段理解成“收了多少数据、发了多少数据”,这是监听场景下最常见的误解。在已建立的连接里,Recv-Q是接收缓冲区待读取的数据量,Send-Q是发送缓冲区的排队数据量,二者完全不同。把两套含义分开记,排查时就不会被输出带偏。Send-Q里的 128 是从哪来的?如果应用调用listen(fd, backlog)时传入了 512,net.core.somaxconn只有 128,内核取二者的较小值,最终队列上限就是 128。想确认这个值,直接看ss就行,不用翻代码。2.3 系统计数器:netstat -s 与 nstat 里的隐藏信号抓包能看单条连接,但看不出整体趋势。要看“系统层面丢了多少握手包”,靠的是内核计数器。下面两条命令各有用处:netstat -s | grep -i listen nstat -az | grep -E "ListenOverflows|ListenDrops"netstat -s输出里的SYNs to LISTEN sockets dropped统计的是被丢弃的 SYN 包数量。这个计数只要在增长,就说明有握手请求在服务端入队之前就被放弃了。nstat读的是内核网络统计模块的数据,ListenOverflows表示全连接队列溢出的累计次数,ListenDrops表示半连接队列或者监听队列丢弃请求的次数。注意一点,nstat -az里的-z会把为 0 的项也打印出来,第一次跑的时候全部是 0 属于正常现象,关键是看后续是否增长。这三个工具的分工可以这样概括:工具观测粒度核心作用tcpdump单条连接看清握手包的时序、重传、TCP 选项ss单端口队列看全连接队列当前水位和上限netstat -s / nstat系统全局看队列溢出和丢 SYN 的累计趋势实际排查时,我通常先用ss -lnt看有没有积压,再用netstat -s和nstat确认溢出计数,最后用tcpdump抓典型的几条连接看包时序。工具之间是配合关系,不是替代关系。3. backlog 的全貌:半连接队列与全连接队列的溢出逻辑3.1 两个队列的分工:谁排队,谁等待Linux 内核处理新连接时实际上有两个队列,很多人把它们混成一个“backlog”,这是排查时思路混乱的根源。第一个是半连接队列(syn queue),存放的是只收到 SYN、还没完成三次握手的请求记录。这个队列的大小不完全由net.ipv4.tcp_max_syn_backlog单独决定,实际生效值还受到系统内存和tcp_syncookies开关的影响,默认常见的配置是 1024 或 2048。第二个是全连接队列(accept queue),存放的是已完成三次握手、等待应用调用accept()的 socket。它的上限就是上一节说的min(backlog, somaxconn)。两个队列在握手过程中的切换时机,恰好对应第 1 节的状态机:SYN 到达后进半连接队列,ACK 到达后从半连接队列取出并放入全连接队列。所以,任何一个队列满了,握手都会失败,但表现不一样。3.2 队列满的时候,内核做了什么先说全连接队列溢出。这是生产环境里最常见的握手故障源,因为业务代码accept()不够快或者罗辑里串了耗时操作,队列很快就顶到上限。此时新到达的握手完成包怎么处理,取决于一个容易被忽略的参数:net.ipv4.tcp_abort_on_overflow默认值是 0,意思是当全连接队列满时,内核丢弃这个连接。对应到网络包层面,服务端会丢掉客户端发来的第三次 ACK,或者在某些内核版本中直接不再处理新到的 SYN。客户端那边呢?它已经进入ESTABLISHED了,等到开始发业务数据时发现没人响应,运气好一点可能收到 RST,运气不好就一直等,直到应用层超时。如果把这个参数改成 1,队列满时内核直接给客户端回 RST,客户端会立刻看到connection reset by peer。看起来比干等更“痛快”,但生产环境一般不建议开,因为 RST 会把瞬时过载的后果直接暴露给客户端,造成大面积的连接错误。再看半连接队列溢出。如果 SYN 来得太快,半连接队列满了,内核的行为取决于tcp_syncookies:关闭时,新到的 SYN 被直接丢弃,客户端收不到 SYN+ACK,会一直重传 SYN,表现为 connect 卡住直到超时。开启时,内核不依赖半连接队列,而是通过 SYN cookie 机制验证客户端。只要 cookie 校验通过,即使队列满了也能建立连接。所以tcp_syncookies其实是半连接队列的“保险丝”,它把“队列满了就拒绝”改成“队列满了也能继续服务”,换来的是握手过程中部分 TCP 选项受限(因为状态不在队列里,后续选项协商不完整)。这也是为什么不要轻易关 syncookies,尤其在高 SYN 并发场景下。3.3 从客户端视角看队列溢出客户端不知道服务端队列长什么样,它只能看到结果。整理一下:服务端状态客户端可观察现象抓包特征半连接队列满且未开 syncookiesconnect 超时同一 seq 的 SYN 反复重传,无 SYN+ACK半连接队列满但开了 syncookies握手仍能完成正常三次握手,但 TCP 选项可能不完整全连接队列满,abort_on_overflow=0connect 成功但业务超时或 RSTSYN+ACK 正常,第三次 ACK 后无响应全连接队列满,abort_on_overflow=1connect 直接收到 RST服务端在三次握手最后阶段回 RST这些现象不是你背下来就完事,而是要在实际抓包里对得上号。有一次我排查一个“客户端偶发 connect 超时”的问题,抓包看到 SYN 重传到第三次,服务端才回 SYN+ACK,明显是全连接队列满导致内核没来得及处理。接着ss -lnt一看,Recv-Q已经顶满Send-Q,问题就定位了。队列溢出还有一个副作用:它不会像磁盘满那样报错,只会默默丢包。业务日志里看到的可能是“上游连接超时”“连接重置”,如果没有计数器佐证,很容易被误判成网络丢包或者防火墙拦截。4. 调参前必读:握手重传、超时与加速的取舍4.1 重传节奏:为什么是 1 秒、2 秒、4 秒SYN 发出后如果没有收到 SYN+ACK,TCP 不会傻等,而是按指数退避重传。Linux 默认的首次重传间隔是 1 秒,之后依次变成 2 秒、4 秒、8 秒,以此类推,直到达到重传上限。这个上限由net.ipv4.tcp_syn_retries控制,默认是 6。也就是说,如果 SYN 一直在丢,客户端会在第 1、2、4、8、16、32 秒各重传一次,加起来大约 63 秒后放弃。服务端回 SYN+ACK 后的重传则由net.ipv4.tcp_synack_retries控制,默认 5,总时长约 31 秒。这两个参数经常被人“为了快速失败”调成 1 或 2。调小确实能让 connect 快速失败,但它带来一个代价:网络出现短暂抖动时,本来能够恢复的连接也会被提前放弃。我见过把tcp_syn_retries调成 1 的服务,客户端偶尔报 connect 超时,抓包一看,SYN 第一次没回包,1 秒后重发一次,还是没回,就放弃了。而这一秒的抖动如果放在默认参数下,第三次重传大概率就成功了。所以这个参数的调整准则是“你愿意等多久才知道目标不可达”,而不是“越快越好”。对内部服务调用可以适当调小,对公网服务建议保留默认,或者只下调到 3 左右。4.2 参数组合表:什么场景动什么,什么东西别动调参与其说是“调一个数”,不如说是“选一个组合”。下面是几个典型场景的参数组合思路:场景优先调整的参数方向备注全连接队列积压net.core.somaxconn、应用层 backlog调大需要同时改应用配置,否则只有一端变化高 SYN 并发net.ipv4.tcp_max_syn_backlog调大同时确认tcp_syncookies为 1客户端快速失败net.ipv4.tcp_syn_retries调小根据业务容忍度设置服务端减少半连接占用net.ipv4.tcp_synack_retries调小慎用,可能影响弱网用户大量 TIME_WAITnet.ipv4.tcp_tw_reuse开启仅对连接发起方有效,需 timestamps 开启有一个参数我要单独提醒,就是net.ipv4.tcp_tw_recycle。它曾经被用来加速 TIME_WAIT 回收,但坑很大,尤其当你前面有 NAT 设备时,它会因为时间戳比较机制误杀正常连接。这个参数在新内核里已经不受支持,无论网上哪篇老文章说它有用,我都建议直接忽略。4.3 握手加速的诱惑与代价:TFO、reuse 与 syncookies握手加速无非三种思路:减少往返次数、复用旧连接、跳过队列。减少往返次数靠TCP Fast Open,核心思想是在 SYN 里直接携带应用数据,省掉一个 RTT。Linux 通过net.ipv4.tcp_fastopen控制,有 1、2、3 三个档位,分别表示只做客户端、只做服务端、两端都做。听起来很美,但实际部署要考虑两个问题:一是首次连接拿不到 TFO cookie,该走完整握手还得走;二是中间有些老旧设备会直接把带数据的 SYN 当异常包丢掉。所以在公网大范围开启之前,最好先在小流量上验证链路兼容性。复用它是在 TIME_WAIT 上做文章。前面说过tcp_tw_reuse允许内核在安全条件下复用 TIME_WAIT 连接,前提是双方都开了时间戳。这个参数对大量短连接的客户端(比如频繁请求外部 API 的服务)很有用,能显著降低端口被 TIME_WAIT 占满的风险。但注意,它是给连接发起方用的,服务端改这个参数对自己的 TIME_WAIT 没有帮助。服务端如果想减少 TIME_WAIT,更该关心的是自己为什么频繁主动关闭连接,而不是用参数掩盖。至于 syncookies,它解决的是“半连接队列满”的问题,代价是部分握手选项不完整。在高并发或者 SYN 泛洪场景下,它是最便宜的保命手段,不要关。但在正常流量下,也不要把tcp_max_syn_backlog调到极大值去“省掉 syncookies”,因为半连接队列里的每条记录都占内存,队列越大,内存和哈希表的开销越高。5. 一次连接超时的实测复盘:从抓包到定位的完整链路5.1 现象与初步判断某天收到告警,某个业务端口偶发连接耗时飙升,平均值从原来的 2 毫秒涨到 300 毫秒,部分请求直接报 connect 超时。服务器 CPU、内存、带宽都正常,业务日志里也没有明显异常。这个现象最大的迷惑点在于:服务器指标正常,容易让人往网络方向想。但我没有先去查交换机,而是直接在服务器上抓包,因为如果是网络链路的问题,tcpdump 同样能看到痕迹;如果是服务端队列的问题,抓包也能定位。5.2 逐步排查:抓包、队列、计数器第一步,确定问题连接长什么样。在服务端抓包,过滤目标端口:tcpdump -nn -i any tcp port 8080 -c 200 -w /tmp/handshake.pcap停下来分析时,过滤掉正常的连接,只看 SYN 重传:tcpdump -nn -r /tmp/handshake.pcap 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0'结果发现一个规律:客户端发来的 SYN 包 seq 相同但时间间隔为 1 秒、2 秒、4 秒,说明客户端在重传 SYN,而服务端一直没有回 SYN+ACK。这就把问题范围缩小到了服务端——要么是内核没处理,要么是中间链路把 SYN 丢了。第二步,看全连接队列。执行:ss -lnt | grep 8080输出显示Recv-Q已经到了 127,而Send-Q是 128。队列几乎顶满。这说明服务端确实收到了 SYN,但三次握手完成之后进不了全连接队列,才导致后续处理缓慢。第三步,用计数器确认这是不是持续性的。连续两次跑:netstat -s | grep -i listen nstat -az | grep -E "ListenOverflows|ListenDrops"第一次读到ListenOverflows是 1500,第二次变成 1523,明显在增长。到这里,根因已经清楚了:全连接队列上限太小,应用 accept 速度跟不上高峰期的连接建立速率,队列反复被打满。为什么服务器 CPU 和内存都正常?因为 accept 慢不是 CPU 不够,而是业务代码里出了一个耗时操作。这是另一个维度的排查,跟网络调优没有关系。5.3 调整与复测确认是全连接队列问题后,调整分两步走。先改内核参数:sysctl -w net.core.somaxconn=2048 echo "net.core.somaxconn = 2048" >> /etc/sysctl.conf再改应用层监听 backlog。大多数服务框架会暴露这个配置,例如常见的 Web 服务器通过backlog参数或者环境变量设置。把应用层的listen()backlog 也调到 2048,重启应用进程,让配置生效。调整之后不要只看“问题有没有消失”,而是要看两组数据:一是ss -lnt里的Recv-Q是否回落到低位;二是nstat -az里的ListenOverflows是否停止增长。如果Recv-Q降了但溢出计数还在涨,说明上限还不够;如果Recv-Q一直很低但计数也在涨,那可能是瞬时尖峰问题,需要再看抓包确认。实测复测结果:Recv-Q从 127 降到稳定在 10 以下,ListenOverflows连续观察 30 分钟没有新增,客户端连接耗时恢复到 2 毫秒左右。5.4 我常用的握手观测清单把这个案例收尾之后,我把平时排查握手问题时用的检查清单整理成了固定顺序,每次遇到类似问题都按这个节奏走:先抓包,确认有没有 SYN 重传、三次握手是否完整。再看ss -lnt,确认全连接队列水位和实际上限。然后连续读两次netstat -s和nstat -az,确认溢出计数是否在增长。最后才是想“该调什么参数”——而且每次只动一个变量,调完重复以上三步验证。说到底,TCP 握手不是一个黑盒,而是一条可以完整观测的事件链。抓包让你看到单个连接的微观时序,ss让你看到队列水位的瞬时快照,计数器让你看到系统层面的整体趋势。把这三层观测能力建立起来之后,再回头去看那些sysctl参数,每一个都有了明确的适用场景和验证方法,不会再有“盲调”的困惑。