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 timestamps=enabled”,和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_retries=3默认值不一定适合每个业务。内网RPC接口说话间就要出结果,宁可快速失败也别让用户等15次重传;跨公网传大文件的下载服务,反而希望连接多扛一会儿。参数没有标准答案,只有适合当前业务的答案。
4.2 Windows下:netsh int tcp set global timestamps=enabled
Windows没有sysctl那一套,对应的命令是netsh int tcp set global。热搜里那句netsh int tcp set global timestamps=enabled,意思是启用TCP时间戳选项。开启之后,Windows的TCP协议栈能更准确地测量RTT,RTO计算也会更准,从而减少“数据其实到了,只是ACK晚到”导致的伪重传。
执行前需要管理员权限,执行后可以用show命令确认:
netsh int tcp set global timestamps=enabled 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 out | SYN重传耗尽或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但中途丢了?两头一对比,立刻清楚。第二,不要忽略时间戳选项。很多老旧服务端默认没开timestamps,RTO估算粗糙,一遇到网络抖动就重传。有条件就开,但记得先灰度。第三,重试逻辑一定要带退避和随机扰动。如果PHP业务自己加了重试,而TCP本身也在指数退避,两层重试叠加会造成雪崩。第四,监控别只看成功率,要加一个重传率指标。Linux上可以从snmp的TcpRetransSegs统计,或者用ss -ti抽样看retr次数。第五,遇到灵异超时,先看时间戳,再查两台机器时钟是否同步。TCP时间戳依赖可靠时钟,NTP对不齐会把RTT样本带偏,重传判断全乱。
说句实在话,TCP重传机制并不玄学,它只是一套“丢包之后怎么补救”的规则。PHP开发者平时写业务代码感觉不到它,是因为Linux内核把这些细节都藏起来了。只要你能把思路切换成内核视角,下回再遇到接口时不时卡一下,就不会再一头雾水地改业务代码了。去看看ss -ti,亲手抓一次包,重传这件事很快就会从玄学变成你工具箱里一个普通的排查项。