
1. 为什么一个写PHP的老哥要去啃TCP重传前阵子在一个PHP技术群里看到有人发了一句特别有共鸣的话接口平时几十毫秒就返回突然某一天开始偶尔要卡上3秒、10秒甚至等到程序直接报Connection timed out。自己本地怎么测都正常线上就是偶发在业务代码里加了重试之后第一次失败第二次又成功了。业务日志干干净净没有慢查询没有死锁Redis和MySQL也都看着正常。这种情况下问题大概率不在PHP代码里而在TCP层——准确地说在TCP的重传机制里。TCP重传是TCP协议保证可靠传输的核心手段发送端只要没在预期时间内收到对方的确认应答也就是ACK就会认为这个数据段丢了重新发送。PHP作为一门应用层语言并没有直接暴露重传的控制权但你用curl请求第三方接口、用PDO连MySQL、用Redis扩展连Redis、用Swoole写常驻内存的TCP服务端底层走的全是这一套机制。这篇东西就是一次“庖丁解牛”把TCP重传的关键原理拆开再落回PHP的实际代码和系统参数上让你以后遇到“玄学超时”时能自己判断是不是重传在搞事。这套内容适合谁适合写过一段时间的PHP、见过线上超时却不知道从哪儿查起的开发者。不需要你精通内核协议栈但至少得看得懂tcpdump的输出。我会把理论讲得够用再给出可以直接抄走的排障步骤。1.1 从PHP的视角看TCP你写的连接代码底层都在忙什么PHP开发者平时接触最多的是HTTP而HTTP跑在TCP上。这一层关系太容易被当成空气了。你执行一次curl请求内核会依次做三件事先用三次握手建立连接然后把请求数据切成一个个TCP段发送出去最后等待对方确认。如果某个数据段在中间丢了内核不会直接告诉PHP“刚才那段丢了”而是默默重传等到重传也救不回来那个socket才会以超时或者重置的方式报错。这里有一个特别关键、但又总被忽略的认知PHP不是TCP的掌控者只是使用者。你给curl设置一个5秒超时不代表每个网络包都会在5秒内重传完成。实际的重传节奏由系统协议栈决定5秒只是应用层给等待结果的时间上限。理解不了这一点后面所有的抓包和分析都会觉得像隔着一层雾。1.2 哪些PHP场景最容易踩到重传的坑我按踩坑概率给这些场景排个序。排第一的是长连接场景Swoole或Workerman写常驻TCP服务客户端断网、断电之后旧连接上可能还挂着未确认的数据。服务端发数据时触发重传重传次数耗尽才会释放连接如果业务里再叠加一个同步阻塞调用整个worker就会被拖死。排第二的是curl访问外部接口尤其是内网服务之间的调用。对方服务重启、防火墙丢包、半连接队列溢出表现全是“偶发超时”。排第三的是MySQL、Redis这类基础设施的持久连接。连接被网关回收但PHP进程不知道下一次请求正好命中一个死连接TCP层先重传再抛错。这几个场景没有一个能绕开重传机制。1.3 庖丁解牛的方法先看协议状态机庖丁解牛的技巧是顺着骨节下刀看TCP也得先看它的状态机。一次完整的TCP生命周期包含三次握手、数据传输和四次挥手每个阶段都有不同的重传行为握手阶段有SYN重传传输阶段有数据段重传和快速重传挥手阶段有FIN重传。所以排障时第一时间要确认连接正处于哪个状态再判断该去查哪一类重传参数。后面我会严格按照这个顺序展开。2. TCP重传到底是怎么被触发的超时重传与快速重传重传机制不是只有一种触发条件。从行为上看它分两大派超时重传和快速重传。把这两派搞清楚你就掌握了重传机制的主干。2.1 超时重传等不到ACK就重新发发送端发出一个TCP段之后会启动一个定时器这个定时器叫重传超时定时器对应的时间叫RTO全称Retransmission Timeout。如果在RTO内没有收到对那个段的ACK发送端就认为段丢了重新发送一次同时RTO按指数退避增长第一次重传等1秒第二次变成2秒第三次4秒直到达到上限或者达到最大重传次数。举个例子。客户端发了一个POST请求体服务端其实已经收到并且处理完了但回程的ACK在网络里丢了。客户端不知道傻等到RTO到期之后重传POST数据。服务端看到重复数据会直接丢弃并重新回一个ACK客户端收到后继续正常流程。在这个过程中用户会察觉一次明显的卡顿但是应用层日志什么都查不到。这就是“玄学超时”最常见的原型。2.2 快速重传三个重复ACK不等定时器直接重发超时重传有一个明显的缺点等待RTO太久了尤其在往返时间很长的网络上。TCP还有一个更聪明的机制叫快速重传。接收端如果收到乱序数据比如发送端发了1、2、3三个段接收端只收到1和3它会立刻重复确认“我还在等2号段”连续发3个重复的ACK。发送端一看到连续3个重复ACK就认为2号段丢了不等RTO到期立即重传2号段。快速重传能大幅减少等待时间但它的前提是丢包之后还有后续数据能触发乱序确认。如果丢的是最后一个数据段后面没有包了那还是只能靠超时。这里要纠正一个常被搞混的点。SACK选项出现之前TCP重传是“一锅端”发送端会把从丢失位置开始的所有数据全部重传。有了SACK也就是Selective Acknowledgment选择性确认接收端就能精确告诉发送端我只缺2号段3、4、5都收到了。发送端因此可以只重传缺失的那一段效率高很多。D-SACK更进一步接收端如果发现之前已经收到过某个数据段又被重传了会把这个信息带在SACK里发出去帮助发送端判断是否存在虚假重传或路由问题。这几个名词你看着陌生等会看tcpdump输出里的SACK标志就不会犯怵了。2.3 RTO计算不是拍脑袋定的RTO不是一个固定值它是根据网络往返时间动态算出来的。经典算法由RFC 6298定义内核维护一个平滑后的RTT叫SRTT同时维护RTT的波动值RTTVAR最后得出RTO SRTT max(4 x RTTVAR, 1ms)。Linux的初始RTO一般是1秒网络越好、抖动越小RTO越小网络越慢、越不稳定RTO会自动放大。这就是为什么同一个接口在办公室内网和公网上的超时表现完全不一样。要算准RTT就得有可靠的时钟参照。TCP时间戳选项对应RFC 7323就是干这个的。它让每个TCP段携带一个发送时刻接收端回显发送端就能算出一个精准的RTT样本还能顺便解决重传ACK的歧义问题。Windows上那句“netsh int tcp set global timestampsenabled”和Linux下的tcp_timestamps参数都是开启这个能力。很多人不知道它和重传的关联有多深其实它直接影响RTO计算的准确性开没开对排障方向影响很大。2.4 握手与挥手阶段的重传SYN重传和FIN重传三次握手阶段客户端发出SYN包之后如果一直没等到SYN-ACK内核会按照tcp_syn_retries参数指定的次数重复发送SYN默认是6次间隔也是指数退避。四次挥手阶段主动关闭的一方发出FIN包后如果没收到ACK内核会按照tcp_orphan_retries参数重复发送FIN。这两个阶段的重传时常被忽略因为问题都表现为“连不上”或者“关不掉”很少有人会往重传方向想。但如果你在服务器上看到大量SYN_SENT状态的连接或者大量TIME_WAIT状态的连接堆积不去大概率就是这两类重传在背后作祟。3. 实操在PHP身边把TCP重传“现场直播”出来理论说再多不如亲手抓一次重传。这一节我会用PHP原生socket写一个极简TCP服务端然后用网络工具人为制造丢包再抓包看重传现场。整套流程在Linux服务器上操作即可PHP版本不用太挑PHP 7以上都行。3.1 用PHP撑起一个最简TCP服务端先贴一段可以直接跑的PHP代码。它监听9501端口收到客户端数据后原样回包。用stream_socket_server就能实现完全不用装扩展。?php // server.php $host 0.0.0.0; $port 9501; $server stream_socket_server(tcp://$host:$port, $errno, $errstr, STREAM_SERVER_BIND | STREAM_SERVER_LISTEN); if (!$server) { die(无法启动$errstr ($errno)\n); } echo TCP server listening on $host:$port\n; while ($conn stream_socket_accept($server, 30)) { echo 连接建立 . stream_socket_get_name($conn, true) . \n; $data fread($conn, 8192); echo 收到数据 . trim($data) . \n; fwrite($conn, echo: . $data); fclose($conn); }php server.php这就是一个真实的TCP服务端。PHP写TCP服务端的性能确实比不上Swoole但用来做机制验证完全够用。另外开一个终端跑客户端可以用nc也可以用下面这段PHP?php // client.php $fp stream_socket_client(tcp://127.0.0.1:9501, $errno, $errstr, 5); if (!$fp) { die(连接失败$errstr ($errno)\n); } fwrite($fp, hello tcp); echo fread($fp, 8192) . \n; fclose($fp);这里必须提醒一句PHP的fread默认是阻塞读没有数据会一直等。生产代码里一定要搭配stream_set_timeout否则对方不回复进程会一直挂在那里。下面这个改法先记住。stream_set_timeout($fp, 5); // 5秒内必须有数据3.2 人为制造丢包抓拍重传现场让服务器网卡随机丢掉20%的包然后用tcpdump抓包是最直观的一步。# 在服务器网卡上注入20%随机丢包 tc qdisc add dev eth0 root netem loss 20%# 抓包过滤TCP 9501端口 tcpdump -i eth0 -nn -v port 9501 -w retrans.pcap然后运行PHP客户端连续多发几次请求。tcpdump输出里会出现类似这样的行23:10:42.123456 IP 192.168.1.10.43826 192.168.1.20.9501: Flags [S], seq 123, ... 23:10:43.123456 IP 192.168.1.10.43826 192.168.1.20.9501: Flags [S], seq 123, ...同一个seq的SYN再次出现就是SYN重传。如果是数据段Wireshark或tcpdump会直接标注[TCP Retransmission]。看完之后记得把丢包恢复tc qdisc del dev eth0 root netem loss 20%有些人看完就算完了但我多啰嗦一句20%丢包率对TCP来说已经算是灾难级别应用会表现为大量超时和重试。真实网络环境下丢包率超过1%就值得高度警惕时延和重传率都要持续盯着。3.3 如何在PHP应用层感知“正在重传”PHP代码里看不到重传那怎么判断它在重传核心看三个间接信号。第一个是耗时分布正常请求只用2毫秒突然出现一批200毫秒、1.2秒、2.5秒的耗时并且间隔看起来像指数增长基本就是RTO退避的节奏。第二个是报错文案Connection timed out对应内核放弃了重传大概率是tcp_retries2耗尽Connection reset by peer对应对端主动发了RST可能是端口没监听也可能是对端应用直接崩了。第三个是系统指标用ss命令看当前连接的RTO、RTT和重传次数。ss -ti state established ( dport :9501 or sport :9501 )输出里的rto: 1.04表示当前重传超时时间retr: 2表示已经重传过2次。把这些信息跟PHP日志里的耗时和报错对上号应用层和协议层的因果链就打通了。4. 系统层参数与PHP场景配置到了排查阶段你必然要动系统参数。这一节把Linux和Windows两侧的关键参数讲清楚再给出PHP场景的配置建议。4.1 Linux下控制重传行为的几个核心参数Linux把TCP参数集中在/proc/sys/net/ipv4/下用sysctl可以临时改永久修改要写/etc/sysctl.conf。跟重传最直接相关的是这几个tcp_syn_retries默认6控制三次握手的SYN重传次数。大量SYN_SENT状态的连接反复重传时可以适当调小。tcp_synack_retries服务端回应SYN-ACK的重传次数也要一起看。tcp_retries2默认15控制已建立连接上数据重传的最大次数。调小可以让连接更快失败调大能让长连接扛住瞬时抖动。tcp_fin_timeout和FIN重传、TIME_WAIT回收有关连接关闭异常时要检查。tcp_keepalive_time、tcp_keepalive_intvl、tcp_keepalive_probes这是保活探测不是重传机制。很多人把它们混为一谈。保活只在连接空闲时定期发探测包而重传管的是“有数据发出去但没收到确认”的场景两者一定要分开。# 查看当前值 sysctl net.ipv4.tcp_syn_retries net.ipv4.tcp_retries2 # 临时修改 sysctl -w net.ipv4.tcp_syn_retries3默认值不一定适合每个业务。内网RPC接口说话间就要出结果宁可快速失败也别让用户等15次重传跨公网传大文件的下载服务反而希望连接多扛一会儿。参数没有标准答案只有适合当前业务的答案。4.2 Windows下netsh int tcp set global timestampsenabledWindows没有sysctl那一套对应的命令是netsh int tcp set global。热搜里那句netsh int tcp set global timestampsenabled意思是启用TCP时间戳选项。开启之后Windows的TCP协议栈能更准确地测量RTTRTO计算也会更准从而减少“数据其实到了只是ACK晚到”导致的伪重传。执行前需要管理员权限执行后可以用show命令确认netsh int tcp set global timestampsenabled netsh interface tcp show global有一个要注意的坑时间戳选项会写进每个TCP包头平白多出12字节开销。如果中间链路存在旧防火墙或者负载均衡设备不理解这个选项甚至可能直接把带时间戳的包丢掉导致连接完全建立不起来。所以开启之后一定要小流量灰度验证别在生产全量机器上一把梭。Windows上还有一个相关参数initial RTO存在感比timestamps低一些但排障时也值得查。4.3 给PHP跑的几个直接建议在PHP侧能配置的是超时和保活控制不了重传次数但把几项组合起来效果很明显。curl请求外部接口时连接超时设置5秒内总超时看业务容忍度。CURLOPT_CONNECTTIMEOUT管的是三次握手阶段CURLOPT_TIMEOUT管整个请求时间。$ch curl_init(); curl_setopt($ch, CURLOPT_URL, http://api.example.com/health); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($ch, CURLOPT_TIMEOUT, 10); curl_setopt($ch, CURLOPT_TCP_KEEPALIVE, 1); curl_setopt($ch, CURLOPT_TCP_KEEPIDLE, 60); curl_exec($ch); curl_close($ch);常驻Worker连MySQL时PDO的PDO::ATTR_TIMEOUT只能管连接阶段真正有效的是MySQL侧的wait_timeout和PHP进程里的连接探活。Swoole或Workerman的TCP服务端建议在心跳逻辑里定期主动发送探测包及时发现死连接而不是等下一次业务数据触发重传。第5节会专门讲这些场景怎么定位。5. 常见问题与排查技巧实录最后这部分实战味道最浓全是能直接抄走的经验。5.1 常见问题速查表现象大概率原因排查方向偶发请求耗时按1s/2s/4s递增RTO指数退避网络丢包或ACK延迟tcpdump看重传ping看丢包检查对端负载连接失败报Connection timed outSYN重传耗尽或tcp_retries2耗尽看SYN_SENT状态检查防火墙是否丢包连接被重置报Connection reset by peer对端RST端口未监听或应用崩溃ss看CLOSE_WAIT检查对端进程PHP长连接用着用着突然崩了网关回收连接TCP层重传失败开keepalive加连接探活捕获Swoole断连回调内网传大文件频繁重传MSS/MTU问题或底层丢包抓包看TCP segment too large检查MTU这些行不是空话都是我对表排查过的问题。但一个现象背后可能有好几个原因表格只是入口具体定位还得靠抓包。5.2 一次真实排障复盘Harbor推送失败背后的TCP重传有段时间开发环境频繁出现Docker镜像推送失败报错信息里有dial tcp 192.168.209.133:443: ...这种片段典型的TCP层问题。当时很多人第一反应是改Docker配置、重装Harbor但问题照旧。我在服务器上用tcpdump抓包看到客户端发出SYN后一直收不到SYN-ACK而是隔一会重复发SYN。这是标准的三次握手超时重传。继续往防火墙那层深挖发现防火墙规则把那个端口对应的高位端口范围拦截了只放行了80/443的一小段。调整规则之后推送立刻恢复。这个案例的教训很值钱看到dial tcp别急着怀疑应用先确认TCP握手本身通不通。5.3 避坑清单与独门经验最后分享几条我认为特别重要的经验。第一抓包要在客户端和服务端两头同时抓。只抓一端容易误判。例如A端发了数据没收到ACK到底是B端没收到还是B端回了ACK但中途丢了两头一对比立刻清楚。第二不要忽略时间戳选项。很多老旧服务端默认没开timestampsRTO估算粗糙一遇到网络抖动就重传。有条件就开但记得先灰度。第三重试逻辑一定要带退避和随机扰动。如果PHP业务自己加了重试而TCP本身也在指数退避两层重试叠加会造成雪崩。第四监控别只看成功率要加一个重传率指标。Linux上可以从snmp的TcpRetransSegs统计或者用ss -ti抽样看retr次数。第五遇到灵异超时先看时间戳再查两台机器时钟是否同步。TCP时间戳依赖可靠时钟NTP对不齐会把RTT样本带偏重传判断全乱。说句实在话TCP重传机制并不玄学它只是一套“丢包之后怎么补救”的规则。PHP开发者平时写业务代码感觉不到它是因为Linux内核把这些细节都藏起来了。只要你能把思路切换成内核视角下回再遇到接口时不时卡一下就不会再一头雾水地改业务代码了。去看看ss -ti亲手抓一次包重传这件事很快就会从玄学变成你工具箱里一个普通的排查项。