ARTICLE DETAIL

资讯详情

深耕编程入门与网站建设的一线实战洞察。

PHP开发者必须懂的TCP重传机制:从RTO到抓包排查实战

PHP开发者必须懂的TCP重传机制:从RTO到抓包排查实战 前段时间复盘一起线上事故场景非常典型PHP 接口平时 50 毫秒内返回但每天总有那么几次要卡上 5 到 8 秒客户端等不及直接报超时。监控、日志、慢查询全部查过PHP 侧没有任何慢代码MySQL 也没有锁。最后抓包才发现问题根本不在应用层而在内核的 TCP 重传上——一条数据包在半路丢了内核要等 RTO 超时才重传一次没成功再翻倍等几次下来几秒钟就没了。这次之后我才意识到TCP 重传不只是网络工程师的功课PHP 程序员同样需要把它庖丁解牛一样拆开看明白。这篇文章就从重传机制的来龙去脉讲起把 PHP 应用与它的接触点、抓包分析方法、以及一套可落地的排查优化手段完整拆一遍。1. 先看骨头TCP重传机制到底在解决什么问题1.1 IP协议的“尽力而为”与TCP的“不许撒谎”TCP 是运行在 IP 之上的可靠传输协议但 IP 本身是个“尽力而为”的协议它只管把数据报往目标地址送不保证不丢、不保证不乱序、甚至不保证不重复。这就像你让同城快递送一份文件快递员路上可能遇到暴雨、堵车、分拣错误文件可能被临时搁置甚至弄丢发件人完全不知情。TCP 要在这个不靠谱的底座上提供“不许撒谎”的可靠字节流核心手段就是确认与重传发送方每发出一个报文段都期望在一定时间内收到接收方的 ACK如果没等到就判定数据可能丢了于是重新发送。这个“重新发送”的动作就是重传机制的本体。需要先说明的是TCP 重传不是应用层能直接触发的它完全由操作系统内核的 TCP 协议栈自动管理。PHP 进程只是把数据交给内核的 socket 缓冲区后续的拆包、序号管理、确认、超时计算、重传全部是内核的事。这也是为什么很多 PHP 开发者遇到网络问题时觉得“无从下手”——因为你写的代码根本没有直接参与这个过程排查问题自然就变成了透视内核行为的过程。1.2 三种重传形态RTO超时、快速重传、选择性重传内核里的重传策略并不是只有一种而是在不同场景下采用不同策略理解这三者的差异你才能读懂抓包里那些“奇奇怪怪的重复包”。第一种是RTO 超时重传也是最基础的一种。发送方为每个发送但未确认的报文段设一个定时器计时器到期还没收到 ACK就重传。这个 RTORetransmission Timeout不是拍脑袋定的而是根据实时测量的 RTT往返时延动态计算的。内核维护两个关键统计量平滑后的 SRTT 和 RTT 的方差 RTTVARRTO 大致等于 SRTT 加上 4 倍 RTTVAR 的余量。网络抖动越大RTO 就会被算得越长避免误判。一旦发生超时重传内核会把 RTO 翻倍也就是指数退避连续失败时会变成 200ms、400ms、800ms、1.6s 这样翻着等这正是前面那个线上事故“一次比一次慢”的来源。第二种是快速重传。如果只是丢了中间某个报文段接收方后续收到的数据是不连续的它会立刻针对“已经收到的最后一个连续字节”回复重复的 ACK。发送方连续收到 3 个重复 ACK 时基本可以确定后续数据丢了于是不等 RTO 定时器立刻重传丢失的报文段。这个机制比干等 RTO 快得多所以叫“快速重传”。第三种是选择性重传SACK。早期的 TCP 一旦丢包发送方往往要把丢失点之后的所有数据都重传一遍非常浪费。有了 SACK 选项之后接收方可以在 ACK 里明确告诉发送方“我收到了哪些不连续的块”发送方只补发真正的空洞。对于高带宽、大窗口的链路SACK 的收益极为明显。1.3 重传判定的细节序号、重复ACK与时间戳选项重传判断的核心数据是 TCP 报文头里的序号SEQ和确认号ACK。每个字节都有序号ACK 号表示“我期望收到的下一个字节序号”。如果某个 ACK 号重复出现说明对端没有收到更新的数据这就构成了快速重传的判定条件。这里要专门提一下 TCP 时间戳选项Timestamps。这个选项在 RFC 7323 中定义有两个作用一是让发送方通过报文里的 TSval 和 TSecr 精确计算 RTT而不需要在 ACK 里额外做往返测量二是防止序列号回绕PAWS在网络带宽极高、序号 32 位不够用的情况下保护连接不被旧报文干扰。时间戳开启后内核的 RTT 采样更精准RTO 的估算也更贴合真实网络间接影响重传时机。后面讲 Windows 调优时我会再回到这个选项上。另外现代 Linux 内核在重传恢复上还有两个新武器TLP尾丢探针和 RACK。TLP 是在怀疑“最后一段数据可能丢了”时主动发一个探测包避免傻等 RTORACK 则用每个报文段的实际 RTT 信息来判定丢失逐步取代老的“3 个重复 ACK”触发逻辑。掌握这些再看新版本内核下的抓包就不会懵。2. PHP视角你的代码与重传机制的接触点2.1 PHP网络编程的三条路PHP 要做网络通信最常见的是三条技术路线它们和内核 TCP 交互的层次略有不同。第一条是流式函数包括fsockopen、stream_socket_client、file_get_contents配合 http 包装器等。它们底层走 PHP 的 streams 抽象层操作简单但能控制的 TCP 参数有限最多通过stream_context_create设置超时通过stream_set_timeout设置读写超时。第二条是socket 扩展包括socket_create、socket_connect、socket_read、socket_write这一套实际上是对 C 语言 socket API 的封装。用它可以设置SO_KEEPALIVE、SO_RCVTIMEO等更底层的选项适合做长连接、自定义协议比如对接 Modbus TCP、游戏服务端这类场景。第三条是cURL 扩展底层是 libcurl它自己管理连接、SSL、重试策略但对 TCP 内核参数的暴露程度最低。你在 PHP 里能配置的通常是连接超时、总超时、是否复用连接句柄至于内核怎么重传cURL 并不关心。三条路线对应不同的控制力层级实际项目里往往是混用的HTTP API 用 cURL内部私有协议用 socket 扩展偶尔的简单探活用流式函数。2.2 一个PHP请求生命周期里TCP发生了什么以一个最简单的场景为例PHP 进程通过 cURL 请求外部 API。整个过程中 TCP 至少要经历三个阶段。第一阶段是连接建立三次握手。客户端发 SYN服务端回 SYNACK客户端再回 ACK。如果 SYN 或 SYNACK 丢失内核会按tcp_syn_retries的次数重发 SYN。这个阶段 PHP 的表现是connect()长时间不返回最终报“Connection timed out”连接超时或者 cURL 错误码 28。在 PHP 侧它对应CURLOPT_CONNECTTIMEOUT这个选项。第二阶段是数据传输。请求体发送、响应体接收都依赖内核维护的发送缓冲区和接收缓冲区。发送时数据进了内核缓冲区就算“成功”PHP 并不关心这些字节什么时候真正被对方收到接收时 PHP 阻塞在read()上直到内核缓冲区有数据或超时。如果中间发生丢包内核会静默重传PHP 侧通常毫无感知除非重传失败次数过多导致连接被内核放弃这时read()可能返回 0 或抛“Connection reset by peer”。第三阶段是连接关闭。正常情况下四次挥手主动关闭方发 FIN对方回 ACK 再回 FIN发起方回最后一个 ACK 后进入 TIME_WAIT。如果 FIN 丢了内核同样会重传。这个阶段最容易出问题的反而是 TIME_WAIT 状态的大量堆积——PHP-FPM 每个请求结束就关闭连接如果 Redis、MySQL 走短连接服务器上会攒下大量 TIME_WAIT 的 socket严重时会耗尽临时端口出现“Cannot assign requested address”新的连接根本建不起来。2.3 哪些PHP配置参数直接影响TCP行为PHP 层面能直接影响 TCP 交互的参数不算多但每一个都值得仔细掂量。default_socket_timeout是流式函数的默认读写超时默认 60 秒。这个值对所有fsockopen、file_get_contents生效如果你的页面强依赖外部服务60 秒的等待时间显然太长用户早就放弃了。cURL 这边CURLOPT_CONNECTTIMEOUT管连接阶段CURLOPT_TIMEOUT管整个请求的总时长。很多项目只设置了CURLOPT_TIMEOUT而没有设置连接超时结果是连接阶段可以无限等TCP 重传失败前应用层早已超时但错误信息可能误导排查方向。连接复用方面curl_init默认每次都是全新连接而curl_multi配合连接池或者在单进程内复用同一个句柄可以显著减少 TCP 建连次数。对数据库来说PDO 的PDO::ATTR_PERSISTENT和 mysqli 的持久连接则是双刃剑——省了握手但连接可能被服务端提前断开变成半开连接用之前必须校验。下面这段代码展示了我常用的一个“带超时和重试”的 HTTP 请求函数重点在于每层超时都显式设置function http_get_with_retry(string $url, int $maxTries 3): string { $delayMs 200; for ($i 1; $i $maxTries; $i) { $ch curl_init($url); curl_setopt($ch, CURLOPT_RETURNTRANSFER, true); curl_setopt($ch, CURLOPT_CONNECTTIMEOUT, 3); curl_setopt($ch, CURLOPT_TIMEOUT, 5); $body curl_exec($ch); $errno curl_errno($ch); curl_close($ch); if ($errno 0) { return $body; } // 指数退避避免所有进程同步重试造成“重传风暴” usleep($delayMs * 1000); $delayMs * 2; } throw new RuntimeException(http request failed after {$maxTries} tries); }3. 实操用tcpdump和Wireshark解剖一次重传3.1 搭建一个可复现丢包的实验环境纸上谈兵没有用要真的理解重传最好自己制造一次丢包。Linux 上可以用tc的 netem 模块模拟随机丢包非常方便。# 在 eth0 上模拟 10% 的随机丢包 tc qdisc add dev eth0 root netem loss 10% # 测试结束后删除规则 tc qdisc del dev eth0 root为了不让实验影响生产环境建议在虚拟机或者本机 loopback 上用两个不同端口做测试。我在本机起了一个简单的 PHP TCP 服务监听 9000 端口返回一段固定的 JSON再用另一段 PHP 脚本以客户端身份连接它并读取响应。对 loopback 接口加上 10% 丢包重传现象就会频繁出现。注意tc命令需要 root 权限。改完规则务必确认删除干净否则会一直丢包排查半天发现是自己埋的雷。3.2 tcpdump抓包与关键字段解读抓包命令本身很简单# 在 loopback 上抓 9000 端口流量保存到文件 tcpdump -i lo -nn -s0 -w retrans.pcap tcp port 9000 # 如果想实时看明细去掉 -w加上 -vv tcpdump -i lo -nn -vv tcp port 9000抓到包之后重点看几个字段SEQ 是否出现了“重复发送”ACK 号是否在原地踏步以及两次相同 SEQ 的报文之间的时间间隔。我用一个简化示意来描述典型的 RTO 重传时间线时间事件说明0.000s发送 SEQ1000, 长度 512原始报文0.200s未见 ACK内核重传 SEQ1000RTO 初值约 200ms0.600s仍未 ACK再次重传 SEQ1000RTO 翻倍到 400ms1.400s第三次重传 SEQ1000RTO 翻倍到 800ms1.400s收到 ACK1512恢复看到时间间隔呈 200、400、800 的翻倍模式基本可以断定是典型的 RTO 指数退避。如果抓包文件里出现大量“两两相邻”的相同 SEQ 报文且时间间隔都很短那多半是快速重传或 SACK 驱动的重传而不是超时重传。3.3 Wireshark过滤重传报文的几个黄金表达式把 pcap 文件拖进 Wireshark 之后不要肉眼翻包直接用分析过滤器tcp.analysis.retransmission所有超时重传的报文tcp.analysis.fast_retransmission快速重传的报文tcp.analysis.duplicate_ack重复 ACKtcp.analysis.ack_rtt配合查看响应时延打开 Statistics 菜单里的 RTT 图表能看到整个连接过程中 RTT 的波动如果 RTT 曲线出现突刺再叠加重传标记就能直观判断是哪一跳网络抖动引发的。我个人的习惯是先数重传报文的数量再看重传集中的时间点最后把时间点和 PHP 访问日志里的耗时异常点做对齐。只要对齐上了问题基本就锁定在 TCP 层剩下的就是找链路原因。3.4 从抓包结果反推根因有一次我排查一个 PHP 服务调用第三方支付接口偶发抖动的问题抓包之后发现重传集中在某一小段时间内而且全是快速重传这说明不是普遍的网络丢包而是某个中间设备在特定流量特征下丢包。后来和对方网络团队沟通确认是他们的负载均衡开启了某种限速策略对长连接上的突发流量直接丢弃。如果没有抓包这种问题是永远不可能通过看 PHP 日志定位到的。所以抓包不是“最后手段”而是应该和看日志同步启动的手段。你可以把 tcpdump 写到环形缓冲区等故障发生时再落盘这样既不影响性能也不会错过现场。4. 系统视角在服务器上如何量化重传4.1 Linux下的TCP统计命令除了抓包服务器上一些现成命令可以快速量化重传情况。# 查看TCP协议栈的统计信息 netstat -s | grep -iE retrans|retries # 更细粒度的内核统计 nstat -az | grep -i retrans # 查看当前已建立连接的 SRTT、RTO、拥塞窗口 ss -ti dport :3306 or sport :3306ss -ti的输出非常有用每一行对应一个连接里面直接显示srtt平滑 RTT、rto当前重传超时、cwnd拥塞窗口等字段。比如看到某个 MySQL 连接的rto已经涨到几秒说明这条连接刚刚发生过不止一次超时重传处于不健康状态。线上排查时我通常先跑netstat -s看全局还是局部问题如果重传数量占整体报文比例很低那只是零星丢包不用紧张如果短时间内重传数暴增再结合ss -ti找具体是哪类连接、连向哪个目标。4.2 Windows下的netsh tcp全局参数PHP 开发者的本机环境经常是 Windows系统级 TCP 参数可以通过netsh查看和修改其中的 Timestamps 选项和重传机制关系最直接。# 查看当前全局TCP参数 netsh int tcp show global # 启用TCP时间戳 netsh int tcp set global timestampsenabled # 恢复默认 netsh int tcp set global timestampsdisabled为什么时间戳影响重传前面说过TCP 通过时间戳选项优化 RTT 测量。当网络路径经过 NAT 网关、负载均衡等多跳链路时RTT 波动可能很大如果内核测得的 RTT 不准确RTO 就会被算得偏大或偏小。偏大导致故障时恢复慢偏小导致频繁误重传。在 Windows 上启用时间戳后RTT 采样更精确重传时机也更合理。注意netsh int tcp set global需要管理员权限执行。修改后建议用netsh int tcp show global确认生效。个别老旧网络设备对 TCP 选项敏感生产环境变更前先小流量验证。4.3 PHP日志与重传事件的关联分析系统统计只能告诉你“有重传”但是哪次 PHP 请求受到牵连需要把 PHP 日志和网络事件做时间对齐。具体做法是先记录故障发生的确切时间点然后往前推几分钟查看 tcpdump 抓包里重传报文的时间戳。PHP 侧可以提前做两件事一是打开 PHP-FPM 的慢日志request_slowlog_timeout设为 2 秒二是把 cURL 的错误码和耗时统一写入应用日志。有了这两份数据再加上网络抓包就能构建出完整的因果链某请求在 10:30:05 发出10:30:05.2 发生第一次超时重传10:30:06.8 重传成功请求在 10:30:07 返回耗时 2 秒——和用户感知完全吻合。这里有一个很容易踩的坑PHP 的错误日志默认只记到秒精度不够。排查网络问题时要确保日志时间戳精确到毫秒否则对齐会很困难。我通常会在日志格式里加上Y-m-d H:i:s.u。5. 常见PHPTCP问题排查实录5.1 问题一cURL偶发超时但应用层无异常现象是外部 API 调用每天忽快忽慢失败的请求错误码是 28操作超时但监控显示服务器 CPU、内存都正常。抓包后发现每次超时前都有一段典型的 RTO 指数退避200ms、400ms、800ms最后在应用层超时前内核还没重传成功。问题出在外网链路的偶发拥塞加上 RTO 初值偏大导致恢复时间被拉长。解决办法分两层应用层给 cURL 设置较短的连接超时3 秒和合理的总超时5~10 秒并对幂等请求做重试网络层和运营商确认链路质量必要时走多条线路做故障切换。我个人的经验是不要追求“永不超时”而是接受一定比例的超时用重试去消化。5.2 问题二PHP-FPM与Redis之间的半开连接现象是 PHP 请求偶尔卡死十几秒然后报read error on connection但 Redis 侧看起来一切正常。深入分析后发现Redis 侧因为空闲把连接断开了但由于某种原因 FIN 没有正常送达 PHP 所在服务器可能是中间设备静默丢弃PHP 侧不知道连接已死还傻等着读数据。内核在没有收到 RST 的情况下会继续等直到超时重传耗尽才关闭。解决办法有两个方向。一是应用层在每次复用连接前执行一次轻量探测比如PING二是内核层开启SO_KEEPALIVE并在系统参数里把tcp_keepalive_time调小让内核主动探测对方是否还活着。顺带说一句PHP 的普通流资源不能直接设置SO_KEEPALIVE需要借助socket_import_stream把流转成 socket 资源再设置$fp stream_socket_client(tcp://127.0.0.1:6379, $errno, $errstr, 3); if ($fp false) { throw new RuntimeException(connect failed: $errstr ($errno)); } stream_set_timeout($fp, 5); $socket socket_import_stream($fp); socket_set_option($socket, SOL_SOCKET, SO_KEEPALIVE, 1); // 正常读写... fwrite($fp, PING\r\n); $line fgets($fp); fclose($fp);5.3 问题三Nginx与PHP-FPM之间的FastCGI重连现象是 Nginx 偶尔报 504 Gateway Timeout但 PHP-FPM 慢日志里没有对应记录PHP 进程也没有忙碌迹象。排查发现Nginx 和 PHP-FPM 之间的 FastCGI 连接被复用时存在“陈旧连接”问题PHP-FPM 侧可能因为进程重启把连接关了Nginx 侧却以为还能用发请求过去后对面没有响应于是陷入等待。TCP 层面的表现是 RST 或长时间无响应。解决方法是三管齐下Nginx 配置里显式设置fastcgi_connect_timeout、fastcgi_read_timeout并开启fastcgi_keepalivePHP-FPM 侧检查listen.backlog是否过小同时把fastcgi的超时报错返回给应用层做重试。像 Harbor 推送镜像时报dial tcp 192.168.209.133:443: i/o timeout这类问题本质也是 TCP 连接阶段的重传超时排查思路完全一致。5.4 问题四TIME_WAIT堆积导致端口耗尽现象是高峰期新的 MySQL 连接报Cannot assign requested addressPHP 进程大量失败。原因很典型PHP-FPM 每个请求都新建 MySQL 短连接请求结束后主动断开大量连接进入 TIME_WAIT。默认情况下 TIME_WAIT 要等 60 秒才消失如果 QPS 很高临时端口很快会不够用。这个问题的解法有两个方向一是把短连接改成长连接或连接池减少建连和断连频率二是必要情况下调整内核net.ipv4.ip_local_port_range扩大端口范围或者开启net.ipv4.tcp_tw_reuse允许复用 TIME_WAIT 连接这只对主动发起连接的一方有效。但治本之策始终是减少连接创建次数而不是跟内核参数死磕。5.5 排查工具速查表现象优先排查方向常用命令/工具连接建立超时SYN 重传、目标端口不通tcpdump tcp[tcpflags] tcp-syn ! 0读写偶发卡顿RTO 退避、中间设备丢包Wiresharktcp.analysis.retransmission连接被重置RST 来源、对端主动关闭netstat -s | grep -i reset连接复用失败半开连接、keepalive 缺失ss -ti看 rto、srtt端口耗尽TIME_WAIT 堆积netstat -an | grep TIME_WAIT | wc -lWindows 本机网络异常TCP 全局参数netsh int tcp show global6. 优化实践让PHP应用与重传机制和平共处6.1 应用层能做的事超时、重试与熔断应用层无法直接调整内核的 RTO但可以通过合理的超时和重试策略把重传的影响控制在可接受范围内。核心原则有三条第一每层网络操作都要设超时连接超时和读写超时分清楚不要图省事只设一个总超时第二重试要带退避和抖动否则所有 PHP 进程在同一时刻重试会制造出人为的“重传风暴”第三对下游依赖要有熔断意识连续失败一定次数后直接降级或短路而不是无限重试把连接池打满。我在生产环境里对第三方 API 的重试策略通常控制在 3 次以内退避从 200ms 开始指数增长并叠加随机抖动。这个策略实测下来既能消化偶发丢包又不会在故障期间造成二次伤害。6.2 内核参数调整的正确姿势如果确认链路本身没有问题但服务器对故障的收敛太慢可以考虑调整内核 TCP 参数。# /etc/sysctl.conf 或 /etc/sysctl.d/99-tcp-tuning.conf net.ipv4.tcp_syn_retries 3 net.ipv4.tcp_synack_retries 2 net.ipv4.tcp_retries2 5 net.ipv4.tcp_keepalive_time 600 net.ipv4.tcp_keepalive_intvl 30 net.ipv4.tcp_keepalive_probes 3# 生效 sysctl -ptcp_syn_retries默认是 6 次折算下来连接失败可能要等一分多钟调小到 3 能让应用层的连接受控地快速失败配合 PHP 的连接超时反而更可控。tcp_retries2控制数据重传的最大次数默认 15 次意味着内核会磨蹭很久才放弃调小到 5~8 次可以让 PHP 更快感知到连接已死。但要明确一点内核参数是全局生效的会波及服务器上所有应用。调整前先确认这台机器只跑 PHP 服务调整后观察一段时间再固化。6.3 哪些操作要远离有一些网上流传的“优化技巧”我劝你谨慎。第一不要试图通过把 socket 读超时调到特别小来“快速失败”。超时太小会导致正常的慢响应也被误杀尤其在网络抖动大的场景下误判率会很高。超时应该基于 RTT 实测值留出 3~5 倍余量。第二不要在中低并发下盲目开启TCP_NODELAY。禁用 Nagle 算法确实能降低小包延迟但也会增加小报文数量在弱网环境下反而加剧拥塞。只有对交互性要求极高的短请求才值得开。第三不要认为开启了 keepalive 就万事大吉。Linux 默认的 keepalive 探测间隔长达 7200 秒不调参的话half-open 连接依然会挂很久。keepalive 只是一个兜底手段应用层的心跳和超时仍然不可缺少。我见过最典型的生产事故就是有人把tcp_retries2调成 3 之后恰好遇到一次瞬断结果所有数据库连接在几秒内集体断开PHP 服务直接雪崩。调参这件事永远要记得“让快速失败服务于应用层的重试策略”而不是为了快而快。结尾做了这么多年 PHP 开发我最大的体会是TCP 重传机制就像内核给所有应用上的一道保险它在绝大多数时候默默工作你完全感觉不到它的存在可一旦它开始频繁动作站在应用层的人往往一脸茫然因为代码没有问题日志也一片平静。真正解决问题的路径永远是先承认“应用层看不到不代表没有发生”然后靠抓包、系统统计、日志对齐这套组合拳把内核里的那台精密仪器拆开看清楚。几次实战下来你会慢慢形成一种直觉看到偶发超时报错第一反应不再是怀疑代码逻辑而是想“哪个报文又丢了”。这种直觉比背下所有 RFC 都值钱。
返回列表