TCP连接状态异常排查与抓包调优指南
发布时间:2026/10/27 20:28:13 锦皓数字建站

线上服务半夜告警监控面板上连接数一路往上顶应用日志里刷着各种超时运维同学第一反应是加机器开发同学怀疑是数据库慢查询结果一圈排查下来问题出在最底下那层——传输层协议 TCP 的连接状态不对大量连接卡在半开或者等待回收的状态里出不去。这种场景我经历过不止一次每次都让我更加确信一件事不管上层框架封装得多好TCP 这门基本功不扎实线上问题就只能靠猜。这篇内容我想聊聊 TCP 协议本身从连接怎么建立、数据怎么保证不丢、流量怎么控制到实际调优和抓包排查把我这些年踩过的坑和验证过的做法整理出来。适合刚学网络基础的同学建立完整认知也适合写了几年业务代码、但一直没认真看过传输层细节的开发者补课。看完你至少能做到看到连接状态异常知道往哪查遇到吞吐上不去知道调哪个参数抓包抓到一堆报文能看懂它在说什么。1. TCP 到底在解决什么问题1.1 从 IP 层的不靠谱说起要理解 TCP 的设计得先看它脚下踩的是什么。IP 层提供的是尽力而为的投递服务翻译成人话就是我尽量帮你送但送不到、送重了、送乱了我都不负责。网络中的路由器会因为队列满了丢包链路会因为拥塞导致报文乱序到达中间任何一跳出问题都不会通知两端。这个模型的好处是简单、便宜、可扩展坏处是对绝大多数应用来说根本不够用——你没法让一个转账请求承受丢包也没法让一个文件下载接受乱序。TCP 就是在 IP 之上补上这一层的可靠性。它把 IP 的尽力而为包装成字节流向上层承诺四件事数据不丢、不重、不乱序、有流量控制。注意这里的关键词是字节流不是消息流。TCP 只保证字节按顺序到达它完全不知道你发的是一个请求还是半个请求这个边界要应用层自己划。很多人对 TCP 的误解就从这个点开始后面讲粘包的时候会详细展开。我习惯用一个类比来解释分层IP 层像邮政系统只负责把信封从 A 城市运到 B 城市路上丢了不赔TCP 像在两端各安排了一个负责任的秘书负责编号、登记、催收、补发直到确认对方完整收到为止。应用层则是坐在办公室里写内容的人只管把文件交给秘书不用操心路上发生了什么。1.2 TCP 的四项承诺与实际代价TCP 的可靠性不是白来的每一项承诺背后都有明确的机制和成本理解这个对应关系才能在实际选型时做出判断。承诺实现机制代价数据不丢序号 确认 超时重传需要缓存未确认数据占用内存数据不重序号去重接收端要维护期望序号不乱序序号排序 接收缓冲区重组乱序时要等待增加延迟不压垮对方滑动窗口流量控制窗口探测带来额外报文不压垮网络拥塞控制慢启动阶段吞吐爬坡慢连接语义三次握手 / 四次挥手建连有 RTT 开销这张表我建议每个写服务端代码的人都过一遍。你会发现TCP 为了保证可靠性付出了不少延迟和内存的代价。所以它并不是万能解在实时音视频、游戏帧同步这类宁可丢一帧也不能等的场景里UDP 加自定义可靠性策略反而更合适。我见过有团队在游戏里硬用 TCP结果一次丢包引发重传阻塞整个画面卡成幻灯片最后还是改成 UDP 加应用层序号。选择 TCP 的判断标准其实很简单你能不能接受数据丢失或乱序。不能接受就用 TCP能接受或者有自己的重传策略就用 UDP。中间态的 QUIC 属于另一个话题这里不展开。1.3 一次请求里 TCP 究竟做了什么拿最常见的 HTTP 请求举例一次完整的交互在传输层大致经历这些步骤客户端调用 connect内核发送 SYN进入 SYN_SENT服务端回复 SYNACK客户端回复 ACK连接建立双方进入 ESTABLISHED客户端发送请求数据数据被切成 MSS 大小的段每段带序号服务端收到后回复 ACK同时把接收窗口大小告诉对方如果某个段丢了服务端重复 ACK 相同序号客户端收到三个重复 ACK 触发快速重传数据传输期间发送端根据拥塞窗口和接收窗口取最小值决定能发多少请求结束一方发送 FIN经过四次挥手断开。这七步里真正容易被忽视的是第 4 步和第 6 步。接收窗口是接收方告诉发送方我还能接多少拥塞窗口是发送方自己估计网络还能承受多少实际发送量取两者较小值。很多吞吐上不去的问题根源就在这两个窗口之一被卡住了——要么是接收端应用读得太慢导致窗口收缩要么是网络丢包导致拥塞窗口被打回原形。提示排查吞吐问题时先看是 rwnd 受限还是 cwnd 受限。前者是应用层消费慢加机器或优化业务逻辑后者是网络路径有问题调参数往往治标不治本。2. 连接管理三次握手和四次挥手的真实细节2.1 三次握手每一步在干什么教科书上的三次握手图大家都见过但真正理解每一步存在的意义得从双方各自要确认什么这个角度切入。第一次客户端发送 SYN1seqx。这一步客户端表达了两个意思——我想建连我的初始序号是 x。此时客户端不知道服务端是否在线、是否愿意接受连接所以进入 SYN_SENT 状态等待。第二次服务端回复 SYN1ACK1seqyackx1。这一步服务端同时做了两件事确认收到了客户端的 SYNackx1并且发出自己的 SYNseqy。这里有个容易忽略的细节SYN 会消耗一个序号所以确认号是 x1 而不是 x。服务端此时进入 SYN_RCVD。第三次客户端回复 ACK1acky1。这一步客户端确认收到了服务端的 SYN。到这里双方都确认了对方的发送能力和自己的接收能力连接正式建立。我经常问身边同学一个问题为什么是三次不是两次答案藏在防止历史连接这个点上。假设网络里滞留了一个很久以前客户端发出的 SYN服务端收到后回复 SYNACK 并直接认为连接建立然后傻等着数据而客户端根本没打算建连收到 SYNACK 会回 RST。这两次握手的情况下服务端白白创建了一个连接资源。三次握手让客户端多了一次我确认我要建这个连的表态服务端就不会被历史报文骗到。2.2 半连接队列与全连接队列服务端处理握手不是一次一个串行来的内核用了两个队列来管理半连接队列SYN queue存放收到 SYN 但还没完成三次握手的连接全连接队列accept queue存放已完成握手、等待应用层 accept 取走的连接。这里有两个极易踩坑的点。第一个是全连接队列满了会怎样。Linux 的默认行为是如果tcp_abort_on_overflow0默认值服务端会丢弃客户端的 ACK让客户端以为握手没完成而重传相当于把问题延后如果设成 1则直接回 RST。前者表现为偶发的连接超时后者表现为偶发的 connection reset两种现象都很迷惑人。第二个是backlog参数的真正含义。应用层调用listen(fd, backlog)传的那个数字并不是全连接队列的精确大小内核实际取的是min(backlog, net.core.somaxconn)。而半连接队列的大小由net.ipv4.tcp_max_syn_backlog控制同时还受somaxconn影响。我见过线上把 backlog 传成 5 的服务平时没问题一到流量高峰就开始丢连接日志里全是超时查了半天才发现是这个数字太小。# 查看当前全连接队列溢出情况 ss -lnt # Recv-Q 表示当前全连接队列长度Send-Q 表示队列上限 # 如果 Recv-Q 长期接近 Send-Q说明应用 accept 太慢 # 内核统计 netstat -s | grep -i listen # 关注 times the listen queue of a socket overflowed这条命令我建议加入日常巡检。Recv-Q持续增长是明确信号要么应用 accept 线程被阻塞了要么单机连接数已经超过处理能力。2.3 四次挥手与 TIME_WAIT 的真实坑挥手比握手多一次原因很直白TCP 是全双工的两个方向要各自关闭。主动关闭方发 FIN 表示我没数据要发了但这不代表我不能收数据所以被动方要先回一个 ACK等自己数据发完了再发 FIN这就形成了四次。完整状态流转是这样的主动方发 FIN进入 FIN_WAIT_1被动方回 ACK进入 CLOSE_WAIT主动方收到后进入 FIN_WAIT_2被动方数据发完发 FIN进入 LAST_ACK主动方回 ACK进入 TIME_WAIT等待 2MSL 后关闭。实际工作中两个状态最让人头疼TIME_WAIT和CLOSE_WAIT。它们的成因完全不同处理方式也完全不同但很多人会搞混。TIME_WAIT 出现在主动关闭方作用是两条一是保证最后一个 ACK 能到达对方如果对方没收到会重发 FIN此时还能再回一次 ACK二是让本次连接中滞留的旧报文在网络中消散避免它们干扰使用相同四元组的新连接。这两个作用决定了 TIME_WAIT 必须存在不能简单粗暴地取消。TIME_WAIT 的数量在主动发起大量短连接的场景下会迅速堆积比如反向代理服务器、爬虫客户端。Linux 默认net.ipv4.tcp_max_tw_buckets有一定上限超过后会直接清理并打日志。处理思路有几个层次最优先改成使用长连接从源头减少短连接数量其次让服务端主动关闭连接把 TIME_WAIT 转移到服务端服务端通常资源更充足且可以配合连接池再次开启net.ipv4.tcp_tw_reuse1允许复用 TIME_WAIT 状态的连接发起新连接仅对客户端出向连接生效最后调整net.ipv4.ip_local_port_range扩大可用端口范围。CLOSE_WAIT 则是另一个故事。它出现在被动关闭方表示我收到了对方的 FIN但我还没调用 close。如果 CLOSE_WAIT 堆积几乎百分之百是应用层代码问题——要么忘了关连接要么关连接的逻辑在异常分支里被跳过了。这个不是调内核参数能解决的只能改代码。注意网上流传的tcp_tw_recycle参数在新版本内核中已经被移除它的设计在处理 NAT 场景时存在缺陷不要再照抄老教程去开。2.4 SYN Flood 的防护思路SYN Flood 是利用握手第一步的经典攻击攻击方伪造大量源地址发 SYN服务端每收到一个就分配资源、回 SYNACK然后傻等那个永远不会来的 ACK半连接队列很快被撑满正常连接进不来。防护手段分几层。最基础的是开启net.ipv4.tcp_syncookies1原理是在 SYNACK 的序号里编码连接信息服务端不为半开连接分配资源只有收到合法 ACK 才真正建连。这招在队列快满时自动触发平时不影响正常流程。更上层的手段包括增大tcp_max_syn_backlog、缩短tcp_synack_retries、在边界设备做限速。需要提醒的是syncookies 会让一些 TCP 选项如窗口缩放在建连初期无法协商属于应急手段而非常态方案。真正扛得住的架构还是在前面有一层能吸收和清洗流量的能力再加上合理的队列配置。3. 可靠性机制序号、确认与重传3.1 序号和确认号到底怎么算TCP 把应用层交下来的字节流看成一个连续的长数组每个字节都有一个编号这个编号就是序号sequence number。但实际报文头里填的不是字节编号本身而是一个 32 位的值初始序号 ISN 是随机生成的之后按已发送字节数递增。为什么初始序号要随机两个原因一是安全防止别人猜测后续序号伪造报文二是避免和历史连接混在一起。ISN 的生成在现代内核里是基于时钟的大约每 4 微秒加 1绕一圈要很久足够保证新旧连接的序号空间不重叠。确认号acknowledgment number表示我期望收到的下一个字节编号是累积确认语义。比如收到 ack1001意思是我已经完整收到了 1 到 1000 号字节下一个我要 1001。这种设计的好处是简单一个 ACK 可以确认一大段数据坏处是一个丢包会导致后面所有数据都无法被确认也就是常说的队头阻塞。这里有个实操中很实用的点抓包时看相对序号比看绝对序号舒服得多。主流抓包工具都支持把首个包的序号显示为 0后续显示相对偏移排查时一眼就能看出哪段数据丢了。3.2 超时重传的 RTO 是怎么算出来的超时重传的核心问题是等多久没收到 ACK 就认为丢了等太短会误判本来没丢也重传浪费带宽还加重拥塞等太长则恢复慢用户体验差。TCP 的做法是动态估算。RFC 6298 给出的算法是这样的维护两个变量平滑往返时间 SRTT 和往返时间偏差 RTTVAR。每测到一个新的 RTT 样本按下面的公式更新RTTVAR (1 - beta) * RTTVAR beta * |SRTT - RTT_sample| SRTT (1 - alpha) * SRTT alpha * RTT_sample RTO SRTT 4 * RTTVAR其中 alpha 1/8beta 1/4。初始时 RTO 取 1 秒第一次测量后按公式更新。Linux 里 RTO 有下限和上限下限默认 200ms上限 120 秒。这个公式的含义值得琢磨SRTT 是平均值RTTVAR 是波动程度用 4 倍偏差作为安全边际。网络越不稳定RTTVAR 越大RTO 就越保守。这是典型的工程智慧——在不稳定的环境里宁可多等一会儿。还有一个经典问题重传后的 RTT 样本还能用吗答案是不能。因为你不知道收到的 ACK 是对原始报文还是对重传报文的确认算出来的 RTT 会偏大或偏小。这就是Karn 算法的结论重传过的报文不采样 RTT。同时只要发生超时重传RTO 就按指数退避翻倍1 秒、2 秒、4 秒……直到收到确认。3.3 快速重传与 SACK 的配合等超时太慢了。如果发送端连续收到三个针对同一序号的重复 ACK说明后面的包已经到了唯独中间那个丢了这时候不用等 RTO 到期直接重传这就是快速重传。为什么是三个因为网络轻微乱序也会产生一两个重复 ACK三个是个经验阈值能在及时性和误判率之间取得平衡。快速重传解决了什么时候重传但没解决重传多少。假设发了 1 到 10 号段2 号丢了3 到 10 都到了发送端只知道 2 号丢了但不知道 3 到 10 已经收到按原始 TCP 的累积确认语义它只能重传 2 号然后等确认推进。如果一次丢了多个包这个过程会反复多轮。SACK选择性确认就是来解决这个问题的。接收端在 ACK 里附带我已经收到了 3 到 10这样的区间信息发送端一看就知道只需要重传 2 号不用重复发已经到达的数据。SACK 在 Linux 里默认开启抓包时能在 TCP 选项里看到 SACK permitted 和 SACK 块。# 查看 SACK 是否启用 sysctl net.ipv4.tcp_sack # 1 表示启用 # 查看重传统计 netstat -s | grep -i retrans # 关注 segments retransmitted 与 total 的比例重传率是一个非常好的健康度指标。内网环境正常应该在万分之一以下公网跨地域在千分之几如果超过 1%基本可以断定链路有质量问题或者拥塞严重。3.4 重传相关的调优参数有几个参数在特定场景下很有用我列一下实际验证过的配置思路参数默认值调整场景注意事项net.ipv4.tcp_retries13一般不改底层重传次数影响路由失效判断net.ipv4.tcp_retries215移动弱网可适当降低决定连接多久后放弃默认约 13-30 分钟net.ipv4.tcp_syn_retries6建连超时敏感场景降到 3约 1248163263 秒net.ipv4.tcp_synack_retries5同上服务端重发 SYNACK 次数net.ipv4.tcp_thin_linear_timeouts0短连接多可考虑开部分内核版本已移除tcp_retries2这个参数值得多说两句。它决定了在已经建立连接的情况下重传多少次后放弃并通知应用。默认 15 次加上指数退避累计时间超过十分钟。如果业务侧有 3 秒超时这个内核参数根本不会触发应用层先放弃了。所以调整它之前先确认应用层超时设置是什么别做无用功。4. 流量控制与拥塞控制4.1 滑动窗口是怎么工作的如果没有流量控制发送端会以自己能发出的最快速度狂发接收端处理不过来只能丢丢了又要重传恶性循环。滑动窗口的作用就是让接收端告诉发送端我还能接多少你悠着点。接收端在 TCP 头的窗口字段里填入当前接收缓冲区的剩余空间这个值叫 rwnd。发送端维护一个发送窗口大小是 min(cwnd, rwnd)只有落在窗口内的数据才能发出去。随着 ACK 回来窗口左边沿右移新的数据进入可发范围这就是滑动的由来。几个细节容易被忽略。第一窗口缩放选项。TCP 头里窗口字段只有 16 位最大 65535 字节在长肥管道高带宽高延迟上完全不够用。窗口缩放选项允许把窗口值左移最多 14 位理论最大约 1GB。这个选项只能在握手阶段协商连接建好后不能改所以那些跑长连接的服务握手时一定要确保双方都支持。第二零窗口。如果接收缓冲区满了接收端会通告一个 0 窗口发送端就停止发送并启动零窗口探测定时器定期发一个探测包询问窗口是否恢复。这个探测包的序号是窗口左边沿减一接收端会回一个当前窗口值。如果探测逻辑出问题连接会假死表现为双方都还连着但数据不动。第三窗口更新通知。窗口从 0 恢复时接收端会主动发一个窗口更新 ACK。但这个 ACK 有可能丢所以发送端还是得靠探测定时器兜底。4.2 拥塞控制四个阶段的实际表现流量控制管的是接收端拥塞控制管的是网络。发送端并不知道网络中间发生了什么只能通过丢包和延迟变化来推测然后调整拥塞窗口 cwnd。经典算法分四个阶段慢启动。连接刚建立时cwnd 从初始值开始每收到一个 ACK 就加一个 MSS效果是指数增长——1、2、4、8、16……Linux 现在的初始窗口是 10 个 MSSRFC 6928相比早期的 1 个 MSS 已经快很多了。慢启动的名字有点误导它其实一点都不慢只是起点低。拥塞避免。当 cwnd 达到慢启动阈值 ssthresh 后改为每个 RTT 增加一个 MSS变成线性增长。这个转折点的判断在早期是基于丢包现在多基于延迟或带宽估计。快速重传。收到三个重复 ACK判定丢包立即重传不等超时。快速恢复。重传后不回到慢启动那太保守了而是把 ssthresh 设为当前窗口的一半cwnd 设为 ssthresh然后直接进入拥塞避免。超时重传的处理更狠ssthresh 减半cwnd 直接回到 1 个 MSS重新慢启动。这就是为什么一次超时会让吞吐断崖式下跌恢复要好几秒。4.3 CUBIC 和 BBR 的取舍Linux 默认的拥塞控制算法是 CUBIC它是基于丢包的。核心思路是用一个三次函数来描述窗口增长让它在远离拥塞点时增长快、接近时增长慢同时具备较好的 RTT 公平性。CUBIC 在大多数场景下表现稳定但有两个明显短板一是在有随机丢包的链路上比如无线它会把随机丢包误判为拥塞不必要地降低窗口二是它依赖丢包作为信号缓冲区大的链路上容易把 buffer 填满造成 bufferbloat延迟飙升。BBR 走的是另一条路不把丢包当拥塞信号而是持续测量瓶颈带宽和最小 RTT建立网络模型按模型决定发送速率。它的优势在高丢包、长距离链路上非常明显能把吞吐提升数倍同时因为不填满缓冲区排队延迟也更低。但 BBR 不是银弹。它在多流竞争时公平性存在争议v1 版本对 loss-based 流不友好在浅缓冲区场景下优势不明显而且它需要较准确的带宽估计起流阶段会有探测开销。实际选择我的建议是默认用 CUBIC稳定可靠如果是跨地域长距离传输、或者链路上有明显非拥塞丢包可以试 BBR但要配合真实业务指标验证别只看单流压测数据。# 查看当前算法和可用算法 sysctl net.ipv4.tcp_congestion_control sysctl net.ipv4.tcp_available_congestion_control # 临时切换 sysctl -w net.ipv4.tcp_congestion_controlbbr # 持久化写入 echo net.ipv4.tcp_congestion_control bbr /etc/sysctl.conf4.4 缓冲区参数怎么定net.ipv4.tcp_rmem和net.ipv4.tcp_wmem是两个三元组分别表示最小、默认、最大值。自动调优开启时tcp_moderate_rcvbuf1内核会在 min 和 max 之间动态调整。这里有个经典的计算公式用来估算单条连接需要的缓冲区大小缓冲区 带宽 × RTT比如跨地域专线带宽 100MbpsRTT 40ms那 BDP 100Mbps × 0.04s 4Mbit 500KB。缓冲区至少要能装下 500KB才能让窗口充分打开、跑满带宽。如果 max 设得比这个小单连接速度就被卡死了。很多发行版默认的tcp_rmem最大值是 6MB 左右对大多数场景够用。但在高带宽高延迟链路上可能要调到 16MB 甚至更大。调大之前想清楚内存开销最大缓冲是 per-connection 的连接数上万时内存占用不容忽视。提示tcp_wmem的第三个值影响发送缓冲它决定了单连接最多能缓存多少未发送数据。如果应用是大量小写入过大的发送缓冲会导致数据在本地排队增加延迟。tcp_notsent_lowat可以限制未发送数据量适合延迟敏感的服务。5. 应用层最容易踩的三个坑5.1 粘包不是 TCP 的错TCP 粘包这个说法其实不准确TCP 是字节流协议它压根没有包的概念自然谈不上粘。所谓粘包本质是应用层没有定义消息边界。举个例子客户端连续调用了两次 send分别写入 hello 和 world。这两次调用可能触发两个 TCP 段也可能合并成一个段发出去还可能被拆成 hel 和 loworld。接收端 read 的时候一次可能读到 helloworld也可能读到 hello 然后第二次读到 world。这些组合完全取决于内核缓冲、MSS、Nagle 算法和网络状况应用层无法预测。解决办法只有三条路选一条就行定长消息。每条消息固定长度接收端按长度切。简单但有浪费适合消息大小高度一致的场景。分隔符。用特殊字符如换行符分隔接收端一直读到分隔符为止。HTTP 头、Redis 协议都这么干。缺点是分隔符不能出现在正文里需要转义。长度前缀。消息头里先写长度接收端先读长度再读正文。这是最通用的方案绝大多数 RPC 框架都用这个。实现时要注意长度字段本身的字节序和大小端约定。import struct def send_message(sock, payload: bytes): # 4 字节大端长度前缀 正文 header struct.pack(I, len(payload)) sock.sendall(header payload) def recv_exact(sock, n: int) - bytes: buf b while len(buf) n: chunk sock.recv(n - len(buf)) if not chunk: raise ConnectionError(连接在读取过程中关闭) buf chunk return buf def recv_message(sock) - bytes: header recv_exact(sock, 4) length struct.unpack(I, header)[0] return recv_exact(sock, length)这段代码的关键在recv_exact这个循环。recv返回的数据长度是不确定的可能少于请求的字节数必须循环读满。我见过太多新手直接sock.recv(1024)然后假设读到了完整消息在本地测试永远没问题一上生产就偶发解析失败。5.2 Nagle 算法与延迟确认的 40ms 叠加Nagle 算法的初衷是减少小包。规则是如果还有未确认的数据在飞就把新的小数据攒起来等确认回来再发。这在小包密集的场景下能显著减少报文数。延迟确认是接收端的行为收到数据后不立刻回 ACK而是等一小会儿通常 40ms 到 200ms期望能捎带在反向数据里一起发减少纯 ACK 报文。这两个机制单独看都很合理放在一起就出问题了。经典的交互场景是这样的客户端发了小包 A服务端收到了但延迟确认客户端有数据 B 要发但 Nagle 说前面还有没确认的等着于是 B 卡住了服务端等到 40ms 超时才回 ACK客户端收到 ACK 后才发 B。一次交互多出 40ms一个页面有十几次这种交互白屏时间就多出半秒。解决办法很简单对延迟敏感的服务设置TCP_NODELAY关闭 Nagle。同时应用层尽量把小的写操作合并成大块发送从源头减少小包数量。import socket sock socket.socket(socket.AF_INET, socket.SOCK_STREAM) sock.setsockopt(socket.IPPROTO_TCP, socket.TCP_NODELAY, 1) sock.connect((127.0.0.1, 8080))需要注意TCP_NODELAY不是什么场景都该开。批量传输大数据的场景Nagle 合包反而能提高效率。判断标准是你的业务对单次交互延迟敏感吗敏感就开。5.3 长连接的假死与保活设计中间设备负载均衡、防火墙、NAT 网关为了节省资源会主动清理长时间空闲的连接。清理通常不发任何通知两端都以为连接好好的直到下一次发数据才发现对面早就没了。这就是连接假死。TCP 自带的 keepalive 能缓解这个问题但默认参数非常保守空闲 7200 秒才开始探测间隔 75 秒探测 9 次才判定断开。这意味着最坏情况下要两个多小时才能发现连接已死对线上服务完全不可接受。# 缩短 TCP keepalive 探测 net.ipv4.tcp_keepalive_time 600 net.ipv4.tcp_keepalive_intvl 30 net.ipv4.tcp_keepalive_probes 3 # 应用层单独设置但更推荐的做法是在应用层做心跳。原因有三一是不依赖内核参数跨语言跨平台一致二是心跳包可以携带业务信息比如版本号、负载情况三是心跳周期可以灵活调整不受全局内核配置约束。应用层心跳的设计有几个要点。周期要小于中间设备的最短空闲超时通常取 30 到 60 秒比较稳妥心跳要有超时判定连续丢几个就算断开心跳失败后要能主动重连而不是等下一次业务请求才发现。我踩过的一个坑是心跳只做了发送没做响应校验结果对端进程卡死但内核还在回 ACK心跳包正常发出连接看起来健康业务请求却全部超时。后来补了双向确认才解决。6. 抓包排查实录与常见问题速查6.1 抓包环境怎么搭排查 TCP 问题抓包是绕不开的。我的习惯是先在出问题的机器上抓如果能看到两端就更好。常用的命令组合如下# 抓指定端口的所有流量写入文件 tcpdump -i any -s 0 -w /tmp/tcp.pcap tcp port 8080 # 只看握手和挥手过程 tcpdump -i any -n tcp[tcpflags] (tcp-syn|tcp-fin|tcp-rst) ! 0 # 实时显示带序号和窗口 tcpdump -i any -n -S -vv host 10.0.0.1 and port 8080几个实操经验。-s 0是抓完整包不截断不然分析时可能缺数据-i any会抓到所有网卡但可能产生重复包分析时注意去重生产环境抓包要限制文件大小和数量-C和-W参数配合使用避免把磁盘写满。抓下来的文件用图形化工具打开看会更直观。重点看几个东西有没有 RST、有没有重复 ACK、窗口是不是持续为 0、重传间隔是不是符合指数退避规律。这些特征基本能定位大部分问题。6.2 三类典型故障的排查路径故障一连接建立慢偶发超时。先看握手耗时。如果 SYN 发出后很久才收到 SYNACK说明服务端处理不过来或者中间链路有问题。查服务端的半连接队列和全连接队列是否溢出同时确认 backlog 配置。如果 SYNACK 发出后客户端没回 ACK通常是客户端侧的问题。排查顺序我一般这样走查ss -lnt看队列、查netstat -s看溢出计数、查服务端 accept 线程是否被业务阻塞。有一次定位到的问题就是 accept 之后立刻做了个同步数据库查询导致队列消费速度跟不上。故障二传输过程中卡顿吞吐上不去。先分清是 cwnd 受限还是 rwnd 受限。抓包看窗口字段如果窗口一直是满的但数据不动可能是拥塞控制把 cwnd 压下来了去看有没有重传。如果窗口本身就在变小那是接收端应用读得慢缓冲区积压。再往下查可以看ss -ti的输出里面有 cwnd、rtt、retrans 等详细指标ss -tin dst 10.0.0.1 # 输出里关注 # cwnd:10 拥塞窗口 # rtt:0.5/0.2 平滑 RTT / 偏差 # retrans:0/12 当前重传数 / 总重传数 # send 1Mbps 发送速率故障三连接大量堆积在某个状态。用一条命令就能看清分布ss -ant | awk NR1 {print $1} | sort | uniq -c | sort -rnTIME_WAIT 多看是不是短连接用太多CLOSE_WAIT 多几乎肯定是应用没关连接去代码里找异常路径ESTABLISHED 多且不降可能是连接池泄漏或者下游响应慢导致的堆积SYN_RECV 多考虑是不是有攻击或者服务端处理能力不足。6.3 常见问题速查表现象最可能的原因排查动作处理方向偶发连接超时全连接队列溢出ss -lnt看 Recv-Q调大 backlog 和 somaxconn大量 CLOSE_WAIT应用未关闭连接查代码异常分支补 finally 或 try-with-resources大量 TIME_WAIT短连接过多统计连接生命周期改长连接或连接池传输 40ms 卡顿Nagle 延迟确认抓包看小包间隔开 TCP_NODELAY吞吐远低于带宽窗口太小或丢包ss -ti看 cwnd 和重传调缓冲区或排查链路连接假死无响应中间设备清理了会话长时间空闲后发探测包加应用层心跳数据解析错乱消息边界未定义检查收包逻辑用长度前缀协议单连接速度上不去缓冲区小于 BDP计算带宽乘 RTT调大 tcp_rmem/tcp_wmem6.4 几个我踩过的坑第一个坑是关于tcp_tw_reuse的。这个参数只对主动发起连接的一方生效服务端作为被动方是不受影响的。当年有个服务端 TIME_WAIT 堆积的问题我信心满满地开了这个参数结果毫无效果白白浪费了半天。后来才想明白服务端要减少 TIME_WAIT得让客户端主动关闭或者改用长连接。第二个坑是 Nagle 和延迟确认的叠加。这个前面提过但实际排查时的难度在于40ms 这个数字太有迷惑性了看着像 GC 停顿、像锁竞争、像磁盘 IO唯独不像网络。我是靠抓包看到每次请求间隔精确的 40ms 才锁定方向的。所以记住这个特征值如果是稳定的、精确的几十毫秒延迟优先怀疑 Nagle。第三个坑是 SACK 的误判。有一次线上重传率异常高我第一反应是链路质量差查了半天运营商和设备都没问题。后来抓包细看发现是中间某台设备的 TCP 选项处理有问题把 SACK 块给丢了导致发送端只能按累积确认的方式反复重传已经到达的数据。这种中间设备的问题最难查因为你在两端抓包都是对的问题出在中间。第四个坑是缓冲区调优的方向搞反了。有段时间某服务单连接吞吐上不去我直接把tcp_wmem最大值拉到 64MB结果毫无改善还占了内存。后来用ss -ti一看cwnd 只有 10根本没打开说明是丢包导致的拥塞窗口压制跟缓冲区一点关系都没有。这个教训是调参之前先测量别凭感觉。再补一个实践中的小技巧。看ss -s的汇总输出能快速掌握全局它按状态统计了所有 socket 数量还能看到内存占用和 TCP 各状态计数。我习惯把它做成定时采集配合历史曲线看趋势比单次快照有用得多。有一次就是靠趋势图发现某个服务每半小时 CLOSE_WAIT 涨一截对上了下游服务定时重连的周期很快就定位到对方重连逻辑里没关旧连接。TCP 这套机制设计于几十年前到现在依然是绝大多数业务的默认选择本身就说明了它的工程质量。但它不是黑盒那些藏在协议细节里的坑只会在你最不希望的时候冒出来。我个人的体会是与其等问题发生了再去翻文档不如平时就把ss、tcpdump这些工具用熟把重传率、连接状态分布这些指标纳入日常监控。等到真出故障的时候你手里有数据心里才有底。
锦
锦皓数字建站
深耕本土企业品牌数字化升级,专注原创端正雅致商务官网,从视觉设计到稳定运维全程保驾护航。